Как моя жена стала моим первым заказчиком
Началось все с того, что моя любимая красавица жена сказала мне, что я провожу слишком много времени со своим девайсом Flipper Zero. Постоянно кручу его в руках, прошиваю, делаю всякие настройки и тому подобное. А на неё стал меньше смотреть.
И мне пришла мысль, а что, если поставить на экран флиппера её фотографию? Девайс всегда со мной и каждый раз как я буду им пользоваться то на главном экране я буду видеть фото своей любимки. И ей приятно, и я буквально смотрю на нее всякий удобный раз.
Я начал искать инфу, как вообще сделать asset pack для Flipper Zero под прошивку Momentum. Нашёл документацию, в каком формате должны быть картинки, какая должна быть структура папок, что должно быть в meta.txt и manifest.txt. Но не нашёл ни одного, удобного инструмента, который делал бы легким создание таких ассетов. Всё, что было, это минималистическая прога — упаковщик для заранее подготовленных PNG картинок и сам скрипт asset_packer.py, ручная упаковка через командную строку, правка текстовых файлов в блокноте и постоянные угадывания, почему анимация не работает.
И тогда я подумал, а почему бы не написать свое собственное приложение для создания анимаций для дельфина. Дело планировалось на пару свободных вечеров.
В итоге прошло несколько месяцев, и вечерний скрипт превратился в полноценное кроссплатформенное приложение с редактором анимаций, обрезкой GIF, пиксельной рисовалкой, валидатором и темами оформления. Назвал я его Flipper Asset Studio, сокращённо FASt.
Пять китайских microSD карт и один оригинальный SanDisk, который решил все
Хочу предупредить всех, кто только собирается купить SD карту для Flipper Zero. Берите карты только проверенных оригинальных производителей, не больше 64 Гб, microSDHC с файловой системой FAT32 (не microSDXC c exFAT). Прошивка может какое‑то время работать на паленой карте, но потом могут появляться ошибки, и система начнет делать ребут.
Когда первый раз попытался закинуть, свой собранный скриптом, asset pack на флиппер через утилиту qFlipper, ничего не произошло. Вроде бы файлы копировались и прогресс‑бар доходил до конца, но на устройстве анимация не появлялась. Я сначала подумал, что сделал ошибку в meta.txt и переделал его, но снова ничего не изменилось. Потом переписал manifest и все тоже самое. Купил пять дешевых китайских карт и на каждую заливал прошивку Momentum и пробовал так же закидывать файлы. Я перепрошивал, сбрасывал настройки до заводских, попробовал разные версии qFlipper и менял шнуры USB, форматировал карты, но ничего так и не менялось. Погуглив я решил, что проблема все же в некачественных microSD картах. Данные либо не записывались вообще, либо записывались частично с ошибками. Я заказал одну карту оригинального производителя на 32 ГБ и скопировал тот же самый asset pack, который не работал на китайских картах. И все заработало стабильно и без ошибок.
Почему я выбрал Python и PyQt6?
Когда только начал задумывать проект, передо мной встал вопрос выбора технологического стека. Вот почему я остановился на Python и PyQt6:
Преимущества Python для этого проекта:
-
Pillow и NumPy — мощные библиотеки для обработки изображений
-
Кроссплатформенность — код работает на Windows, macOS и Linux
-
Быстрая разработка — прототип можно сделать буквально за вечер
-
PyInstaller — простая сборка в исполняемый файл
Почему именно PyQt6, а не Tkinter, PySide или Kivy:
-
Современный UI — PyQt6 позволяет создать приятный и красивый интерфейс, который не выглядит как из 90-х
-
Гибкая система стилей — можно сделать темы оформления как в VS Code
-
Drag‑and‑Drop из коробки
-
Отличная документация
Структура приложения FASt
flipper-asset-studio/├── main.py # Точка входа в приложение (GUI)├── FlipperAssetStudio.spec # Конфигурация сборки PyInstaller├── requirements.txt # Зависимости для запуска├── requirements-dev.txt # Зависимости для разработки и тестов├── README.md # Документация├── .gitignore│├── core/ # Логика: обработка изображений/экспорт/валидация│ ├── __init__.py│ ├── animation_manager.py # Управление кадрами анимации и генерация meta/manifest│ ├── bm_bmx_decoder.py # Декодер/утилиты для .bm/.bmx│ ├── exporter.py # Экспорт анимаций и упаковка meta/manifest│ ├── icon_builder.py # Экспорт иконок (статические и анимированные)│ ├── image_processor.py # PNG -> 1-bit, дизеринг, ресайз/центрирование│ └── validator.py # Проверка структуры asset pack│├── ui/ # UI (PyQt6)│ ├── __init__.py│ ├── main_window.py # Главное окно│ ├── styles.py # Стили и оформление интерфейса│ ├── background.py # Фоновые декоративные элементы│ ├── i18n.py # Интернационализация (RU / EN)│ ├── resources.py # Управление ресурсами (иконки/логотип в рантайме)│ ├── animation_timeline.py # Таймлайн/управление кадрами│ ├── icon_editor.py # Редактор иконок│ ├── gif_crop_editor.py # GIF → PNG (кадрирование анимации)│ ├── jpg_crop_editor.py # Редактор кадрирования (jpg)│ ├── validator_widget.py # Виджет результатов валидации│ ├── drag_drop_widget.py # Drag-and-Drop обработка│ └── create_editor.py # Редактор/страницы создания│├── scripts/│ ├── asset_packer.py # Утилита упаковки/создания asset pack│ ├── build.sh # Сборка исполняемого файла (macOS/Linux)│ └── build_windows.bat # Сборка исполняемого файла (Windows)│├── assets/ # Ресурсы приложения│ ├── icons/ # SVG-иконки интерфейса│ └── logo/ # Логотип (png/ico/icns)│└── tests/ # Автотесты (unittest) ├── __init__.py ├── test_animation_manager.py ├── test_background.py ├── test_create_editor.py ├── test_exporter.py ├── test_gif_crop_editor.py ├── test_i18n.py ├── test_icon_builder.py ├── test_main_window.py ├── test_preview_render.py ├── test_roundtrip.py ├── test_validator.py └── smoke_create_editor.py
Как минимальный скрипт превратился в студию
Изначальный план был скромным, это окно с одной кнопкой Выбрать PNG, вторая кнопка Сделать asset pack. Вроде бы все просто.
Но потом я подумал, а если нужно будет вырезать кусок из большой картинки? Добавил обрезку для JPG файлов. А если обрезать GIF‑анимацию? Добавил редактор GIF с кадрированием. А если нужно нарисовать что‑то самому, пиксель за пикселем? И написал рисовалку с карандашом, ластиком, заливкой и даже режимом луковой шелухи, когда предыдущий кадр просвечивается полупрозрачным слоем, для подгонки кадров когда рисуешь анимацию.
В итоге приложение выросло до восьми полноценных вкладок.
Первая вкладка — Create. Это ручной редактор пикселей. Пользователь рисует на сетке, выбирает размер кисти, переключается между карандашом и ластиком, заливает области ведром. Есть чекбокс для луковой шелухи, чтобы видеть предыдущий кадр при рисовании следующего. Всё отрисовывается через виджет PixelCanvas, который я написал на QPainter. Каждый пиксель хранится в двумерном массиве, а на экране рисуется как сетка из пиксельных квадратиков.
Вторая вкладка — Single Image. Просто перетаскиваешь один файл PNG и видишь превью того, как он будет выглядеть после конвертации в однобитный формат, и экспортируешь.
Третья и четвёртая — JPG Crop и GIF to PNG. Загружаешь изображение, двигаешь рамку для кадрирования, выбираешь разрешение и приложение режет и конвертирует. Для GIF можно смотреть кадры.
Пятая вкладка — Animation Builder. Сюда перетаскиваешь PNG файлы, настраиваешь задержки между кадрами, уровни настроения дельфина и приложение генерирует meta.txt и manifest.txt автоматически и создает необходимые пакпи. Есть живой предпросмотр анимации с настраиваемым FPS и дизерингом.
Шестая — Meta Preview. Просто текстовое поле, где можно посмотреть сгенерированные meta.txt и manifest.txt.
Седьмая — Icons. Редактор иконок флиппера. Passport, NFC, RFID, SubGhz и подобные. Поддерживает все стандартные размеры, пакетную обработку, переключение дизеринга.
Восьмая — Validator. Выбираешь созданную папку с asset pack и валидатор проверяет структуру, размеры кадров, наличие обязательных полей в meta‑файлах.
Девятая — BM/BMX Preview. Сюда можно перетащить уже готовые бинарные файлы (bm или bmx) и увидеть, что в них закодировано.
Темы оформления как в VS Code
Я пишу в VS Code большую часть времени и мне нравятся приятные глазу темные темы типа Dracula. Поэтому, когда я начал делать интерфейс FASt, я подумал, а почему бы не сделать похожие темы и в моем приложении. В styles.py реализована система тем через QSS, где цвета хранятся в словарях. Каждая тема это словарь с ключами типа base, mantle, surface, overlay, text, subtext, blue, crust и тому подобное. Один шаблон QSS‑стилей, цвета подставляются через метод format.
Пока в приложении одиннадцать тем. Catppuccin Mocha для тех, кто любит мягкие тёмные тона. Dracula с её фирменным фиолетовым акцентом. Cyberpunk с неоном. Outrun в стиле ретровейва с розовыми и голубыми градиентами. Ну и некоторые другие.
Переключение темы это одна строка кода, берём словарь цветов, подставляем в шаблон QSS, вызываем setStyleSheet на QApplication. Весь интерфейс перекрашивается моментально, включая кастомные виджеты вроде области drag‑and‑drop, которая использует динамические свойства Qt для переключения состояний.
Про Drag‑and‑Drop. Написан отдельный виджет DragDropArea, который принимает список допустимых расширений файлов. Когда наводишь на эту область файлы, он переключает свойство state в значение drag, это триггерит отдельный набор CSS правил в теме и рамка этого виджета становится пунктирной и фон меняется. Если уводишь файлы, то состояние сбрасывается. Всё через стандартный механизм unpolish/polish в Qt.
Технические грабли, байты и битва с вертикальными полосами
Тут начинается самое интересное. Потому что написать GUI на PyQt это полдела. А вот настоящая боль начинается, когда ты пытаешься правильно упаковать пиксели в формат, который понимает микроконтроллер с маленьким экраном 128 на 64.
Вертикальные полосы на иконках 46 на 49
Flipper Zero хранит изображения в однобитном формате, каждый пиксель это один бит, восемь пикселей упакованы в один байт, упаковка построчная, порядок битов от младшего к старшему (LSB‑first). Для экрана 128 на 64 всё идеально: 128 делится на 8 без остатка, каждая строка занимает ровно 16 байт и никакого паддинга.
Но иконки паспорта имеют размер 46 на 49. 46 не делится на 8. Значит, на каждую строку нужно 6 байт (48 бит), из которых последние 2 бита это мусор, паддинг. Если при декодировании не учитывать этот паддинг и просто прочитать байты подряд, то каждая следующая строка будет сдвинута на 2 бита. На экране это будет выглядеть как будто картинку разделили на полосы и поменяли местами.
Я потратил на эту багу три вечера, пока не додумался написать round‑trip тест, что бы понять в чем дело и исправить. Тест генерирует случайную матрицу пикселей размером 46 на 49, упаковывает её через numpy.packbits с параметром bitorder равным little, потом декодирует обратно через numpy.unpackbits и сравнивает результат с оригиналом через numpy.array_equal. Теперь этот тест есть в test_roundtrip.py и гарантирует, что никто случайно не сломает упаковку для нестандартных разрешений.
Замирающий интерфейс при импорте анимации
Когда перетаскиваешь в окно пачку кадров анимации, то каждый кадр нужно прочитать через Pillow, сконвертировать в оттенки серого, применить метод дизеринга Флойда‑Стейнберга, упаковать в битовый массив и сжать через Heatshrink. Если делать их в основном потоке, интерфейс приложения замирает на несколько секунд. Кнопки не реагируют на нажатие, превью не обновляется, курсор превращается в колесо загрузки. Как будто приложение просто упало.
Решение: написал класс BackgroundRunner, он оборачивает QThread. Когда бросаешь файлы в DragDropArea, список путей отправляется в фоновый поток. Затем вызывается статический метод FlipperImageProcessor.process_png_to_bytes, который работает только с Pillow и NumPy, без создания любых объектов Qt, потому что QPixmap нельзя создавать вне главного потока. Байты возвращается в главный поток через pyqtSignal. И уже в главном потоке из этих байтов собирается QPixmap для отображения в списке кадров. И интерфейс становится отзывчивым и всё работает.
Координаты кадрирования, которые не совпадали
В редакторах JPG и GIF есть такой виджет CropPreviewWidget, который показывает превью изображения и позволяет двигать и растягивать рамку для кадрирования. Проблема была в том, что большое изображение не влезает на экран и поэтому превью уменьшается. Рамка кадрирования накладывается поверх уменьшенного превью этого изображения, и её координаты хранятся в системе координат этого превью. И если применять эти координаты к оригинальному изображению в полном разрешении, то рамка, которая на превью выглядела как вырезать прямоугольник в центре картинки, просто вырезает маленький прямоугольник в левом верхнем углу.
Решение: в CropPreviewWidget появился метод set_source_size, который запоминает реальные размеры оригинала. Координаты рамки всегда хранятся в системе координат оригинала. А при отрисовке в paintEvent и при обработке мышиных событий происходит пересчёт через масштабный коэффициент: scale равно минимальному из отношений ширины и высоты превью к ширине и высоте оригинала. Определение, за какой уголок рамки я тяну — hit‑testing и отрисовка работают в одних координатах, а экспорт работает в других, и между ними всегда правильный пересчёт.
Тестирование рисовалки мышью
Написал тесты для PixelCanvas. Можно, конечно, вручную нарисовать что‑нибудь и посмотреть глазами. Но я хотел что бы были автоматические тесты.
В test_create_editor.py используется QTest.mousePress, QTest.mouseMove и QTest.mouseRelease, чтобы нарисовать линию от одной точки к другой прямо в канвасе. Потом проверяется, что нужные пиксели в массиве pixels изменили свое значение с нуля на единицу. Еще есть тест проверяющий заливку, создаются стенки из единичных пикселей, заливаются области внутри них и проверяется, что заливка не ушла за пределы этих стенок, а изолированный пиксель снаружи остался нетронутым. И тест который проверяет, что перерисовка repaint вызывается именно во время движения мыши с нажатой правой кнопкой, а не только после отпускания этой кнопки, я должен видеть линию в процессе рисования, а не только в конце.
Интернационализация и сборка
Приложение поддерживает два языка, русский и английский. Я написал маленькую систему i18n. В модуле i18n.py есть функция tr, которая принимает ключ и возвращает переведённую строку из текущего словаря. Функция trf для строк с параметрами, которая берёт перевод и вызывает format с переданными аргументами. Каждый виджет имеет метод retranslate, который обновляет все текстовые надписи при смене языка. Ни внешних библиотек, ни.qm файлов, просто питоновские словари.
Сборка через PyInstaller это отдельная история. На моём macOS всё собиралось идеально. Но стоило скинуть собранный исполняемый файл exe другу на Windows 10, как тут же полезли ошибки с путями к ресурсам. В PyInstaller при работе из собранного бинарника файлы лежат не рядом с exe, а во временной директории, путь к которой хранится в sys._MEIPASS. Я добавил функцию resource_path, которая проверяет, запущено ли приложение из PyInstaller, и если так, то подставляет правильный префикс.
На macOS была своя проблема, это Gatekeeper. Собранный через PyInstaller.app вылетал с ошибкой Launchd job spawn failed на Apple Silicon. Тогда я сделал ad‑hoc подпись через codesign ‑force ‑sign ‑. Никакого сертификата разработчика, просто подпись, которая говорит системе что это локальная сборка. Эта команда теперь есть в моем release.yml в GitHub Actions.
Заключение
FASt начался как шутка. Поставлю фото жены на флиппер, чтобы чаще на неё смотреть. А закончился полноценным open‑source приложением с восемью вкладками, темами, фоновой обработкой, кастомными виджетами отрисовки, тестированием и CI/CD.
Что я усвоил из этого проекта?
Не покупать дешевые китайские подделки карт microSD.
Python и PyQt6 это достаточно гибкий и функциональный стек для серьёзных проектов. Если правильно разделить логику и интерфейс, вынести тяжёлые вычисления в фоновые потоки и покрыть критические пути тестами, то можно создать очень хорошее приложение.
Самые интересные баги прячутся на стыке систем. Мой код на Python был правильным. Формат Flipper был тоже правильным. Но между ними был двухбитный паддинг, про который я и не подумал, и он ломал всё. Нужно писать round‑trip тесты.
И наконец, жена теперь видит себя на экране моего флиппера каждый раз, когда я его достаю. Говорит, что ей нравится. Миссия выполнена.

Ссылки:
-
Momentum‑Firmware: https://github.com/Next‑Flip/Momentum‑Firmware
ссылка на оригинал статьи https://habr.com/ru/articles/1086206/