Наверняка вы видели портреты, собранные из одной нити, натянутой между сотнями гвоздей. Я решил проверить: можно ли научить нейросеть генерировать не готовую картинку, а инструкцию, по которой такая картинка строится?
Я превратил цифры MNIST в последовательности переходов между 256 гвоздями и обучил небольшой Transformer продолжать путь нити. В результате модель выдаёт не PNG, а JSON-траекторию, которую можно отрисовать в любом разрешении — или потенциально передать физической string-art установке.

Представьте круг из 256 гвоздей, одну непрерывную нить и последовательность переходов:
0 -> 152 -> 250 -> 147 -> ...
В сыром бинарном представлении каждый индекс помещается в один байт. Несколько сотен таких переходов, заранее известные правила декодирования — и по этой инструкции на экране, а потенциально и на физической установке, появляется узнаваемая цифра.
Я назвал этот эксперимент Nitograph. Его суть не в том, чтобы изобрести новый формат сжатия. Мне было интересно проверить гипотезу: можно ли заменить матрицу пикселей короткой последовательностью действий, а затем научить модель самостоятельно такие последовательности генерировать.
Как устроен pipeline
Проект выстроен в цепочку из нескольких этапов:
MNIST image -> string-art encoder -> dataset of nail sequences -> causal Transformer -> sampler with geometry rules -> JSON / 4K render
Сначала изображение цифры превращается в траекторию нити. Затем такие траектории собираются в датасет, на котором обучается causal Transformer. После обучения модель может сгенерировать траекторию для заданного класса, а renderer превращает её в картинку .
Главный артефакт здесь — не PNG, а JSON:
{ "digit": 7, "num_nails": 256, "line_count": 220, "temperature": 0.85, "top_k": 32, "seed": 42, "nails": [0, 152, 250, 147, 243, 141, 238, 139]}
Для одного из примеров получились такие размеры:
model params: 1,532,864checkpoint: 7,153,739 bytesPNG render: 6,454,345 bytesJSON path: 2,251 bytespath length: 221 nail indicesrender size: 4096x4096
Сравнивать эти числа как обычное сжатие некорректно. PNG самодостаточен, а JSON требует заранее известного декодера. Размер PNG также зависит от разрешения, свечения и параметров сохранения.
Кроме того, текущий JSON сделан человекочитаемым и содержит метаданные. Если хранить только 221 индекс в бинарном виде, они заняли бы примерно 221 байт.
Поэтому правильнее считать JSON не «сжатой картинкой», а процедурным описанием построения.
PNG отвечает на вопрос «что получилось».
JSON отвечает на вопрос «как это построить».
Как изображение становится траекторией
Первый и самый важный компонент — не нейросеть, а string-art encoder.
Я работаю на небольшом canvas 64×64 и размещаю 256 гвоздей по окружности. Это не финальное разрешение изображения, а сетка, на которой удобно оценивать, какие пиксели заденет линия между двумя гвоздями.
Для каждой возможной пары гвоздей заранее рассчитывается толстая линия Брезенхэма. В результате получается lookup table:
256 nails x 256 nails -> path table
После этого геометрию не нужно вычислять заново при каждом выборе следующего шага.
Дальше encoder действует жадно. На каждом шаге он перебирает доступные гвозди и смотрит, какая хорда сильнее всего уменьшит ошибку реконструкции относительно исходного изображения.
for candidate in nails: score = improvement_if_draw(current, candidate)next = best_candidate
Чистый greedy-алгоритм быстро начинает портить результат. Он делает слишком короткие переходы, зацикливается на A -> B -> A, повторяет одни и те же рёбра и иногда прорисовывает пустой фон.
Поэтому я добавил минимальный круговой зазор между гвоздями, запрет мгновенного возврата, штраф за повтор рёбер, penalty за лишнюю прорисовку фона и нормализацию score по длине линии.
Это не глобальный оптимизатор и не гарантия лучшей возможной string-art реконструкции. Качество encoder напрямую ограничивает качество обучающих данных: модель в основном учится воспроизводить структуру тех траекторий, которые он построил.
Но качества получаемых последовательностей оказалось достаточно, чтобы проверить саму идею.

Почему именно MNIST
MNIST — намеренно маленькая и контролируемая среда. Здесь всего десять классов, результат легко оценить глазами, датасет можно быстро перестраивать, а ошибки encoder и sampler сразу заметны.
Целью не было соревноваться с современными генеративными моделями. Мне нужно было проверить саму идею «изображение как последовательность действий».
Переход к лицам, фотографиям или произвольным иллюстрациям потребует более сильного encoder, conditioning по входному изображению, другой функции ошибки и значительно более длинных последовательностей.
Как данные попадают в Transformer
После кодирования каждая цифра превращается в последовательность токенов:
0..255 nail tokens256 EOS257 PAD258..267 CLASS_0..CLASS_9
Один обучающий пример выглядит так:
input: [CLASS_7, nail_0, nail_152, nail_250, ...]target: [nail_0, nail_152, nail_250, ..., EOS]
Это autoregressive language modeling, только вместо слов используются номера гвоздей, а вместо точки — токен конца последовательности.
Модель небольшая:
layers: 4embedding: 192attention: 6 headsparameters: ~1.53Marchitecture: causal Transformer
Обучение стандартное: AdamW, train/validation split, cosine scheduler, gradient clipping, mixed precision на CUDA и сохранение лучшего checkpoint по validation loss.
Что модель на самом деле учится делать
Здесь важно честно обозначить границы эксперимента.
В текущей версии conditioning работает только по классу цифры. Модель получает токен CLASS_7, но не получает конкретное рукописное изображение семёрки.
Поэтому я использовал canonical prototypes — согласованные типовые представления классов. Параметр --samples задаёт размер синтетического набора последовательностей, но не означает такое же количество независимых стилей почерка.
Высокая validation accuracy в такой постановке показывает, что Transformer научился воспроизводить структуру подготовленных траекторий внутри синтетического распределения. Она не доказывает, что модель обобщается на неизвестный почерк или умеет восстанавливать произвольную цифру MNIST.
Текущая версия отвечает на вопрос:
Как может выглядеть траектория нити для класса «7» в рамках подготовленного набора?
И пока не отвечает на вопрос:
Как построить нитью вот эту конкретную рукописную семёрку?
Для второго сценария потребуется conditioning по входному изображению и разделение train/validation по независимым исходным изображениям.
Зачем нужна модель, если уже есть encoder
Для текущей маленькой задачи encoder действительно можно использовать самостоятельно: передать ему изображение и получить траекторию.
Если цель — один раз преобразовать конкретную цифру в string art, Transformer не обязателен.
Постановка с моделью немного другая. Она позволяет генерировать путь непосредственно из условия вроде CLASS_7, не запускать пиксельный greedy-поиск при каждом вызове и получать разные допустимые варианты через sampling.
Кроме того, такой pipeline можно развивать дальше: добавить conditioning по входному изображению, новые типы токенов, более сложные правила генерации и differentiable renderer.
В нынешней версии это прежде всего proof of concept. Я не утверждаю, что Transformer уже превосходит исходный encoder по качеству или скорости.
Почему Transformer, а не LSTM
Для последовательностей длиной около двухсот шагов здесь вполне можно было бы использовать LSTM, GRU или более простой вероятностный baseline.
Transformer я выбрал не потому, что он единственный подходит для задачи. Его удобно обучать как autoregressive модель, self-attention позволяет учитывать всю предыдущую траекторию, а архитектуру легко расширять дополнительными conditioning tokens.
Была и практическая причина: мне было удобнее реализовать и затем развивать именно такую архитектуру.
Строгого сравнения моделей я пока не проводил, поэтому не утверждаю, что Transformer здесь является оптимальным выбором.
Генерация с геометрическими правилами
Во время генерации Transformer предсказывает следующий токен, после чего в дело вступает sampler.
Он запрещает повторно выбирать текущий гвоздь, делать слишком короткий круговой переход и сразу возвращаться по схеме A -> B -> A. Дополнительно применяются repetition penalty, temperature и top-k.
Минимальный запуск:
python prepare_dataset.py --samples 5000 --max-lines 240python train_ai.py --epochs 15 --batch-size 32python generate.py 7
Параметры sampling можно менять:
python generate.py 3 --temperature 0.70 --top-k 16 --seed 10python generate.py 9 --temperature 1.00 --top-k 48 --seed 77python generate.py 4 --temperature 0 --top-k 1
temperature 0 включает воспроизводимый greedy decoding. Он стабилен, но траектории обычно получаются более однообразными.
Умеренный sampling добавляет вариативности, однако без геометрических ограничений быстро уходит в повторы и шум. Поэтому нейросетевой sampling и детерминированные правила здесь работают вместе, а не заменяют друг друга.
От JSON к картинке
Финальный render я делаю в разрешении 4096×4096, но это нужно исключительно для визуального результата. Сама последовательность от разрешения не зависит.
Изображение собирается тремя batched-слоями через LineCollection: широкий слабый glow, средний glow и тонкая яркая линия. В результате получается эффект натянутой светящейся нити.
JSON при этом не привязан к конкретному способу рендера. С помощью других рендереров те же данные можно было бы вывести в SVG, нарисовать в интерактивном canvas или использовать как инструкцию для физической установки.
В этом и заключается главное свойство процедурного представления: данные описывают не пиксели, а порядок действий.
Что получилось, а что пока нет
Эксперимент показал, что string-art траекторию можно представить как дискретный язык, а causal model способна обучаться на таких последовательностях. Класс цифры можно использовать как conditioning token, геометрические ограничения — сочетать с autoregressive sampling, а готовую инструкцию — визуализировать разными способами.
При этом модель не ищет оптимальную траекторию и во время генерации не сравнивает результат с исходными пикселями. Качество генерации зависит от greedy encoder, а sampler содержит вручную заданные геометрические правила.
В текущей схеме обучения нет reconstruction-aware loss, conditioning работает только по классу, а сравнение с LSTM, GRU и простыми baseline пока не проводилось.
Высокая validation accuracy также не означает обобщение на новые изображения.
Часть этих ограничений можно убрать технически. Другие являются прямым следствием постановки задачи. Например, без заранее известного мира гвоздей исчезает и сама компактность представления.
Что дальше
Ближайший логичный шаг — conditioning по конкретному входному изображению. Модель должна получать не просто CLASS_7, а embedding определённой рукописной семёрки и строить траекторию именно для неё.
После этого можно добавить reconstruction-aware loss, beam search с геометрическим score, SVG export и прямое сравнение архитектур.
Ещё одна интересная ветка — использовать те же JSON-инструкции не только для рендера, но и для физической установки с гвоздями и настоящей нитью.
Итог
Мне было важно проверить не «может ли нейросеть нарисовать цифру». Ответ на этот вопрос давно известен и сам по себе малоинтересен.
Настоящий вопрос звучал иначе:
Может ли модель генерировать короткую, дискретную и физически интерпретируемую инструкцию, из которой затем появляется изображение?
В Nitograph изображение существует не просто как набор пикселей. Оно существует как порядок действий:
возьми текущий гвоздь-> протяни нить к следующему-> повтори
Пока это proof of concept на MNIST, а не универсальная технология. Но ограниченность делает эксперимент особенно наглядным: можно открыть JSON, пройти по индексам шаг за шагом и увидеть, как из простых действий возникает узнаваемая цифра.
Код, README, примеры, веса модели и готовые output-файлы лежат в открытом репозитории. Проект можно запустить локально, сгенерировать собственные траектории и поэкспериментировать с параметрами sampling.
Репозиторий проекта — Nitograph на GitHub
ссылка на оригинал статьи https://habr.com/ru/articles/1046912/