12 спрайтов и 61 цвет: как нейросеть собрала пиксель-арт-маскота для сайта из черновиков Qwen Image 2.1

Нейросеть нарисовала ведьму, которая выглядит как пиксель-арт. Спрайтом она не является: в файле 1344×1344 и 40 318 уникальных цветов (это кадр read), а сетки, по которой эти «пиксели» выстроены, не существует. Это принципиальная разница, и она не косметическая: картинку «под пиксели» нельзя перекрасить в ночную палитру одной операцией, нельзя анимировать по кадрам и нельзя гарантировать, что при смене эмоции персонаж не дёрнется. Ниже — пайплайн, который превращает такие черновики в спрайты, где один пиксель — это ровно один цвет из палитры, и где это свойство проверяется числом, а не глазами. Заодно покажу, где пайплайн ломается: на каждый из пяти приёмов нашлись свои грабли.
Сразу главное, потому что дальше будет много слов про реализацию. Код пайплайна я не писал: его написал агент — DeepSeek Harness с моделью DeepSeek Flash V4.1 на thinking low. За мной осталась разведка и приёмка: найти материал, разобраться, что такое настоящий пиксель-арт и чем он отличается от картинки, поставить задачу, проверить результат и решить, что из этого идёт на сайт. Дальше по тексту первое лицо осталось там, где я выбирал, смотрел и решал; реализация пайплайна — работа агента по моей постановке.
Что за маскот
У меня есть сайт-визитка на Rust (Leptos + axum), и в hero-секции стоит терминал — единственный вход в чат с ассистентом. Маскот сидит на верхней кромке «подвала» этого терминала и реагирует на состояние запроса: base в покое, read пока запрос в полёте, thinking пока печатается ответ, smirk когда ответ допечатан, dissatisfied когда запрос упал. То есть это не украшение, а индикатор состояния — просто в виде ведьмы.

Зовут её Кики, и имя взято из «Ведьминой службы доставки» Миядзаки. Это отсылка именно к имени, а не копия персонажа: ни кота, ни красного банта, ни узнаваемого платья оттуда нет — героиня нарисована с нуля. Кадров в наборе двенадцать: пять уехали на сайт в холсте 144×192, остальные лежат про запас, под новые реакции в чате.
Откуда взялся прототип
Первый прототип Кики появился раньше, на Qwen Image 2512, ещё до выхода 2.1. Он так и лежал у меня в папке, пока не вышла Qwen Image 2.1: модели 2.1 встали на диск 12 октября, и уже после этого я вернулся к прототипу и начал делать набор. Тогда он остался просто картинкой — ни общей палитры, ни сетки у него не было, и именно это потом оказалось главной проблемой.

По файлам это видно, если знать, куда смотреть. Одиннадцать черновиков — это .webp 1344×1344, ровный batch из 2.1. Двенадцатый, он же прототип, — .jpg 1328×1328, и имя файла прямо называет модель: Qwen-Image-2512_00029_. Та же поза — сидя, руки в упор по бокам — стоит на сайте кадром base, той самой эмоцией покоя. То есть на сайте сейчас живёт кадр, который старше всего пайплайна. Остальные одиннадцать — уже 2.1. Дальше речь про весь набор из двенадцати: прототип тоже прогнали через пайплайн, иначе палитра не была бы общей, а заголовок обещал бы двенадцать спрайтов, которых нет.
Стенд
CPU: Ryzen 7 5800X, 8 ядер / 16 потоков; RAM: 64 ГБ DDR4
GPU 0: RTX 5080 16 ГБ; GPU 1: RTX 3060 12 ГБ (второй слот PCIe 2.0 x4)
OS: Arch Linux, драйвер 610.57.04, CUDA 13.3
Генерация: ComfyUI, Qwen Image 2.1 в int8 (
qwen_image_2.1_int8_convrot, 6,76 ГиБ), текст-энкодерqwen3vl_8b_int8_convrot(8,71 ГиБ), улучшатель промптаqwen3.5_9b_qwen_image_2.1_pe_t2i(8,82 ГиБ), VAE (0,63 ГиБ)Шаблоны:
image_qwen_image_2_1_t2iиimage_qwen_image_2_1_image_editОбработка: Python + Pillow + numpy, без графических редакторов; код пишет агент, я проверяю результат
Прошлая моя статья здесь была про то, как 35B-модель работает на этой паре карт через PCIe Gen2 x4. С картинками арифметика другая, и она считается. Основной путь — диффузионная модель 6,76 ГиБ, текст-энкодер 8,71 ГиБ и VAE 0,63 ГиБ, итого 16,10 ГиБ; в 16-гигабайтную RTX 5080 столько не влезает даже без запаса на активации. Плюс улучшатель промпта на 8,82 ГиБ, который ComfyUI подгружает отдельно перед каждым прогоном с улучшением, — со всеми моделями через память проходит 24,92 ГиБ. Поэтому ComfyUI разгружает их по очереди, и одной карты хватает. Вторую карту здесь не задействовали намеренно: распиливать нечего, модель и так укладывается с офлоадом, а текст-энкодер всё равно выгружается целиком.
Время беру из лога ComfyUI, а не на глаз, и сразу оговорюсь: стенд прогрет, модели уже в кэше, с холодного старта первый прогон заметно дольше. Кадр 1664×944 на 25 шагах: 17,9 с. Он же с включённым улучшателем промпта: 40,6 с. Правка с двумя референсными картинками: 77,7 с.
Эксперимент: прошу модель сделать пиксель-арт
Прежде чем писать пайплайн, я проверил самое очевидное решение — а если просто попросить? Под рукой был шаблон image_qwen_image_2_1_image_edit: он принимает две картинки, одну как холст, вторую как источник стиля. На холст пошла иллюстрация, сгенерированная в image_qwen_image_2_1_t2i для этой же статьи, а стилем — готовый пиксель-арт черновик из набора.
Инструкцию я сформулировал максимально прямо: «Keep the composition, pose, camera and colour scheme of image1, but render the whole frame with the flat, blocky, low-resolution look of image2». Улучшатель промпта развернул её в абзац, где прямым текстом перечислено: flat blocky pixel rendering, chunky low-resolution pixel clusters, hard-edged colour blocks, limited saturated palette, no smooth gradients, no anti-aliasing. Результат выглядит убедительно — тем интереснее его замерить.
Картинка | Размер | Уникальных цветов | На 1000 пикселей рисунка |
|---|---|---|---|
Иллюстрация на входе (t2i) | 1664×944 | 46 304 | 29,5 |
image_edit: «сделай пиксель-арт» | 1664×960 | 62 513 | 39,1 |
Черновик | 1344×1344 | 36 256 | 20,1 |
Спрайт после пайплайна | 219×316 | 57 | 1,5 |
Плотность считается по нарисованным пикселям: у полнокадровых картинок это весь кадр, у спрайта — только непрозрачные пиксели. Если у спрайта взять все RGB-тройки подряд, к числу добавится ещё одна, которой в палитре нет: это нули под полностью прозрачными пикселями. Мелочь, но именно на таких мелочах в разделе про правильные замеры и ловят.
Модель, которую прямо попросили убрать сглаживание, выдала больше цветов, чем было на входе: 62 513 против 46 304. Черновик довольна из набора 2.1 и того беднее — 36 256. Плотность цвета у результата при этом в 26 раз выше, чем у спрайта из пайплайна.
Оговорюсь про природу этого сравнения, и здесь важно не свалить на модель чужую работу. У спрайта палитра ограничена по построению, поэтому «в 26 раз» — это масштаб разницы, а не спортивный результат. Содержательная часть замера в другом: число цветов запросом не задаётся. Это не то, чем управляет генератор, — он рисует картинку по описанию, и «сделай ровно 61 цвет» не является параметром промпта. Модель ограничивает палитру примерно так же, как фотоаппарат без включенных фильтров цветокоррекции ограничивает количество цветов в кадре, — то есть никак. Поэтому и чинить это промптом нечем: ограничение палитры живёт в постобработке, и в этом наборе оно стоит отдельным шагом.
Просьба «сделай пиксель-арт», измеренная числом

Это не значит, что модель плохая. Она делает ровно то, о чём её просят, в тех терминах, которые понимает: «плоские блоки» для неё — приём внутри картинки, а не формат хранения. Разница между «выглядит как пиксель-арт» и «является пиксель-артом» — это разница между картинкой и данными, и промптом она не закрывается. Если коротко, спрайт отличается от такой картинки не разрешением, а тремя свойствами, и каждое проверяемо:
Один цвет на пиксель. Не «почти один»: между двумя соседними пикселями не может быть промежуточного оттенка.
Целая сетка. Все пиксели лежат на решётке с постоянным шагом, а не «где-то блок 3 пикселя, где-то 5».
Бинарная альфа. Пиксель либо есть, либо его нет: полупрозрачная кромка разваливается при
image-rendering: pixelated.
Именно третье свойство убивает идею «сгенерировать и просто уменьшить»: при уменьшении с анти-алиасингом каждый край получает свой оттенок, и цветов становится больше, а не меньше. Одна деталь черновиков при этом правильная, и её не чинили, а сохраняли: их блоки действительно разного размера. Крупные квадраты на шляпе, полосы шириной в один пиксель спрайта на щеке. Пиксель-арт без разницы масштабов выглядит как даунсемпл, а не как рисунок.
Черновик и спрайт рядом, плюс зум на одну и ту же область

То же самое готовыми нодами
Прежде чем защищать своё, надо назвать чужое. На этой машине стоят пять пакетов под эту задачу, причём два из них — comfyui-pixelization и comfy_pixelization — разные проекты, регистрирующие узел с одним и тем же именем Pixelization. Два самых очевидных, PixelOE и Krea2 Pixel Art Refiner, прогнали на том же черновике.
Готовые ноды пикселизации против пайплайна

Результат честнее, чем я ожидал: Krea2 Pixel Art Refiner с colors=61 выдал ровно 61 уникальный цвет. Половину задачи — «сделать мало цветов» — готовый узел решает одной нодой и без моего кода. PixelOE в 61 цвет не уложился (вышло 423), хотя параметры задали те же: pixel_size=4, color_quant=true, num_colors=61, quant_mode=weighted-kmeans, dither_mode=none. Визуально он при этом отработал не хуже. Так что если задача — «сделать картинку похожей на пиксель-арт», весь этот пайплайн не нужен. Он нужен для другой половины, и она измеримо другая:
Что нужно | Готовые ноды | Пайплайн |
|---|---|---|
Ограничить палитру | да | да |
Обрезать по фигуре и отдать бинарную альфу | нет: на выходе весь кадр со светлым фоном | да, 219×316 без фона |
Одна палитра на все двенадцать кадров | нет, при вызове по одному кадру | да |
Отдать спрайт как данные — сетку и палитру, а не картинку | нет | да |
Размер 336×336 у готовых нод — это решение для сравнения, а не их поведение: у refiner по умолчанию scale_to_original=true и он вернул бы исходные 1344×1344, у PixelOE по умолчанию включён доапскейл. Оба флага поставили на уменьшение, чтобы сравнивать с холстом спрайта, и это стоит держать в голове, читая строку про обрезку. Без этой оговорки строка читается как свойство узлов, хотя это свойство моей настройки.
Про общую палитру — отдельный замер, и он показывает не открытие, а ограничение интерфейса. У Krea2PixelArtRefiner на входе шесть полей: image, width, height, colors, color_reduction_first и scale_to_original. Палитру в него подать нечем — есть только число цветов, целое от 2 до 256. То есть «вот эти 61» узел принять не может по устройству, и общая палитра на набор в нём не выражается: цвета он каждый раз считает заново по своему кадру. Поэтому refiner с colors=61 на двух кадрах одной героини дал 3 общих цвета из 61, объединение — 119. Само по себе «палитра на кадр — палитра на кадр» — не находка, это определение; интересно здесь другое: два кадра одной героини разошлись настолько, что общими остались три оттенка. На сайте это выглядело бы так: персонаж меняет эмоцию и вместе с ней меняет оттенок платья.
Именно здесь я и понял, что мне нужен не пиксельный фильтр, а формат: палитра, общая для набора, и сетка, по которой спрайт можно перерисовать целиком. Ограничение палитры — следствие, а не цель. Это и есть граница, за которой фильтр заканчивается: одно изображение он обработает, а набор — уже нет.
Пять приёмов
Дальше — то, что реально понадобилось. Каждый приём описываю одинаково: зачем, как, что это дало в цифрах и где ломается. Это не архитектура, а список мест, где очевидное решение не сработало, и порядок у них такой же, как в конвейере.
Пайплайн целиком: семь шагов от черновика до карты блоков

1. Медиана блока вместо среднего
Зачем. Нужно свести 1344×1344 к сетке. Очевидный ход — усреднить блок 4×4, и на сглаженном крае он даёт ровно ту грязь, от которой мы уходим: среднее между чёрной шляпой и светлым фоном — серый. Как. Сначала кадр обрезается по габаритам фигуры: берутся пиксели, у которых цветовое расстояние до фона больше 40, по ним строится рамка плюс поля шириной в два будущих пикселя. Иначе сетка считалась бы по пустому полю 1344×1344. Для кадра read обрезка вышла 887×1188 — после деления на четыре это и есть спрайт 222×297. Дальше каждый блок 4×4 сворачивается в один пиксель спрайта по медиане отдельно по каждому каналу: если в блоке 12 пикселей исходника пришлось на шляпу и 4 на фон, медиана останется цветом шляпы, а среднее уедет в серость. Здесь и дальше «блок» — это квадрат исходной картинки, а «пиксель» — уже пиксель спрайта, тот, который видно на экране.
Эффект. Уменьшение ровно вчетверо (3,99–4,00 по кадрам), холст 217–286 × 235–318. На этом шаге появляется сетка, которой в черновике не было. Где ломается. Четвёрка — компромисс. Самая мелкая настоящая деталь черновика — кромка пряжки и полосы румянца — занимает 5–6 исходных пикселей, то есть при cell=4 попадает в один-два пикселя спрайта. cell=2 дал бы вдвое больше деталей, но и спрайт вдвое крупнее, а cell=6 их уже съедает.
2. Одна палитра на весь набор
Зачем. Двенадцать кадров одной героини обязаны выглядеть как один набор. Если считать палитру на кадр отдельно, оттенки уедут: шляпа в одном кадре окажется чуть холоднее, чем в другом, и при смене эмоции платье будет мигать. Насколько это реально, видно по замеру выше — у готового узла из 61 цвета на двух кадрах одной героини совпало три.
Как. Палитра строится median cut по объединённой гистограмме всех кадров и дальше только расширяется: новый кадр может добавить цвет, но не может переписать существующие. K-means я тоже пробовал и получил характерный провал: золотая пряжка — десятки пикселей из миллиона, центроид её не замечает, и пряжка становится серой. Median cut делит по самой широкой коробке и такие мелкие, но далёкие кластеры сохраняет. Эффект. Одна палитра ровно на двенадцать кадров, и соответствие ей 1.0 на каждом: любой непрозрачный пиксель в точности равен одной из записей.
Где ломается. Потолок не в палитре, а в формате: спрайт — это текстовая сетка, где символ означает индекс цвета, а алфавит держит 62 знака. Больше 62 цветов в спрайт не влезает, и в основном наборе занят 61 слот. Ближайшая пара разведена на 25 единиц, то есть слить её незаметно нельзя: следующий кадр с новыми оттенками потребует убирать оттенок или расширять алфавит.
Хороший пример — кадр с книгой: страницы вышли серыми. Кремовый #fceacc при хроме 48 — светло-нейтральный, такого в палитре не было: ближайший оттенок отстоял на 45 единиц. Пришлось расширять: старые цвета остаются дословно, добавляются только те акценты, которые база выразить не может. Так появился #f5e6cc, и до него расстояние — 14,5 единицы. Обе цифры считаются по замороженной палитре из репозитория: первая — если убрать из неё добавленный цвет, вторая — если оставить.
3. Свечение и тень: перекрасить, а не вырезать
Зачем. Генератор добавляет вокруг силуэта светлый ореол и мягкую тень под фигурой. Тень не часть персонажа, а ореол превращается в грязную кайму по контуру. Как. «Свечение» — это светлый и малонасыщенный пиксель: среднее трёх каналов выше 112, а разность между максимальным и минимальным каналом меньше 30. Порог подобран не на глаз: на 120 в волосах оставались серые полосы #747476 (среднее 117), а на 112 они уходят, при этом блик на шляпе #64636b (среднее 102) остаётся.
Дальше — главное. Первая версия делала такие пиксели прозрачными и пробила дыры в волосах: полоса, зажатая между прядями, исчезала, и сквозь неё просвечивал фон. Поэтому решение раздвоено: пиксель на открытом краю, где вокруг хотя бы 75% свободных пикселей, становится прозрачным, а запертый внутри фигуры — перекрашивается в цвет соседних волос.
Эффект. Снято 7 894 пикселя свечения и тени на двенадцать кадров. Отдельно я проверил, что очистка не пробивает мелких дыр: обошёл все прозрачные области, не достижимые от края кадра, и посчитал те, что занимают один-два пикселя. На двенадцати кадрах и пяти порогах от 0,5 до 0,9 таких проколов от 0 до 7 — все семь на самом осторожном пороге 0,9. А если снимать всё светлое без разбора, проколов становится от 6 до 30 на кадр, и самая крупная дыра вырастает до 318 пикселей. Разница между «перекрасить» и «вырезать» — вот она.
Ореол и тень: что находит правило, что будет при наивном удалении и что отгружено

Где ломается. Порог подобран перебором, и это компромисс, а не константа. Четыре кадра, пять значений; в таблице — сколько пикселей очистка признала свечением. Она же показывает цену компромисса: чем осторожнее правило, тем больше ореола остаётся на контуре.
Кадр | 0,50 | 0,60 | 0,75 | 0,80 | 0,90 |
|---|---|---|---|---|---|
| 1425 | 1116 | 967 | 756 | 641 |
| 1551 | 1290 | 1206 | 977 | 885 |
| 1295 | 1112 | 1050 | 911 | 861 |
| 49 | 25 | 11 | 6 | 3 |
Читать её надо так. Слева направо порог растёт, очистка осторожнее, и выше шанс, что ореол по контуру останется: в довольна на 0,9 снято 641 пиксель вместо 967. Справа налево очистка захватывает больше, и разъезжается просвет между ногами: крупнейшая замкнутая прозрачная область в довольна — 304 пикселя на 0,5, 227 на 0,75 и 157 на 0,9. 0,75 стоит посередине, и выбран он по глазам, а не по оптимуму.
Перебор порога 0,5 — 0,75 — 0,9 на одном кадре и таблица замеров

4. Глаза защищаются по единственному насыщенному зелёному
Зачем. Правило «убрать всё светлое и малонасыщенное» съедает белок глаза и блики — на чиби-лице это значит потерять персонажа. Как. Ирис — единственный насыщенный зелёный в кадре, и это позволяет найти его без разметки: пиксель ирисовый, если g - max(r, b) > 25. Рамка ириса расширяется на три пикселя, и эта область исключена из всех правил очистки — на кадре довольна это 560 пикселей. Сверху всегда добавляется самая верхняя светлая тёплая область: она нужна на случай, когда глаз закрыт и зелёного в кадре нет вообще (кадр ухмылка, там она накрывает лицо целиком — 4690 пикселей).
Эффект. Под защитой 20 388 пикселей на двенадцать кадров — от 708 до 5 751 на кадр. Из них светлых, то есть таких, которые правило без защиты съело бы, — 848, от 17 до 110 на кадр. Проверку я прогнал заново, чтобы не ссылаться на память: каждый кадр считается дважды, с очисткой свечения и без, и сравниваются пиксели внутри защищённой области. На всех двенадцати кадрах отличий ноль. То есть защита не «уменьшает ущерб», а закрывает эти 848 пикселей полностью. Разбор одного кадра: на довольна 960 пикселей защиты — 560 дала рамка ириса, 400 вторая половина правила, и светлых из них 86 на ирисе и один в области кожи.
Защита глаз: маска ириса, область кожи и результат без защиты и с ней

Где ломается. Правило держится на двух подгонках: ирис должен быть зелёным, кожа — светлой и тёплой. Изумрудная брошь или зелёный фон — и рамка накроет не то; тёмная кожа, зверь или робот — и вторая половина правила промахнётся мимо лица целиком. На кадре довольна она уже промахивается: 400 из 960 пикселей защиты — золотая пряжка на шляпе, потому что она проходит тест кожи по всем условиям. Проку от неё один пиксель из четырёхсот. Числа тут скромные: 848 пикселей на двенадцать кадров — это немного, и на глаз разница видна только на зрачке и блике. Но это ровно те пиксели, из-за которых чиби-лицо выглядит живым.
5. Карта блоков вместо «на глаз»
Зачем. От задания осталось требование: пиксели должны быть разного размера. Сразу оговорюсь, где тут неточность, потому что за неё зацепится любой практик. Сами пиксели в спрайте одного размера — сетка у него одна. Разного размера у него плоские области: где-то полоса шириной в один пиксель, где-то заливка в восемь. Измерять надо именно это, иначе получается разговор ни о чём. Как. Кадр разбирается на максимальные однородные квадраты: если блок 8×8 целиком одного цвета, он помечается как 8×8 и внутрь алгоритм не идёт, остаток делится пополам рекурсивно до 1×1. Гистограмма этих блоков и есть ответ.
Эффект. Доля площади холста в среднем по двенадцати кадрам: 8×8 — 41,3%, 1×1 — 25,8%, 2×2 — 21,8%, 4×4 — 11,1%. Разброс по кадрам — от 35 до 54% на крупных блоках. Если бы карта выродилась в один размер, эта гистограмма показала бы это сразу.
Гистограмма блоков: 1×1, 2×2, 4×4, 8×8

Где ломается. Первая версия выродилась в одни 1×1: холст не был кратен размеру блока, и на швах любой квадрат 2×2 задевал границу. Пришлось добивать холст до целого числа степеней двойки сентинелом и класть блоки встык. Вторая ловушка — гистограмма считала несуществующий размер 0.
Замеры
Всё, что ниже, снято на одном прогоне пайплайна по двенадцати кадрам при cell=4. Числа берутся из отчёта пайплайна, а не переписываются руками. Если отчёт изменится, таблица перестанет быть верной, и это заметно сразу.
Метрика | Значение |
|---|---|
Цветов на кадр | 41–61 |
Альфа | только 0 и 255 |
Размер холста | 217–286 × 235–318 |
IoU силуэта против второго теста — не независимого, оговорка ниже | 0,941–0,978 (среднее 0,960) |
Полнота по сильным краям | 0,873–0,906 (среднее 0,885) |
Точность по сильным краям | 0,671–0,768 (среднее 0,739) |
Число снятых пикселей свечения и размер защищённой области я здесь не повторяю — они уже названы в приёмах выше. Оба этих числа зависят от порога. Здесь собрано только то, что от него не зависит.
Палитра набора, отсортированная по площади

Точность по краям заметно ниже полноты, и это честный результат, а не артефакт замера: пайплайн рисует края там, где в черновике их не было, — на месте снятого ореола появляется граница силуэта. Полнота при этом выше: почти все настоящие края сохранены. Про «второй тест» стоит сказать точнее, потому что независимым его можно назвать только отчасти. Силуэт черновика считается другим способом, чем в пайплайне: попиксельное цветовое расстояние до фона с порогом 60 и голосование большинством внутри пикселя — без заливки от края и без снятия свечения, на которых стоит сам пайплайн. То есть это не независимая разметка от стороннего человека, а вторая моя реализация, специально написанная так, чтобы не повторить ошибку первой. Считать это верхней границей доверия, а не сертификатом.
Все двенадцать кадров набора

Что не сработало
Уменьшение без палитры не работает. Первый вариант пайплайна просто уменьшал картинку по сетке и получал на выходе тысячи цветов: сглаженная кромка распадается на десятки оттенков. Пока не появился шаг привязки к общей палитре, «спрайт» оставался картинкой. cell=6 не спасает положение, а портит его. Гипотеза была, что более крупный блок даст более «честный» пиксель-арт. На практике детали лица на шестёрке смазываются, а палитра упёрлась в те же 62 слота. Набор на шестёрке оставили как альтернативу, но на сайт он не пошёл.
Как это проверить
Проверка не требует смотреть на картинку. Три условия, которые прогоняются как тесты, и все три — про формат, а не про качество рисунка. Каждое печатает по строке на утверждение и падает с ненулевым кодом возврата.
Каждый непрозрачный пиксель равен записи палитры. Если хоть один не равен — пайплайн сломан. Сейчас — 1,0 на всех двенадцати кадрах.
Альфа принимает ровно два значения. Появление любого третьего означает, что где-то вернулся анти-алиасинг.
Гистограмма блоков содержит больше одного размера. Если всё выродилось в
1×1или в8×8, значит либо карта блоков считается неправильно, либо спрайт перестал быть пиксель-артом.
Плюс отдельно: внутри защищённой области глаз кадр не меняется при включении и выключении очистки свечения. Это проверка того, что правила очистки не трогают то, что защищено. Она единственная здесь сравнивает два прогона одного кадра, а не свойства одного прогона.
Как повторить
Ядро пайплайна — два действия над массивом numpy, и оба укладываются в пять строк. Ниже они целиком, без обвязки. Ровно эти две строки и превращают картинку в данные.
# 1. Блок 4x4 -> один пиксель. Медиана, а не среднее:
# сглаженный край не тянет цвет в серость.
blocks = arr.reshape(h // 4, 4, w // 4, 4, 3).transpose(0, 2, 1, 3, 4)
cells = np.median(blocks.reshape(-1, 16, 3), axis=1)
# 2. Один цвет на пиксель: ближайшая запись палитры.
idx = np.stack([redmean(cells, c) for c in palette]).argmin(0)
redmean — дешёвый заменитель расстояния в Lab: sqrt((2 + rm/256)·dr² + 4·dg² + (2 + (255−rm)/256)·db²), где rm — средняя красная компонента пары. Он считается без перехода в другое цветовое пространство и ведёт себя заметно ближе к человеческому восприятию, чем евклидово расстояние в RGB. Всё остальное — чистка одиночек, силуэт, свечение, карта блоков — это уже надстройка над этими двумя шагами. Работает на чистом Python с Pillow и numpy, графический редактор не нужен.
Весь пайплайн лежит в открытом репозитории — github.com/aigen31/deepseek-pixel-art-generation. Там семь шагов из приёмов выше, два драйвера (по референсу и по черновикам), замороженная палитра набора и 34 проверки, которые печатают по строке на утверждение и падают с ненулевым кодом возврата. Зависимостей две: Pillow и numpy.
Где замер, а где объяснение
Измерено: число уникальных цветов в черновиках и спрайтах, размеры холстов, соответствие палитре, бинарность альфы, IoU силуэта, полнота и точность по краям, гистограмма блоков, количество снятых пикселей, число мелких проколов при пяти порогах очистки, число проколов при наивном удалении, размер крупнейшей замкнутой прозрачной области на каждом пороге, число светлых пикселей под защитой глаз, ноль отличий в защищённой области глаз, палитры готовых нод на двух кадрах. Объяснение, а не измерение: что медиана «лучше держит край, чем среднее», что правило про 75% свободных пикселей отделяет ореол от полосы в волосах, и что порог яркости 112 — правильная граница для этой модели. Всё это согласуется с замерами, но отдельными экспериментами это не изолировано. Пороги подобраны перебором на четырёх кадрах из двенадцати.
Не проверял: как пайплайн поведёт себя на другой модели и на другом персонаже. Пороги в коде — аргументы со значениями по умолчанию, подобранными на этом наборе, так что перебирать их придётся заново. Но два места завязаны на саму Кики жёстко: ирис ищется как единственный насыщенный зелёный, а лицо — как светлая тёплая кожа. Персонаж с синими или карими глазами, зверь, робот или человек с тёмной кожей защиты не получит вообще: маска вернётся пустой, и правило свечения съест белок вместе с бликами. Это первое, что придётся переписать, и обе функции помечены в репозитории.
На сайте: 144×192
Отдельная история — как спрайт попадает на страницу, и здесь надо честно назвать одно место, где правило целочисленного масштаба у меня не выполнено. Первое решение — общий холст 144×192, кадры выровнены по низу. Если считать масштаб по каждому кадру отдельно, при смене эмоции персонаж будет менять размер, поэтому холст, масштаб и выравнивание считает один скрипт, а не человек в графическом редакторе. Точка опоры у кадров при этом разная, потому что позы разные: у base силуэт падает с 126-го пикселя холста до 46-го, и кромка подвала проходит по бёдрам. Поэтому у каждого кадра своя поправка --seat-nudge — без неё кадры с поднятыми коленями читаются как висящие в воздухе, а при смене эмоции ведьма прыгает на несколько пикселей.
Второе — размеры на странице: 144, 120 и 96 px по ширине, и это единственное место, где правило целочисленного масштаба у меня не выполнено. Кратен холсту только первый: 120 — это 5/6, а 96 — 2/3. Задумано было так, чтобы файл совпадал с областью показа: тогда браузер ничего не пересчитывает, а Pagespeed не видит «изображение больше, чем нужно». Получилось это только на одном размере из трёх. Я измерил на живой странице: при окне 1280 px в файле 144 px и в области 144 px, то есть один к одному; при 1100 px область уже 120×160, при 900 — 96×128, а файл всё тот же 144×192. На двух размерах из трёх браузер уменьшает картинку сам, и делает это ровно так, как не надо: 144 → 120 и 144 → 96 — дробные масштабы, при которых image-rendering: pixelated выбрасывает часть пикселей, и соседние становятся разной ширины. Pagespeed на этих же двух размерах получает как раз то, за что снижает балл: картинку на 20 и на 50 процентов больше области.
Доводится это просто: три файла и srcset — 144×192, 120×160 и 96×128, каждый ровно того размера, в котором показывается, и каждый кратен одной и той же сетке. Общий делитель 144, 120 и 96 — это 24, то есть рисовать надо на сетке 24×32. Цена — детализация лица: спрайт сейчас в девять раз шире. Второй вариант дешевле: оставить сетку крупной и убрать 120 из списка, показывая кадр в 144 и 96. Пока не сделано ни то, ни другое, и писать «все размеры кратны холсту» я не стану.
И про сами файлы, потому что здесь слиплись два разных решения. Размер 144×192 — первое, и оно осознанное: под самый широкий брейкпоинт файл подобран ровно под область показа. Усреднение при уменьшении — второе, и вот с ним всё хуже. Из пайплайна выходит холст 219–237 пикселей по ширине, а скрипт сборки вписывает кадр в холст с масштабом min(192 / высота, 144 / ширина) — это около 0,61, — и каждый пиксель холста становится средним по попавшим в него исходным. Именно этот фильтр, а не выбранный размер, съедает палитру и сетку: в отгруженном base.png 3 113 уникальных цветов вместо 58. Альфа при этом остаётся бинарной, её скрипт чинит отдельно. Что дело в фильтре, а не в размере, я проверил на том же коде: если холст совпадает с габаритами самой широкой и самой высокой фигуры из набора, масштаб выходит ровно 1, и скрипт копирует пиксели один в один — 58 цветов на входе, 58 на выходе. Сейчас на сайте лежит картинка, а не спрайт, и это следствие того, что кадр уменьшили не в целое число раз, а не того, что файл маленький.
Кто это собирал
Схема работы такая. Я запускаю DeepSeek Harness и ставлю задачу словами; внутри работает модель DeepSeek Flash V4.1 на thinking low. Дальше цикл: агент разворачивает окружение, ищет нужные скиллы через скилл find-skills, читает их, пишет код и показывает мне. Я смотрю на результат и говорю, что не так, — цикл идёт заново. Ни одной строки пайплайна я не написал: моя часть — разведка, постановка и приёмка, его — реализация.
Скиллы здесь — не промпты для картинок, а инструкции для агента. pixel-art-redraw описывает, как читать референс и что считается настоящим пиксель-артом; pixel-art-sprites — как устроены спрайт-листы и палитры. Из них агент берёт критерии, по которым потом сам же проверяет свою работу. Разница принципиальная: без этих критериев агент считает задачу выполненной, как только картинка выглядит похожей.
Что это меняет на практике. Агент хорошо делает ту часть, которую можно проверить кодом; ошибки он делает ровно там, где проверки нет. Дырки в волосах от первой версии очистки свечения нашлись не тестом, а взглядом: соответствие палитре и бинарность альфы были в порядке, потому что обе проверки смотрят на формат, а не на то, стало ли лучше. Автоматика такое не ловит. Это как раз то место, где от меня требовалось не писать код, а смотреть, — и таких мест в проекте оказалось больше, чем хотелось бы.
Поэтому единственное, что здесь по-настоящему держит качество, — это то, что результат является данными: сетка символов, палитра и три проверки выше. Их можно прогнать тестом. Картинку тестом не проверишь.
Итоги
Настоящий пиксель-арт отличается от картинки «под пиксели» не стилем, а представимостью: если спрайт хранится как сетка индексов плюс палитра, любое требование к нему — «та же ведьма, ночная палитра, вдвое крупнее» — становится детерминированной операцией, которую можно проверить пиксель за пикселем. Картинка такого не позволяет, и никакое разрешение этого не исправит. Что здесь стоит денег и внимания: палитра упирается в алфавит сетки, а не в возможности генератора, и потолок этот задаёт выбранный мной формат хранения, а не пиксель-арт как дисциплина: если писать индекс двумя символами вместо одного, ограничение уезжает в тысячи цветов. В текущем алфавите занят 61 слот из 62, а ближайшая пара цветов разведена слишком далеко, чтобы слить её незаметно, — поэтому новую эмоцию с новыми оттенками уже не добавить одним прогоном: придётся решать, чем пожертвовать.
Нейросеть при этом никуда не делась — она осталась черновиком. Хорошим черновиком: двенадцать кадров одной героини, от улыбки до полёта на метле, руками я бы этого не сделал. Но между «нарисовать черновик» и «получить спрайт» лежит вся эта статья.
Бонус: тот же маскот, но живой и на весь экран
Из набора выросли не только спрайты для страницы. Отдельный проект — живые веб-обои для Wallpaper Engine: Кики сидит перед ЭЛТ-монитором, по стеклу построчно ползёт терминал, лампа подсветки перебирает шестнадцать уровней яркости, а свет от экрана ложится на шляпу, волосы и пол. Это отдельная история, и она здесь потому, что обои собраны по тем же правилам, что и спрайты.
Живые обои для Wallpaper Engine: 640×360 логической сетки, целочисленный масштаб

Собраны они по тем же правилам, что и спрайты, и это видно по решениям. Вся сцена нарисована на одной логической сетке 640×360 и увеличивается одним целочисленным преобразованием: 2× для 720p, 3× для 1080p, 4× для 1440p, 6× для 4K. Логический пиксель всегда остаётся целым числом пикселей экрана, ни один слой не пересэмплируется. Это ровно тот же запрет на дробный масштаб, который на сайте выполняется только для одного размера из трёх, — здесь он выполняется всегда.
Обои — это 16 PNG-слоёв, а не готовая картинка: фон, стекло, ореол, свет на полу, тень, девушка, блик на шляпе и заплатка век размером с два глаза. Пять анимаций — это изменения яркости и прозрачности этих слоёв, а не видео. Палитр две, и они переключаются свойством самих обоев: снятая с референса и урезанная до 128 цветов. Кроме палитры в свойствах яркость свечения, скорость терминала, моргание и дрожание тени.
Сборка детерминированная: ни вывода модели, ни случайности, ни таймстемпов. Поэтому честная проверка после правки — пересобрать всё и посмотреть, что git status остался пустым. Непустой дифф и есть изменение. Отдельный скрипт прогоняет 87 проверок примерно за секунду. Обои лежат отдельно: github.com/aigen31/wallpaper-engine-kiki-character. Внутри готовая папка для Wallpaper Engine и весь пайплайн, который её собирает.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.