Есть картинка 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. Для каждого файла:
- Картинка читается через OpenCV как есть, расширение файла не важно. Для проверки этого в тестах лежит JPEG с именем
herta-unix-pic.exeи настоящаяvulkan-1.dll: первый должен обработаться, вторая — быть пропущенной. - Серое изображение превращается в трёхканальное.
- Если каналов четыре, прозрачность отделяется, утраивается до трёх каналов и прогоняется через ту же сеть отдельно. По картинке с прозрачностью сеть отрабатывает дважды.
- Результат пишется в подпапку
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» — это запуск чужой программы, а не загрузка файла с весами.
Три обстоятельства делают это заметнее.
- Предупреждения нет. Ни в интерфейсе, ни в README не сказано, что код из репозитория будет выполнен.
- Версия не закрепляется.
cccvумеет принимать ветку и хеш коммита, но Final2x их не передаёт. При следующем запуске с той же ссылкой выполняетсяgit pull, и отработает то, что лежит в репозитории сегодня, а не то, что было вчера. - Хеш весов необязателен. В
config.jsonдемо-модели стоит"hash": null, и проверка из предыдущего раздела для неё пропускается.
Совет «скачай Final2x и вставь вот эту ссылку, там модель лучше» по последствиям равен совету «скачай и запусти вот этот скрипт». Я бы добавил в интерфейс хотя бы подтверждение при первом использовании незнакомого репозитория и показывал хеш коммита.
Что уходит в сеть
Я выписал все адреса из кода окна и ядра. Список короткий.
github.com/EutropicAI/cccv/releases/download/model_zoo/— веса встроенных моделей, при первом использовании каждой.- Адрес из поля «Model», если туда вписана ссылка, —
git cloneиgit pull. - Адрес из поля «Proxy», если оно заполнено. Он приписывается перед адресом GitHub, и файл весов идёт через этот сервер. Подсказка в поле предлагает как пример
https://github.abskoop.workers.dev/— чужой сервис, к проекту отношения не имеющий. Встроенные модели от подмены на прокси защищает сверка SHA-256.
Больше ничего. Телеметрии, счётчиков и проверки обновлений в коде нет; локальных портов приложение не открывает. Сами картинки никуда не отправляются: их читает и пишет только локальный процесс ядра. Единственная ссылка в интерфейсе ведёт на соседний проект авторов и открывается в системном браузере.
Автообновления тоже нет — новую версию нужно скачивать руками.
Отдельно про 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. Обновление потребует переписать приём файлов.
Настройки самого окна тоже не строгие:
sandbox: falseвsrc/main/index.ts;- политика CSP в
index.htmlзакомментирована; - preload отдаёт странице готовый объект
electronAPIиз@electron-toolkit/preload, а в нёмipcRenderer.sendиinvokeна любой канал, без списка разрешённых.
Насколько это опасно на практике? Меньше, чем звучит. Окно грузит только свой локальный файл, чужих страниц не открывает, попытка открыть новое окно перехватывается и уходит в системный браузер. Каналов, на которые отвечает главный процесс, шесть: запуск и остановка ядра, диалог выбора файлов, три кнопки окна. Путь к ядру страница задать не может. Старый 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 — скачать архив или образ со страницы релизов. Требования по коду и заметкам к релизам такие:
- Windows: ускорение только на видеокартах NVIDIA. На остальных — процессор.
- macOS: только Apple Silicon, считает через MPS. Сборки под Intel убрали в версии 3.0.0.
- Linux: Python 3.10 или новее и PyTorch 2.0 или новее ставятся самостоятельно, затем ядро:
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 внутри.
Ограничения и риски
- Своя модель по ссылке — это выполнение чужого кода, без предупреждения и без закрепления версии.
- Electron 27 без поддержки с апреля 2024 года, песочница и CSP выключены.
- Масштаб по умолчанию 2x для любой модели. Модель 4x без вписанной четвёрки отработает впустую.
- Сборки не подписаны. На macOS нужен
xattr -cr, на Windows — доверие к архиву. - Только картинки. Видео и пакетная обработка с разными настройками — не сюда.
- Нет подсказок по моделям. 68 имён без описаний.
- Метаданные теряются: EXIF и цветовой профиль в результат не переносятся.
- Один основной автор. Из 355 коммитов 262 сделал один человек; всего участников десять, один из них — Dependabot.
- Кэш моделей внутри каталога приложения: переустановка означает повторную загрузку.
Кому подойдёт
Возвращаюсь к трём вопросам.
Что происходит с картинкой — понятно и прозрачно: локальный процесс, открытые 404 строки, проверенные хешем веса. Картинки компьютер не покидают, телеметрии нет. Это главное, ради чего такую программу ставят, и здесь всё честно.
Что качается из сети — файлы весов с GitHub. Пока в поле «Model» стоит имя из списка, а поле «Proxy» пустое, сюрпризов нет.
Можно ли ставить на рабочую машину — да, с двумя правилами. Не вставлять в поле «Model» ссылки, код по которым ты не читал. И помнить, что обновлений безопасности у окна нет давно, а подлинность сборки подтверждается только адресом, с которого она скачана.
Кому подойдёт: тем, кому нужно увеличить пачку картинок на своей видеокарте NVIDIA или на Mac с Apple Silicon и кто готов подобрать модель перебором. Тем, кому нужна утилита командной строки для того же самого, — Final2x-core сам по себе, пожалуй, интереснее окна.
Кому не подойдёт: владельцам видеокарт AMD и Intel на Windows — у них будет считать процессор. Тем, кто ждёт установщик, автообновление и подписанное приложение. И тем, кому нужны исследовательские эксперименты с чужими моделями на машине с рабочими доступами — для этого лучше взять отдельный компьютер или контейнер.