личный блог

Статьи · ai

Final2x: увеличение картинок нейросетями на своём компьютере — разбор по исходникам

·17 мин чтения ·Статьи·

Есть картинка 640 на 480, а нужна она на слайд, в печать или на обои. Обычное растягивание даёт мыло. Онлайн-сервисы справляются лучше, но просят загрузить файл на чужой сервер. Final2x обещает третий путь: окно, в которое перетаскиваешь картинки, список нейросетей и кнопка START — всё считается на своей видеокарте.

Проект не новый и не маленький. Первый коммит сделан 19 июня 2023 года, с тех пор 355 коммитов, 19 релизов, 7351 звезда и 315 559 скачиваний сборок. Версия 4.1.0 вышла 30 сентября 2026 года. При этом README умещается в 70 строк и про устройство программы не говорит почти ничего.

Сразу про метод: я читал исходники версии 4.1.0 и библиотеки cccv, на которой она работает, а само приложение не запускал. Всё ниже — то, что написано в коде и в настройках сборки. Вопросов у меня было три: что происходит с картинкой между кнопкой START и готовым файлом, что программа качает из сети и можно ли ставить её на рабочую машину. Короткий ответ: встроенным моделям доверять можно, а поле «Model» умеет больше, чем кажется, и об этом стоит знать до первого запуска.

Что это и из чего состоит

Final2x — это две программы в одной коробке.

Часть Где лежит На чём написана Объём
Окно src/ Electron 27, Vue 3, naive-ui, TypeScript 2541 строка
Ядро Final2x-core core/ Python, pydantic, OpenCV 404 строки
Библиотека cccv отдельный репозиторий PyTorch архитектуры сетей и загрузка весов

Окно ничего не считает. Оно собирает список файлов и настройки, запускает ядро отдельным процессом и показывает его вывод. Ядро тоже считает не само: в нём 404 строки, и почти все они про чтение настроек, очередь файлов и альфа-канал. Нейросети живут в cccv — библиотеке тех же авторов, в поставке закреплена версия 0.1.0.

До версии 4.1.0 ядро было отдельным репозиторием. Теперь оно лежит в каталоге core/, а скрипт scripts/check_version.py при релизе проверяет, что версия в package.json, версия в core/pyproject.toml и тег совпадают.

Интерфейс переведён на четыре языка: английский, китайский, японский и французский. Русского нет.

Как проходит обработка

По нажатию START окно собирает объект с настройками и отправляет его в главный процесс Electron. Дальше — src/main/runCommand.ts:

let config_json = JSON.stringify(coreConfig.config)
config_json = Buffer.from(config_json, 'utf8').toString('base64')

const resourceUrl = getCorePath()
const args = [...getCoreArgs(), '-b', config_json]

if (!coreConfig.options.open_output_folder)
  args.push('-n')

const runningChild = spawn(resourceUrl, args)

Настройки превращаются в JSON, JSON — в base64, и эта строка уходит ядру одним аргументом -b. Запуск идёт через spawn без оболочки, так что пробелы и кавычки в путях ничего не ломают — аккуратное решение.

Ядро разбирает строку и проверяет её моделью pydantic из core/Final2x_core/config.py. В ней девять полей: модель, устройство, тайлы, точность, прокси, масштаб, папка вывода, список файлов и формат. Папка должна существовать, каждый файл тоже — иначе ядро завершится до начала работы.

Потом идёт очередь, core/Final2x_core/SRqueue.py. Для каждого файла:

  1. Картинка читается через OpenCV как есть, расширение файла не важно. Для проверки этого в тестах лежит JPEG с именем herta-unix-pic.exe и настоящая vulkan-1.dll: первый должен обработаться, вторая — быть пропущенной.
  2. Серое изображение превращается в трёхканальное.
  3. Если каналов четыре, прозрачность отделяется, утраивается до трёх каналов и прогоняется через ту же сеть отдельно. По картинке с прозрачностью сеть отрабатывает дважды.
  4. Результат пишется в подпапку outputs с именем вида 2x-имя.png. Существующий файл не затирается: к имени добавляется (1), (2) и так далее.

Форматов вывода четыре: PNG, JPG, WebP и TIFF. Метаданные при этом не переносятся: ядро сохраняет только массив пикселей, EXIF и цветовой профиль исходника в результат не попадают.

Режим тайлов включён по умолчанию: картинка режется на квадраты 128 на 128 пикселей, чтобы большая фотография поместилась в память видеокарты. Размер квадрата зашит в коде — tile = (128, 128) if self.config.use_tile else None — и из интерфейса не меняется.

Масштаб по умолчанию — всегда 2x

Это место я перечитал дважды. В списке есть модели на 2x, 3x и 4x, а рядом поле «Custom Scale» с подсказкой «Default». Логично ожидать, что пустое поле означает «как у модели». В коде иначе:

cfg = cls(**config)
if cfg.target_scale is None or cfg.target_scale <= 0:
    cfg.target_scale = 2

Пустое поле превращается в двойку независимо от выбранной модели. А дальше, в SRclass.py, результат сети подгоняется под этот размер:

img = self._SR_class.inference_image(img)
...
if _current_size != _target_size:
    img = cv2.resize(img, _target_size, interpolation=cv2.INTER_LINEAR)

По коду выходит так: модель 4x честно увеличит картинку вчетверо, после чего её уменьшат обратно до 2x обычной билинейной интерполяцией. Чтобы получить 4x, четвёрку нужно вписать руками.

Работает это и в обратную сторону. Модель 2x с масштабом 8 даст двукратное увеличение сетью и ещё четырёхкратное — той же интерполяцией, то есть мылом. Тест test_targetscale.py гоняет одну модель 2x с масштабами от 0,673 до 8 и проверяет только размер и похожесть результата. Поле «Custom Scale» — это размер итогового файла, а не сила нейросети. В интерфейсе об этом ни слова.

Интерфейс и ядро общаются текстом

Главное архитектурное решение проекта — два процесса и никакого протокола между ними. Ядро пишет в stdout обычный журнал, а окно выуживает из него три строки регулярными выражениями (MyProgress.vue):

const skipImageRegex = /______Skip_Image______:(.+)/
const processingRegex = /Processing------\[ ([\d.]+)% /
const srSuccessRegex = /______SR_COMPLETED______/

Первая показывает уведомление о пропущенном файле, вторая двигает полосу прогресса, третья означает успех. Нет третьей строки к моменту завершения процесса — окно покажет «Processing failed» и предложит читать журнал.

Что в этом хорошего. Ядро — самостоятельная утилита командной строки: принимает настройки как base64, как JSON или из YAML-файла, ставится из PyPI и работает без окна. Падение PyTorch не роняет интерфейс. Кнопка TERMINATE убивает процесс со всеми потомками через tree-kill, то же происходит перед выходом из приложения.

Что плохо. Связь держится на тексте журнала: стоит поменять формулировку в logger.info — и прогресс молча остановится на нуле. Процесс ядра живёт одну пачку файлов: каждое нажатие START заново импортирует PyTorch и заново грузит модель в память.

Ещё одна деталь из src/main/getCorePath.ts:

if (checkPipPackage())
  return 'Final2x-core'

checkPipPackage запускает Final2x-core -h и смотрит на код возврата. Если команда с таким именем нашлась в PATH, используется она, а не ядро из поставки. На Linux это штатный путь: сборки для него так и называются — linux-pip, ядра внутри нет. На Windows и macOS это значит, что забытая в PATH старая версия ядра тихо подменит встроенную. Проверка идёт синхронно при каждом START, а ядро до разбора аргументов успевает импортировать PyTorch — на Linux главный процесс Electron на это время заблокирован.

Модели: 68 встроенных

Список моделей в интерфейсе не пишется руками. Файл modelOptions.ts генерирует скрипт core/scripts/gen_pretrained_model_name.py из реестра cccv. Я пересчитал: в файле 68 записей.

Семейство Моделей
RealCUGAN 17
HAT 14
DAT 13
RealESRGAN 9
SwinIR 6
EDSR 3
SCUNet 3
SRCNN 3

По умолчанию выбрана RealESRGAN_RealESRGAN_x2plus_2x. Подсказок, какая модель для чего, в интерфейсе нет — только имена. По ним видно разделение: часть обучена на фотографиях, часть на аниме, SCUNet убирает шум без увеличения. Выбирать придётся перебором.

Веса в поставку не входят. При первом использовании модели ядро качает её файл с GitHub, из релиза model_zoo репозитория cccv: там 75 файлов почти на 4 ГБ. Кэш лежит в папке cache_models рядом с исполняемым файлом ядра — то есть, по коду, внутри каталога установленного приложения, а на macOS внутри Final2x.app. Переустановил приложение — модели скачаются заново.

Хорошая новость: у всех 68 встроенных моделей в конфигурации записан SHA-256, и после загрузки файл с ним сверяется (cccv/util/remote.py):

if config.hash is not None:
    get_hash = get_file_sha256(cached_file_path)
    if get_hash != config.hash:
        raise ValueError(...)

Сам файл весов читается через torch.load(..., weights_only=True) — в этом режиме PyTorch не выполняет код, спрятанный в файле. Для программы, которая качает бинарные файлы из сети, это правильные две строки.

Мелкая шероховатость: при несовпадении хеша файл из кэша не удаляется. Следующий запуск найдёт его на месте, качать не станет и упадёт с той же ошибкой, пока файл не удалишь руками.

Своя модель по ссылке: что при этом выполняется

Теперь про поле «Model». Это не обычный выпадающий список: у него включён свободный ввод (filterable tag), и в заметках к версии 4.0.0 прямо сказано, что туда можно вписать путь к папке или адрес git-репозитория с моделью. README ссылается на пример — cccv_demo_remote_model.

Что происходит со ссылкой, видно в cccv/auto/config.py. Если строка начинается с http, репозиторий клонируется в кэш. А дальше:

# auto import all .py files in the directory to register the arch, model and config
for py_file in dir_path.glob("*.py"):
    spec = importlib.util.spec_from_file_location(py_file.stem, py_file)
    if spec is None or spec.loader is None:
        continue
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)

Каждый .py из корня репозитория импортируется, то есть выполняется, с правами пользователя. Это не ошибка, а способ подключить новую архитектуру сети: в демо-репозитории два таких файла, и один из них при импорте печатает New Model Registered: demo_srcnn. Но следствие стоит назвать прямо: ссылка в поле «Model» — это запуск чужой программы, а не загрузка файла с весами.

Три обстоятельства делают это заметнее.

Совет «скачай Final2x и вставь вот эту ссылку, там модель лучше» по последствиям равен совету «скачай и запусти вот этот скрипт». Я бы добавил в интерфейс хотя бы подтверждение при первом использовании незнакомого репозитория и показывал хеш коммита.

Что уходит в сеть

Я выписал все адреса из кода окна и ядра. Список короткий.

Больше ничего. Телеметрии, счётчиков и проверки обновлений в коде нет; локальных портов приложение не открывает. Сами картинки никуда не отправляются: их читает и пишет только локальный процесс ядра. Единственная ссылка в интерфейсе ведёт на соседний проект авторов и открывается в системном браузере.

Автообновления тоже нет — новую версию нужно скачивать руками.

Отдельно про macOS: на чистой системе загрузка моделей падала с CERTIFICATE_VERIFY_FAILED. В 4.1.0 это исправили: ядро первым делом подключает системное хранилище сертификатов через truststore, до импорта всего остального. На это написан отдельный интеграционный тест, test_frozen_tls.py.

Окно: Electron 27 и его настройки

Вот что насторожило меня больше остального. В package.json стоит "electron": "^27.3.11", в lock-файле — ровно 27.3.11. Поддержка ветки Electron 27 закончилась 16 апреля 2024 года, внутри у неё Chromium 118; актуальная ветка сейчас — 44. Остальные зависимости при этом свежие: Vite 7, Vue 3.5, обновления приходят регулярно. Electron стоит на месте два с половиной года.

Причина, похоже, техническая. Окно получает путь к перетащенному файлу из свойства files[0].path, а его убрали в Electron 32. Обновление потребует переписать приём файлов.

Настройки самого окна тоже не строгие:

Насколько это опасно на практике? Меньше, чем звучит. Окно грузит только свой локальный файл, чужих страниц не открывает, попытка открыть новое окно перехватывается и уходит в системный браузер. Каналов, на которые отвечает главный процесс, шесть: запуск и остановка ядра, диалог выбора файлов, три кнопки окна. Путь к ядру страница задать не может. Старый Chromium здесь показывает только интерфейс самого приложения, а картинки декодирует не он, а OpenCV в ядре.

Так что это не дыра, а запущенность: три слоя защиты выключены, и держится всё на том, что в окно не попадает чужое содержимое. Имена файлов и журнал ядра выводятся компонентами naive-ui как текст, не как разметка.

Одно место я проверил отдельно. После обработки ядро открывает папку с результатами:

elif sys.platform.startswith("darwin"):
    os.system('open "{}"'.format(path))
elif sys.platform.startswith("linux"):
    os.system('xdg-open "{}"'.format(path))

Путь подставляется в строку для оболочки. Я взял эту строку, заменил open на безобидную команду и передал папку с именем $(touch marker) — файл marker появился. На macOS и Linux имя папки вывода, в котором есть $(...) или обратные кавычки, выполнится как команда. Встретить такую папку случайно трудно, но чинится это одной строкой — subprocess.run(["open", path]). Открытие папки включено по умолчанию и отключается переключателем «Auto Open».

Сборка, подпись и релизы

Релиз собирает Release.yml по тегу v*. Начало у него образцовое: задание validate проверяет, что коммит тега лежит в main, а версии окна, ядра и тега совпадают. Зависимости ставятся строго по lock-файлам (pnpm install --frozen-lockfile, uv sync --locked). Пакет на PyPI публикуется без сохранённого токена, через доверенную публикацию.

Что получается на выходе у версии 4.1.0:

Платформа Файл Размер Ядро внутри
Windows x64 архив .7z 2,1 ГБ да, с PyTorch под CUDA
macOS arm64 .dmg 267 МБ да
Linux x64 AppImage, deb, snap 80–122 МБ нет, ставится через pip

Два гигабайта под Windows — это PyTorch 2.7.1 со сборкой CUDA 12.8, упакованный PyInstaller. Установщика для Windows нет: раздел nsis в настройках сборки есть, но релиз собирается с флагом --dir и отдаёт распакованный каталог в архиве. В package.json описаны сборки под Windows arm64, macOS x64 и Linux arm64, но в релиз они не попадают.

С подписью хуже. Сертификатов в Release.yml не передаётся, а скрипт нотаризации build/notarize.js без переменных APPLE_ID и APPLE_ID_PASS сразу выходит. В нём, к слову, опечатка: проверяется APPLE_ID_PASS, а читается process.env.APPLEIDPASS. Следствие видно в README: на macOS предлагается снимать карантин командой xattr -cr. Подлинность сборки подтверждает только то, что она скачана со страницы релизов; GitHub показывает для каждого файла SHA-256, отдельных подписей нет.

Ещё два следа шаблона в настройках для macOS. В Info.plist попадают строки-объяснения для доступа к камере и микрофону, хотя в коде к ним никто не обращается. А в правах приложения разрешены JIT, неподписанная исполняемая память и переменные окружения загрузчика.

Сторонние действия в CI — их двенадцать — закреплены по тегам (@v4, @v10.0.1), а публикация на PyPI — по ветке release/v1. По хешу коммита не закреплено ни одно.

Установка и запуск

Windows и macOS — скачать архив или образ со страницы релизов. Требования по коду и заметкам к релизам такие:

pip install Final2x-core
Final2x-core -h
sudo apt install -y libomp5 xdg-utils

PyTorch среди зависимостей пакета нет — ни у Final2x-core, ни у cccv. Какую сборку поставить, под CUDA или под ROCm для AMD, решает пользователь.

Ядро полезно и без окна. Настройки можно положить в YAML:

pretrained_model_name: RealESRGAN_RealESRGAN_x4plus_4x.pth
device: auto
use_tile: true
precision: fp32
target_scale: 4
output_path: /home/user/pictures
input_path:
  - /home/user/pictures/photo.jpg
save_format: .png

и запустить Final2x-core -y config.yaml -n. Ключ -n отключает открытие папки, -l включает запись журнала в файл. Для пакетной обработки на сервере это удобнее окна: окно умеет только одну очередь за раз.

Что остаётся работать: значок в трее. На macOS кнопка закрытия прячет приложение, а не завершает его; на Windows и Linux — завершает. Процесс ядра живёт только во время обработки.

Зависимости ставятся из npm, PyPI и download.pytorch.org. В lock-файле ядра 84 пакета, восемь из них берутся из каталога PyTorch под CUDA 12.8. Три пакета окна — systeminformation, node-fetch и extract-zip — числятся в package.json, но в коде не импортируются.

Лицензии

Окно и ядро — BSD 3-Clause. Библиотека cccv — MIT. PyTorch, OpenCV, Electron и naive-ui идут под своими свободными лицензиями.

С весами моделей сложнее. Это 75 файлов разных авторов: в конфигурации cccv они помечены комментариями вроде «RealESRGAN official» и «RealESRGAN community». Лицензии весов не названы ни в README Final2x, ни в README cccv. Для домашнего использования это неважно. Перед коммерческим — условия каждой модели придётся искать у её авторов.

Собранное ядро в сборках для Windows и macOS по исходникам не проверить: это каталог PyInstaller с PyTorch и библиотеками CUDA внутри.

Ограничения и риски

Кому подойдёт

Возвращаюсь к трём вопросам.

Что происходит с картинкой — понятно и прозрачно: локальный процесс, открытые 404 строки, проверенные хешем веса. Картинки компьютер не покидают, телеметрии нет. Это главное, ради чего такую программу ставят, и здесь всё честно.

Что качается из сети — файлы весов с GitHub. Пока в поле «Model» стоит имя из списка, а поле «Proxy» пустое, сюрпризов нет.

Можно ли ставить на рабочую машину — да, с двумя правилами. Не вставлять в поле «Model» ссылки, код по которым ты не читал. И помнить, что обновлений безопасности у окна нет давно, а подлинность сборки подтверждается только адресом, с которого она скачана.

Кому подойдёт: тем, кому нужно увеличить пачку картинок на своей видеокарте NVIDIA или на Mac с Apple Silicon и кто готов подобрать модель перебором. Тем, кому нужна утилита командной строки для того же самого, — Final2x-core сам по себе, пожалуй, интереснее окна.

Кому не подойдёт: владельцам видеокарт AMD и Intel на Windows — у них будет считать процессор. Тем, кто ждёт установщик, автообновление и подписанное приложение. И тем, кому нужны исследовательские эксперименты с чужими моделями на машине с рабочими доступами — для этого лучше взять отдельный компьютер или контейнер.

Олег Букатчук
Олег Букатчук
DevOps- и SRE-инженер. Строил отказоустойчивую инфраструктуру для классифайда, e-commerce и госсектора: cian.ru, aliexpress.ru, mos.ru.