Files
WhatIDo/TODO_PHOTO_FACE_AI.md
T
dev 4c63a46d24 fix(photo-ai): лестница OOM на CUDA + приёмка face-режима и GPU-оверрайда
Stage 3 закрыт. Face-режим (off/face/all, strength, warnings) был написан в Stage 1;
здесь доведена приёмка и найден баг, который невозможно было увидеть без GPU-прогона.

Главное: realesные OOM никогда не доходили до run_guarded. И realessrgan/utils.py, и
gfpgan/utils.py ловят RuntimeError вокруг вызова сети и идут дальше
(`except RuntimeError as error: print('Error', error)`), поэтому на нехватку памяти
realesrgan падал уже не RuntimeError, а UnboundLocalError на присваивании
output_tile. Наружу уходил голый 500 «Internal Server Error»: ни лестницы тайлов,
ни деградации на CPU, ни внятного текста. В gfpgan это было тихое ухудшение —
при OOM лицо молча оставалось исходным, а задание уходило в «успех».

Починка: guard_forward() оборачивает forward сетей, которые строим мы
(RealESRGANer.model, restorer.gfpgan, CodeFormer net) и превращает OOM-RuntimeError
в TileOOM. TileOOM не наследует RuntimeError, поэтому проглатывающие except его
пропускают; is_oom и обе точки run_guarded ловят его явно. Обёртка вешается на
экземпляр, идемпотентна по флагу _photo_ai_guarded и не трогает класс.

Также добавлены два предупреждения из чек-листа, которых в коде не было: вход меньше
320×320 (лица могут не найтись) и CodeFormer на не-CUDA. Предупреждение «лица не
найдены» и деградация на CPU были на месте и не менялись.

Проверено на RTX 3050 Laptop (4096 МБ, драйвер 615.71.09, CUDA 12.6):
- tile=2048, x2plus face=all, полное фото: до правки 500 + UnboundLocalError,
  после 200 за 28.3 с с единственным warning «не хватило памяти при tile=2048»;
- инъекция OOM: лестница 2048 → 1024 → 512, затем переход на CPU (device: cpu,
  half: false) и успешный повтор; при повторе уже на CPU — честная 500 с подсказкой
  про PHOTO_AI_TILE / PHOTO_AI_MAX_PIXELS;
- 640×480: off 1.0 с / face 3.8 с / all 2.2 с, faces_found=6; фото без лиц даёт
  faces_found=0 и байты, равные face=off;
- I1: 6/6 MATCH байт-в-байт против Stage 2 на CPU (jpg/.jpeg/png+70/webp+100/x4v3/anime).

docker-compose.gpu.yml: GPU выдаётся через CDI (device_ids nvidia.com/gpu=all) —
не требует правки /etc/docker/daemon.json и перезапуска демона, в отличие от
классического резервирования driver: nvidia. TORCH_VARIANT cu124 → cu126: в индексе
cu124 последний torch 2.6.0, а cu126 даёт те же 2.14.0/0.29.0, что и CPU-образ, так
что варианты сборки отличаются только CUDA-библиотеками. Образ тегируется отдельно
(whatido-photo-ai:cu126), чтобы сборка GPU-варианта не перетирала CPU-образ
whatido-photo-ai:latest — откат остаётся обычным docker compose up -d photo-ai.

README: раздел «Запуск на NVIDIA GPU» с установкой NVIDIA Container Toolkit и генерацией
CDI-спеки, оговорками про 4 ГБ VRAM (x2plus + gfpgan влезают, general-x4v3 тяжелее,
LOAD_ALL=1 лучше не включать) и описанием параметров /enhance. .env.example: команда
GPU-запуска и рекомендация по PHOTO_AI_TILE. Журналы раздела 3 и GPU-прогона — в
TODO_PHOTO_FACE_AI.md.

worker.js, server.js, схема БД и фронтенд не тронуты — они в Stage 4…6.
2026-09-29 12:02:03 +03:00

606 lines
67 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TODO: фото-ИИ с восстановлением лиц (Real-ESRGAN + GFPGAN/CodeFormer), авто-выбор GPU/CPU
Источник требований: `PLAN_PHOTO_FACE_AI.md`. Этот файл — **исполняемый чек-лист для агента**,
который пишет код. План не дублируется: здесь только задачи, якоря в коде, контракты и приёмка.
Перед стартом агент обязан прочитать `AGENTS.md` (правила проекта) и `PLAN_PHOTO_FACE_AI.md` целиком.
---
## 0. Жёсткие инварианты (нарушение = регресс, работа не принята)
- [x] **I1. Обратная совместимость `/enhance`.** Запрос только с `image` + `scale=2` (без `model`/`face`)
обязан давать тот же JPEG, что и до изменений: `x2plus`, `half=False` на CPU, `IMWRITE_JPEG_QUALITY=92`.
Проверяется сравнением с эталоном из Stage 0. Эталон снят (см. «Журнал раздела 0»), прогон
`node backups/photo-face-ai-baseline/capture.js <label> && node backups/photo-face-ai-baseline/verify.js <label>`
даёт байт-в-байт совпадение; повторная проверка обязательна на приёмке Stage 1.
- [x] **I2. Старые задания.** `photo_jobs` со `params IS NULL` и `action='ai'` продолжают обрабатываться
воркером как раньше (дефолты подставляются на стороне воркера/сервиса).
- [x] **I3. photo-ai не обязателен.** Пустой `PHOTO_AI_URL` → кнопка «🤖 ИИ» скрыта
(`public/js/journal.js:718`), `POST /api/entries/:id/photo/enhance-ai` → `503`
(`server.js:5115`), приложение полностью работоспособно.
- [x] **I4. GPU не обязателен.** Нет `nvidia-ctk` / нет CUDA → контейнер поднимается, `PHOTO_AI_DEVICE=cuda`
даёт WARN в лог и молчаливый откат на CPU. Падение `/health` из-за отсутствия GPU недопустимо.
Проверено на этом хосте: `nvidia-ctk` **отсутствует**, nvidia-runtime в `docker info` нет, `photo-ai`
поднялся и `/health` → `{"ok":true}` (GPU недоступен → сервис не падает). Переменная
`PHOTO_AI_DEVICE` появится только в Stage 1 — проверка `cuda` → WARN + CPU откат переносится
на приёмку Stage 1, сам GPU-режим на этом хосте не проверяется (стоп-условие: нужен
`nvidia-container-toolkit`, ставить без разрешения оператора нельзя).
- [x] **I5. Воркер не сжигает попытки на «мягких» ошибках.** `503` / недоступный сервис → задание возвращается
в `pending` **без** инкремента `attempts`; лимит мягких повторов (например 60) → одна честная ошибка.
- [x] **I6. Никаких новых колонок в БД.** Метаданные обработки (модель, устройство, время, `faces_found`)
живут в `photo_jobs.params` (JSONB уже есть) и в `audit_log.target`.
- [x] **I7. Код без комментариев** (правило `AGENTS.md`), CommonJS в Node-части, параметризованный SQL,
никаких прямых `fs.*` по `uploads/` (только `storage.*`).
### Журнал раздела 0 (проверено 2026-09-28)
Что сделано, кроме проверки: в `worker.js` устранены два нарушения инвариантов — прямой
`fs.writeFileSync` в `uploads/` вместо `storage.put` (I7) и сжигание `attempts` на `503`/недоступности
(I5). В `server.js` флаг `photo_ai_enabled` вынесен из кэша `public-settings` (I3). Новые переменные
`PHOTO_AI_SOFT_*` описаны в `.env.example`.
| Инвариант | Как проверено | Результат |
|---|---|---|
| I1 | `capture.js` → `before/`, повтор `repeat1/`, прогон после правок `after-section0/`; `verify.js` сравнивает sha256 | 5/5 байт-в-байт, `/health` `{"ok":true}` |
| I2 | запись `INSERT INTO photo_jobs (entry_id, action, status, params) VALUES (385,'ai','pending',NULL)` | `done`, `attempts=0`, результат отдаётся из S3 (200) |
| I3 | `docker compose -f docker-compose.yml -f no-photo-ai.yml up -d app` (`PHOTO_AI_URL: ""`) | `photo_ai_enabled=false`, `enhance-ai` → 503, 8 маршрутов API живы |
| I4 | `backups/photo-face-ai-baseline/env.txt` | `nvidia-ctk` NOT_FOUND, `nvidia-container-runtime` NOT_FOUND, runtimes `runc`/`io.containerd.runc.v2`, `photo-ai` Up, `/health` ok |
| I5 | `photo-ai` остановлен → задание ждало 70 с → вернулось `pending` при `attempts=0`; после `docker compose start photo-ai` → `done` при `attempts=0`. Лимит проверен прогоном с `PHOTO_AI_SOFT_MAX_RETRIES=2` | мягкий повтор не тратит попытки; после лимита `status=error`, `attempts=0`, текст «мягкие повторы исчерпаны (2)» |
| I6 | `git status db/` пуст, `information_schema.columns` для `photo_jobs` — те же 12 колонок; метаданные повторов в `audit_log.target` (`soft_attempt`, `soft_limit`) | без изменений схемы |
| I7 | `node --check worker.js server.js api.smoketest.js public/js/audit.js`; `grep` по `worker.js` — ни `require('fs')`, ни `require('path')`, ни `uploadsDir`; в диффе нет строк с комментариями; все SQL — на `$1/$2` | чисто |
Эталон и скрипты — в `backups/photo-face-ai-baseline/` (вне git): `env.txt`, `inputs/`, `inputs.json`,
`before/`, `repeat1/`, `after-section0/`, `capture.js`, `verify.js`, `make-inputs.js`.
Тестовые задания (id 90–94), их аудит, уведомления и 3 объекта результата в S3 удалены.
---
## 1. Что уже есть (сверено в коде, не перепроверять)
| Место | Сейчас | Что меняем |
|---|---|---|
| `photo-ai/app.py:15–73` | одна модель, `MODEL_PATH`, `MAX_INPUT_PIXELS`, `lock`+`upsampler`, `/health` → `{ok}`, `/enhance(image, scale)` | реестр моделей, `pick_device()`, `ModelPool`, расширенные `/health` и `/models` |
| `photo-ai/Dockerfile` | `python:3.10-slim`, `torch --index-url .../whl/cpu`, `realesrgan==0.3.0`, патч `basicsr/data/degradations.py` | `ARG TORCH_VARIANT`, `gfpgan`/`facexlib`, вендоринг CodeFormer |
| `docker-compose.yml:194–204` | `photo-ai`: `MODEL_PATH`, `MAX_INPUT_PIXELS`, том `photo-ai-models`, `ports: 127.0.0.1:8081:8080` (D1) | новые env + `healthcheck` |
| `docker-compose.yml:208` | том `photo-ai-models` | без изменений |
| `server.js:1841` | `PHOTO_AI_URL` | + константы `PHOTO_AI_FACE_MODEL`, `PHOTO_AI_FACE_TIMEOUT_MS` |
| `server.js:1237–1256` | `ensurePhotoJobsTable()`: колонка `action VARCHAR(20)`, `params JSONB`, CHECK на action **нет** | миграция схемы не требуется |
| `server.js:2254` | `PHOTO_JOB_ACTIONS = new Set(['ai','enhance','restore','rollback'])` | + `'ai_face'`, `'ai_upscale'` |
| `server.js:5114–5136` | `POST .../enhance-ai`: без тела → `action='ai'`, `params=NULL` | приём/валидация `{model, face, face_model, strength}` |
| `server.js:5782–5837` | `GET /api/photo-jobs/status`: `service = await aiHealthCheck()` вычисляется, но **в ответ не попадает** | вернуть `service` (это баг-дыра, см. D2) |
| `server.js:769–826` | `getStackInfo()`: `app/deps/runtime/database/cache/storage` | + блок `photo_ai` |
| `server.js:1663–1664` | `keys`/`defaults` для `/api/public-settings` | + `photo_ai_face_mode`, `photo_ai_face_model`, `photo_ai_device_pref` |
| `server.js:1710` | валидация `photo_enhance_engine` в `PUT /api/settings` | + валидация трёх новых ключей |
| `server.js:6045–6056` | `createPhotoEnhanceWorker({...})` | + `faceTimeoutMs`, `defaultFaceModel` |
| `worker.js:11` | `PHOTO_AI_TIMEOUT_MS = 300000` (хардкод) | читать env, добавить face-таймаут |
| `worker.js:35–41` | `CONFIG` воркера | + `face_model`, `default_model`, `face_timeout_ms` |
| `worker.js:122–141` | `runAiEnhance(srcKey)`: `image` + `scale=2`, ждёт сырой `image/jpeg` | проброс `params`, `Accept: application/json`, разбор JSON, `503` без траты попыток |
| `worker.js:176` | `job.action === 'ai' ? runAiEnhance : enhanceWithSharp` | + `'ai_face'`, `'ai_upscale'` |
| `worker.js:186–192` | инкремент `attempts` и `photo.job.retry` | не инкрементить при `503`/недоступности |
| `public/js/journal.js:606–615` | `loadEnhanceEngine()` читает `/api/public-settings` | + чтение дефолта face-режима |
| `public/js/journal.js:617–671` | `runPhotoAi()`: `confirm()` + POST без тела | + селект модели, тело `{model, face, face_model}` |
| `public/js/journal.js:718` | показ кнопки по `photo_ai_enabled` | без изменений (I3) |
| `public/js/worker.js:30–35` | `PHOTO_ACTION_LABELS` | + `ai_face`, `ai_upscale` |
| `public/js/worker.js:300–308` | `openPhotoJob(r)` — модалка «Было/Стало» | + `model`/`device`/`faces_found`/`elapsed_ms` |
| `public/js/settings.js:1` | `DIRTY_FIELDS` | + новые поля |
| `public/js/settings.js:217–218, 739` | загрузка/сохранение `photo_enhance_engine` | + face-режим, face-модель, желаемое устройство |
| `public/js/settings.js:372–477` | `renderStackInfo()` — 5 карточек | + карточка «Фото-ИИ» |
| `public/settings.html:221–260` | карточка `#sec-photo` (камера) | + блок «Фото-ИИ» (или новая карточка) |
| `public/js/audit.js:34–48` | словарь `photo.*` | + новые коды аудита |
| `db/init.sql:242`, `db/migration.sql:234` | `INSERT ... 'photo_worker_enabled'` | + дефолты новых настроек |
| `.env.example:39–41` | `PHOTO_AI_URL`, `PHOTO_AI_MAX_PIXELS` | + остальные переменные |
| `photo-ai` в compose | **порт не проброшен**; `text-corrector` занимает `8080:8080` | см. D1 — ручные `curl` из плана не сработают |
---
## 2. Расхождения плана с кодом — решить ДО правок
Все шесть расхождений закрыты 2026-09-28 (решения ниже, правки кода — в своих этапах).
- [x] **D1. Порт для ручных проверок.** `photo-ai` не имеет `ports`/`expose`, а хостовый `8080` занят
`text-corrector`. Команды вида `curl http://localhost:8080/enhance` из §10 плана попадут
в `text-corrector`. Решение выбрано одно: пробросить `ports: ["127.0.0.1:8081:8080"]` в сервис
`photo-ai` — только loopback, наружу (`0.0.0.0`) ничего не публикуется, хостовый `8080`
(`text-corrector`) не затрагивается. **Правка применена 2026-09-28:** порт добавлен в
`docker-compose.yml`, способ отражён в `README.md` (раздел «ИИ-улучшение фото») и `.env.example`.
Проверено: `docker compose up -d photo-ai` → `curl http://127.0.0.1:8081/health` → `{"ok":true}`,
`ss -tulpn` → `LISTEN 127.0.0.1:8081` (не `0.0.0.0`).
- [x] **D2. `service` в статусе фото-воркера.** `server.js:5825` вызывает `aiHealthCheck()` (это health
**текстового** ИИ), результат не возвращается. Решение: отдельная функция, общий `aiHealthCheck()`
не переиспользуем и из `/api/photo-jobs/status` убираем.
- `photoAiHealth()` рядом с `aiHealthCheck()`: `GET ${PHOTO_AI_URL}/health`, таймаут 5 с, без throw;
пустой `PHOTO_AI_URL` → `{ configured: false, reachable: false, latency_ms: 0, error: 'PHOTO_AI_URL не настроен' }`,
недоступность/таймаут → `{ configured: true, reachable: false, latency_ms, error }`.
- `service` в ответе = `{ configured, reachable, latency_ms, error }` + passthrough полей photo-ai
(`ok, ready, device, device_name, half, tile, driver, cuda, vram_total_mb, vram_free_mb, models,
face_models, loaded, loading, max_pixels`). Первые четыре — тот же контракт, который уже читает
фронт текстового ИИ (`public/js/worker.js:89–116`: `reachable`, `latency_ms`, `error`),
поэтому карточки переиспользуются без правок.
- Вызов уходит в тот же `Promise.all`, что и запросы к БД, — иначе статус получает лишние 5 с;
HTTP 200 в любом случае, недоступный сервис — не ошибка API.
- Прямой прокси для оператора — `GET /api/photo-ai/health` (`requireAdmin`), отдельным пунктом Stage 4.
- **Правка применена 2026-09-28:** `photoAiHealth()` в `server.js` рядом с `aiHealthCheck()`,
вызов ушёл в `Promise.all` запроса `/api/photo-jobs/status`, `service` возвращается.
Проверено на живом стеке: сервис поднят → `{"ok":true,"configured":true,"reachable":true,"latency_ms":3,"error":null}`;
`docker compose stop photo-ai` → HTTP 200 и `{"configured":true,"reachable":false,"latency_ms":3661,"error":"fetch failed"}`
(без исключения); после `start` → снова `reachable: true`. Контракт зафиксирован в
`api.smoketest.js` (два новых assert'а).
- [x] **D3. Где живёт белый список действий.** План говорит про два места (`ensurePhotoJobsTable()` и restore).
По факту `PHOTO_JOB_ACTIONS` используется **только** в нормализации restore-данных (`server.js:2254`,
единственное применение — `server.js:2259`; `grep` по репозиторию больше нигде), а в
`ensurePhotoJobsTable()` (`server.js:1237–1254`) CHECK-ограничения на `action` нет:
`action VARCHAR(20) NOT NULL DEFAULT 'ai'` — новые значения (`ai_face`, `ai_upscale`) помещаются.
Решение: обновить одно место.
- Набор выносится на уровень модуля (рядом с `PHOTO_AI_URL`, `server.js:1841`), а не внутрь
функции restore; используется в restore-нормализации **и** в валидации
`POST /api/entries/:id/photo/enhance-ai` (Stage 4) — забыть маршрут нельзя.
- CHECK в БД **не добавляем** (I6: никаких изменений схемы, миграция не нужна). Сверка списка
с фактическими `action`, которые пишет код, — `grep` на приёмке этапа.
- `ai_upscale` в список попадает сразу, хотя маршрута, его создающего, пока нет: список —
это допустимые значения для restore, а не реестр маршрутов.
- [x] **D4. Доступность пакетов.** Проверено 2026-09-28 в работающем контейнере `photo-ai`
(`pip download gfpgan==1.3.8 facexlib==0.3.0 --no-deps` — оба колеса с PyPI, 52 и 59 КБ;
`pip list` в образе: `gfpgan 1.3.8`, `facexlib 0.3.0`, `basicsr 1.4.2`, `realesrgan 0.3.0`,
`filterpy 1.4.5`, `numba 0.67.0`, `lmdb 2.3.0`, `scipy 1.15.3`; `import gfpgan, facexlib` — ок).
Решение: **вендорить не нужно**, пакеты есть на PyPI и уже стоят в образе — `realesrgan==0.3.0`
тянет `gfpgan>=1.3.5` и `facexlib>=0.2.5` транзитивно. CodeFormer официальным pip-пакетом
не распространяется (см. D5).
- Stage 2 фиксирует версии явно (`gfpgan==1.3.8 facexlib==0.3.0`) и убирает dev-зависимости
gfpgan из рантайма (`tb-nightly`, `yapf`): ставить gfpgan с `--no-deps` и перечислить
реальные зависимости явно.
- **Найденное расхождение (Stage 2):** пин `numpy<2` в `photo-ai/Dockerfile` не действует — в образе
`numpy 2.2.6`, потому что пин живёт в отдельном вызове `pip install`, который следующие установки
не учитывают; там же одновременно стоят `opencv-python 5.0.0.93` (через gfpgan) и
`opencv-python-headless 5.0.0.93`. До Stage 2 numpy не трогаем (Stage 1 меряет I1 на текущем
2.2.6); в Stage 2 пин либо переносится в один общий вызов `pip install`, либо фиксируется
фактическая версия — с обязательной перепроверкой эталона I1 после любого изменения numpy.
- **Закрыто в Stage 2:** требования `numpy<2` и `opencv-python-headless 5.0.0.93` несовместимы
(opencv 5 требует numpy ≥ 2), поэтому зафиксирована фактическая версия `numpy==2.2.6` в том же
вызове `pip install`, что и остальные зависимости, а `opencv-python` (не-headless) больше не
устанавливается вовсе. Эталон I1 перепроверен — 5/5 байт-в-байт (см. «Журнал раздела 2»).
- Веса (проверено HEAD): `x2plus` v0.2.1 — 200; `general-x4v3` и `animevideov3` v0.2.5.0 — 200;
`codeformer.pth` v0.1.0 — 200. GFPGAN: берём `GFPGANv1.4.pth` из релиза `v1.3.0` (200, `arch='clean'`,
`channel_multiplier=2` — как у официального `inference_gfpgan.py -v 1.4`); `GFPGANCleanv1-NoCE-C2.pth`
в релизах `v1.3.8`/`v1.3.4`/`v1.3.0` отсутствует (404), в `v0.2.0` есть (200) — как запасной вариант.
- **Учтётся в Stage 1:** `GFPGANer` жёстко передаёт facexlib `model_rootpath='gfpgan/weights'`
(относительный путь → каталог образа, не том `/models`), поэтому веса детектора/парсера facexlib
сейчас скачиваются мимо тома и теряются при пересборке. Свой `FaceRestoreHelper`/каталог
`/models/weights` — обязательное требование этапа.
- [x] **D5. CodeFormer — опционален по умолчанию.** Проверено 2026-09-28: на PyPI есть только
сторонняя обёртка `codeformer 0.0.11` (`github.com/rohitkhatri/codeformer`, тянет `lpips`) —
это не официальный `sczhou/CodeFormer`, использовать его не будем. Официальные веса доступны.
Решение: официальный модуль вендорится в `photo-ai/vendor/codeformer/` **только если** face-режим
CodeFormer реально понадобится; в рамках текущего плана не вендорим, дефолт
`PHOTO_AI_FACE_MODEL=gfpgan` (Stage 1–3), чтобы дефолтный путь работал без вендоринга.
- `FACE_REGISTRY` всегда содержит обе записи, но запись `codeformer` активна только если
`import codeformer` (с `photo-ai/vendor` в `sys.path`) успешен.
- Нет модуля → запись не попадает в `face_models` в `/health` и в список `/models`, UI её не показывает,
`POST /enhance` с `face_model=codeformer` → `400` с текстом «CodeFormer не установлен в образ,
доступен gfpgan». Сборка и CPU-режим не падают.
- `strength` валиден только для CodeFormer: при `face_model=gfpgan` и `strength`, отличном от 0.7,
→ `400` с пояснением, иначе параметр молча игнорировался бы.
- Веса CodeFormer качаются тем же загрузчиком в `/models/weights/codeformer.pth`.
- [x] **D6. Поведение без `photo-ai` в compose.** Сейчас `PHOTO_AI_URL` по умолчанию
`http://photo-ai:8080` (`docker-compose.yml:79`) — то есть «ИИ» включён по умолчанию.
Решение: дефолт **не меняем** — фото-ИИ включено из коробки и работает на CPU; выключается
только явно (`PHOTO_AI_URL=` в `.env` → кнопка «🤖 ИИ» скрыта, `enhance-ai` → 503, I3).
Заодно исправлен найденный рассинхрон: `.env.example` содержал `PHOTO_AI_URL=` (пусто) с комментарием
«пусто = контейнер photo-ai», то есть инструкция «скопируй `.env.example`» молча выключала ИИ-фото.
В этом же изменении `.env.example` приведён к дефолту compose с явным описанием обоих состояний;
блок про photo-ai в `README` (включая GPU-запуск) появится в Stage 6 с той же формулировкой.
Текущий `.env` переменной не содержит → на этом хосте действует дефолт compose (включено).
---
## 3. Stage 0. Подготовка и эталон «до» (обязательно до любых правок кода)
- [x] Зафиксировать окружение: `docker --version`, `docker compose version`, `nvidia-smi`,
наличие/отсутствие `nvidia-ctk`, `docker info | grep -i runtime`.
- [x] Если `nvidia-ctk` нет — зафиксировать это как «GPU-режим не проверяем на этом хосте»,
**не** пытаться ставить системные пакеты без явного разрешения оператора.
- [x] Сохранить текущее состояние `.env` (`PHOTO_AI_URL` пусто или задан).
- [x] Поднять текущий стек как есть: `docker compose up -d --build`.
- [x] Снять эталон «до» на 3–5 фото (портрет, групповое, без лиц, зашумлённое 640×480):
результат `/enhance` с `scale=2` без других параметров + `/health`. Сохранить файлы и
размеры в `backups/photo-face-ai-baseline/` (вне git).
- [x] **Приёмка:** эталон сохранён, `curl /health` отвечает, текущий сценарий «🤖 ИИ» даёт `done`.
Stage 0 закрыт 2026-09-28, журнал проверок — «Журнал раздела 0» выше. Повторная сверка при закрытии:
`node backups/photo-face-ai-baseline/verify.js after-section0` → 5/5 `MATCH` (I1 байт-в-байт),
`photo-ai /health` → `{"ok":true}` из контейнера `app`, сценарий «🤖 ИИ» → `done` (I2).
---
## 4. Stage 1. `photo-ai/app.py`: реестр моделей + авто-выбор устройства
- [x] `pick_device() -> (device, half, device_name)` по §4.1 плана: env → cuda → mps → cpu;
`half=True` только на CUDA; явный `cuda` без CUDA = WARN + cpu; явный `cpu` = всегда cpu.
- [x] `MODEL_REGISTRY`: `x2plus` (`RRDBNet(scale=2)`), `general-x4v3` (`SRVGGNetCompact(upscale=4)` + `wdn`),
`animevideo-v3` (`SRVGGNetCompact(upscale=4)`).
- [x] `FACE_REGISTRY`: `gfpgan` (`GFPGANer(arch='clean', channel_multiplier=2, upscale=2, bg_upsampler=…)`),
`codeformer` (за `D5`).
- [x] Загрузка весов: список URL из §3.4 плана, каталог `${PHOTO_AI_MODELS_DIR:-/models}/weights/`,
скачивание в `.tmp` → `os.replace`, проверка минимального размера, кэш в томе.
`MODEL_PATH` читается как алиас для `x2plus` (существующий `.env`/том не ломается).
- [x] `class ModelPool`: `threading.Lock`, ленивая загрузка по требованию, кеш, LRU с лимитом 2,
`PHOTO_AI_LOAD_ALL=1` — предзагрузка, прогрев на синтетическом шуме 64×64 после загрузки.
- [x] OOM-деградация: `RuntimeError` с CUDA OOM → `tile` пополам (256→128→64), один ретрай;
повтор → инвалидация модели, переход на CPU, ещё одна попытка; финал — `500` с понятным текстом.
- [x] `GET /health` — контракт §4.3 плана (поле `ok` сохраняется, добавляются `ready`, `device`,
`device_name`, `half`, `tile`, `driver`, `cuda`, `vram_total_mb`, `vram_free_mb`, `models`,
`face_models`, `loaded`, `loading`, `max_pixels`).
- [x] `GET /models` — список моделей, face-моделей, устройство, дефолты.
- [x] `POST /enhance`: поля `image`, `scale`, `model` (дефолт `x2plus`), `face` (`off|face|all`, дефолт `off`),
`face_model` (`gfpgan|codeformer`), `strength` (0..1, только CodeFormer, дефолт 0.7),
`jpeg_quality` (70..100, дефолт 92). Неизвестная модель → `400` со списком допустимых.
Модель не готова → `503` + `Retry-After: 5`.
- [x] Два формата ответа: сырой `image/jpeg` по умолчанию (совместимость) и JSON при
`Accept: application/json`: `{ok, image_base64, model, face, face_model, faces_found, device,
elapsed_ms, warnings}`. При `face != off` — `enhance(..., has_aligned=False, only_center_face=False,
paste_back=True)`; лица не найдены — не ошибка, `faces_found: 0` + чистый `bg_upsampler`.
- [x] Расширение выходного файла — по имени файла, не по `content_type` (воркер шлёт `image/jpeg` для всего).
- [x] **Проверка I1:** эталон из Stage 0 воспроизводится (сравнить размер/содержимое, `node --check`-эквивалент
для Python — `python -c "import ast;ast.parse(open('photo-ai/app.py').read())"`).
- [x] **Приёмка:** `/health` отдаёт `device`/`device_name`/`half`; `PHOTO_AI_DEVICE=cpu` при рабочей CUDA →
`cpu`; `PHOTO_AI_DEVICE=cuda` без CUDA → `cpu` + WARN, сервис поднялся; `PHOTO_AI_DEVICE=auto` без
CUDA → `cpu`.
### Журнал раздела 1 (проверено 2026-09-29)
Изменён только `photo-ai/app.py` (схема БД, воркер, `server.js`, compose, Dockerfile, фронтенд и `.env.example`
не тронуты — они в Stage 2…6).
| Проверка | Как | Результат |
|---|---|---|
| I1 | `capture.js after-stage1` + `verify.js after-stage1` | 5/5 `MATCH` байт-в-байт; повторно после двух правок `is_oom`/`BASE_TILE` — снова 5/5 |
| I2 | `INSERT INTO photo_jobs (entry_id, action, params, status) VALUES (390,'ai',NULL,'pending')` | `done`, `attempts=0`, `after_path` в S3; вход 939×875 PNG → 1878×1750 JPEG (ровно x2) |
| I4 | override-файлы с `PHOTO_AI_DEVICE=cuda\|cpu\|auto\|bogus` | `cuda` → WARN «CUDA недоступна — работаю на CPU» + `device=cpu`; `cpu`/`auto` → `cpu`; `bogus` → WARN об неизвестном значении и `auto`; сервис поднялся во всех случаях |
| I5 | 503 во время стартовой предзагрузки | `503` + `Retry-After: 5`; `worker.js:19` `isSoftStatus` относит `status >= 500` к мягким, попытка не тратится |
| I7 | `ast.parse`, `grep` на комментарии, неиспользуемые импорты | чисто, комментариев 0 |
| Реестр | `general-x4v3` (360×548 → 1440×2192), `animevideo-v3` | 200, веса скачаны в том, повторный запрос из кэша |
| LRU | последовательно `general-x4v3` → `animevideo-v3` → `face=all` | `loaded` держится ровно 2 записи, вытесняется самая старая (`x2plus` → `general-x4v3` → `animevideo-v3` → `gfpgan`) |
| Лица | `face=face` и `face=all` на реальных фото (800×450 и 1400×788) | `faces_found: 10`, 47 с / 42 с на CPU, `warnings: []` |
| Лица, их нет | `nofaces.jpg` 300×200 | `ok: true`, `faces_found: 0`, warning «лица не найдены, фон обработан апскейлом», 1.8 с — не ошибка |
| `jpeg_quality` | 70 / 92 / 100 и 30 / 101 / `abc` | 165935 / 327022 / 929904 байт, повтор 70 → те же байты; вне диапазона `400` с текстом «должен быть 70..100», нечисловое — `422` от pydantic |
| Валидация | неизвестные `model`/`face`/`face_model`, `strength` с gfpgan и вне 0..1, битое изображение | `400` с перечнем допустимых значений / `400 bad image` |
| OOM-лестница | подмена `process_image` в контейнере: OOM на 0/1/2/всех попытках | тайлы `[256]`, `[256,128]`, `[256,128,64]`, `[256,128,64]`; при полном провале `500` «не хватило памяти даже при tile=64: пересмотрите PHOTO_AI_TILE или PHOTO_AI_MAX_PIXELS» |
| Деградация | повторный вызов `degrade_to_cpu` | выполняется один раз (идемпотентно), `half=False`, предупреждение в ответе |
Решения и находки, которые нужно знать дальше:
- **GPU на хосте есть** — `nvidia-smi` работает, драйвер `615.71.09`, CUDA UMD 13.4. Не хватает только
`nvidia-container-toolkit`; его установка — решение оператора (в условиях задачи это стоп-условие),
поэтому ветка CUDA осталась непроверенной. Всё, что связано с `cuda`/`half`/`vram_*`, в Stage 1
покрыто только кодом и unit-проверками, а не прогоном.
- **`animevideo-v3`** — имя реестра взято по формулировке этого чек-листа (в плане §3.4 встречается
`animevideov3`, это имя файла весов). Ключ используется в API, при несовпадении с планом поправить
и то, и другое одним изменением.
- **`image_ext`** добавлен в JSON-ответ сверх полей, перечисленных в чек-листе: без него клиент
не может угадать формат (`.jpeg` нормализуется в `.jpg` ради байт-в-байтности, `.png`/`.webp`
не меняются).
- **D4 (веса facexlib в томе)**: `face_helper()` пишет в `${PHOTO_AI_MODELS_DIR}/weights` через
`model_rootpath`, а встроенный помощник `GFPGANer` ищет `gfpgan/weights` относительно cwd.
`link_default_facexlib_dir()` при первом же построении face-модели заменяет этот каталог
символической ссылкой на том — `/app/gfpgan/weights -> /models/weights`. Второй детектор не
создаётся, 195 МБ дубля нет (проверено `readlink`).
- **Ветка CodeFormer написана, но не проверена** — модуль не вендорен (Stage 2). Проверен только
путь отказа: `face_model=codeformer` → `400` «модель лиц codeformer не установлена в образ,
доступен gfpgan», и `strength` с gfpgan → `400`. Сам `build_face_codeformer` нужно прогнать после
того, как пакет появится в образе.
- **`tile` не залипает.** После OOM `state['tile']` восстанавливается на `PHOTO_AI_TILE` в `finally`
(`BASE_TILE`), иначе одна тяжёлая картинка навсегда замедляла бы сервис в 16 раз. `degrade_to_cpu`
больше не сбрасывает тайл на дефолт — операторское значение сохраняется.
- **`is_oom` расширен** на сообщения CPU-аллокатора PyTorch (`not enough memory`, `alloc_cpu`,
`can't allocate memory`): без этого исчерпание RAM на CPU (единственный тестируемый режим)
возвращало `500` с текстом «ошибка модели: …» вместо лестницы тайлов и подсказки про
`PHOTO_AI_TILE`/`PHOTO_AI_MAX_PIXELS`.
- **`portrait.jpg` — плохой тестовый вход**: на нём детектор facexlib не находит лица (порог
`get_face_landmarks_5` — 0.97, на реальных фото скор 0.999+). Ранние «0 лиц» в проверках были
ошибкой тест-скрипта (`len(h.cropped_faces)` заполняется только `align_warp_face()`), а не
поломкой детектора. Годится любой портрет из `uploads/` (4032×2268 и ещё 7 файлов).
- **`.env.example` не правился**: новые переменные ещё не подставляются в compose (Stage 2),
документировать их сейчас — задокументировать неработающее.
---
## 5. Stage 2. `photo-ai/Dockerfile` + compose
- [x] `ARG TORCH_VARIANT=cpu` и `ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}`;
один образ, `cu124` — вариант сборки.
- [x] Сохранить патч `basicsr/data/degradations.py` (`functional_tensor` → `functional`) — без него basicsr
падает на torch ≥ 2.0. Не «упрощать» Dockerfile без проверки.
- [x] Сохранить `libgl1 libglib2.0-0` (нужны facexlib), `numpy<2`, `opencv-python-headless`.
- [x] Добавить `gfpgan`/`facexlib` (или вендоринг по `D4`), вендоренный CodeFormer копировать в образ
при наличии (`D5`).
- [x] `ENV PHOTO_AI_MODELS_DIR=/models`; `MODEL_PATH` остаётся валидным алиасом.
- [x] `docker-compose.yml`, сервис `photo-ai`: env `PHOTO_AI_DEVICE`, `PHOTO_AI_FACE_MODEL`, `PHOTO_AI_TILE`,
`PHOTO_AI_MAX_PIXELS`, `PHOTO_AI_LOAD_ALL`, `PHOTO_AI_JPEG_QUALITY`; `healthcheck` с
`start_period: 300s`; порт по `D1` уже проброшен на loopback — сохранить.
- [x] Новый `docker-compose.gpu.yml` (по образцу `docker-compose.minio.yml`): `build.args.TORCH_VARIANT=cu124`,
`PHOTO_AI_DEVICE=cuda`, `deploy.resources.reservations.devices` (`driver: nvidia`, `count: 1`).
Без него стек поднимается на любой машине.
- [x] Сервису `app` добавить env `PHOTO_AI_FACE_MODEL` и `PHOTO_AI_FACE_TIMEOUT_MS`.
- [x] **Приёмка:** CPU-сборка стартует; `/api/photo-jobs/status` показывает `service.device`;
при пустом `PHOTO_AI_URL` приложение работает полностью (I3); `docker compose -f docker-compose.yml
-f docker-compose.gpu.yml config` валиден (сам GPU-пуск — только если toolkit есть).
### Журнал раздела 2 (проверено 2026-09-29)
Изменены `photo-ai/Dockerfile`, `docker-compose.yml`, добавлены `docker-compose.gpu.yml` и
`photo-ai/vendor/.gitkeep`, а также `.env.example` и таблица переменных в `README.md` — Stage 1 отложил
их именно на Stage 2, потому что раньше compose эти переменные не подставлял. `app.py`, `worker.js`,
`server.js`, схема БД и фронтенд не тронуты — они в Stage 3…6.
| Проверка | Как | Результат |
|---|---|---|
| CPU-сборка | `docker compose build photo-ai` (слои pip с нуля) | `EXIT=0`, образ `whatido-photo-ai:latest` **2.63 ГБ** (план оценивал ~2.5 ГБ) |
| Состав пакетов | `docker exec photo-ai pip list` | `gfpgan 1.3.8`, `facexlib 0.3.0`, `basicsr 1.4.2`, `realesrgan 0.3.0`, `numpy 2.2.6`, `torch 2.14.0+cpu`, `matplotlib 3.10.9`; `opencv-python-headless 5.0.0.93` — **единственный** cv2; `tb-nightly` и `yapf` отсутствуют |
| Импорты | `python -c "import basicsr, gfpgan, facexlib, realesrgan, cv2"` | ок; cv2 `GUI: NONE` (до этого активной была не-headless сборка с `GUI: QT5`) |
| Патч basicsr | лог сборки | `basicsr patch applied to /usr/local/lib/python3.10/site-packages/basicsr/data/degradations.py` |
| **I1** | `capture.js after-stage2` + `verify.js after-stage2` | **5/5 `MATCH` байт-в-байт** (`795a519b9a9c70f6`, `fe2496f7ef798456`, `fce1949260590855`, `1b52a49d690ca8d7`, `e0e6e480f4799bc0`) — смена cv2 на headless и явный пин numpy результат не изменили |
| `service.device` | `GET /api/photo-jobs/status` (admin) | 200; `service` = `ok/ready/device=cpu/device_name/half/tile/driver/cuda/vram_total_mb/vram_free_mb/models/face_models/loaded/loading/max_pixels` + `configured/reachable/latency_ms/error` |
| healthcheck | `docker inspect photo-ai` | `healthy` после `start_period: 300s`; та же команда вручную в контейнере → exit 0 |
| env контейнера | `docker inspect photo-ai` | `PHOTO_AI_DEVICE=auto`, `PHOTO_AI_TILE=256`, `PHOTO_AI_FACE_MODEL=gfpgan`, `PHOTO_AI_LOAD_ALL=0`, `PHOTO_AI_JPEG_QUALITY=92`, `PHOTO_AI_MAX_PIXELS=4000000`, `PHOTO_AI_MODELS_DIR=/models`, `MODEL_PATH=/models/RealESRGAN_x2plus.pth` |
| env `app` | `docker compose exec app node -e ...` | `PHOTO_AI_URL=http://photo-ai:8080`, `PHOTO_AI_FACE_MODEL=gfpgan`, `PHOTO_AI_FACE_TIMEOUT_MS=600000` |
| I3 | override с `PHOTO_AI_URL: ""` (файл в `/tmp`, вне репозитория) | `photo_ai_enabled=false`, `POST /api/entries/1/photo/enhance-ai` → `503` «ИИ-обработка фото не настроена», 8 маршрутов API → 200; возврат к базовой конфигурации → `photo_ai_enabled=true` |
| I4 (ветка cuda) | `PHOTO_AI_DEVICE=cuda` на новом образе | WARN «PHOTO_AI_DEVICE=cuda, но CUDA недоступна — работаю на CPU», `/health` 200 и `device=cpu` |
| Compose | `docker compose config -q` — база, `+docker-compose.minio.yml`, `+docker-compose.gpu.yml` | три конфигурации валидны; GPU-оверрайд даёт `TORCH_VARIANT=cu124`, `PHOTO_AI_DEVICE=cuda` и `deploy.resources.reservations.devices[driver=nvidia, count=1, capabilities=[gpu]]` |
| GPU-пуск | `up -d photo-ai` с оверрайдом, когда `nvidia-ctk` есть на хосте | **сделан 2026-09-29**, отдельный журнал «GPU-прогон» в разделе 6: `device=cuda:0`, `half=true`, `vram_total_mb=3822`. Изначально был стоп-условием (toolkit отсутствовал), условие снято |
Решения и находки, которые нужно знать дальше:
- **`numpy<2` был невыполним.** `opencv-python-headless 5.0.0.93` требует numpy ≥ 2, поэтому пин заменён
на фактическую версию `numpy==2.2.6` и перенесён в тот же вызов `pip install`, что и остальные
зависимости (раньше пин стоял отдельным вызовом, и следующие установки его игнорировали). I1 после
изменения numpy перепроверен — байт-в-байт.
- **`--no-deps` для `basicsr`/`realesrgan`/`gfpgan`/`facexlib`** убирает dev-зависимости gfpgan
(`tb-nightly`, `yapf`). Проверено, что в рантайме они не нужны: `tensorboard` в basicsr импортируется
только лениво внутри `init_tb_logger`/`read_data_from_tensorboard`, `yapf` не импортируется вовсе,
а `matplotlib` приходит как зависимость `filterpy` (требование facexlib) и в образе остался.
- **`opencv-python` (не-headless) больше не ставится.** Раньше он приходил транзитивно через gfpgan и
перезаписывал headless-сборку (активным был cv2 с `GUI: QT5`). Теперь cv2 один, headless, и JPEG-байты
совпали с эталоном — GUI-обвязка на результат не влияла.
- **`libgl1 libglib2.0-0` оставлены** по требованию плана (facexlib), хотя при headless cv2 они нужны
меньше: экономия здесь не стоит риска незамеченной регрессии.
- **`photo-ai/vendor/.gitkeep`** — каталог вендоринга существует в репозитории, поэтому
`COPY vendor/ ./vendor/` не падает на сборке без CodeFormer, а положенный туда модуль подхватывается
`sys.path` в `app.py` без правок Dockerfile (`D5`).
- **env `PHOTO_AI_FACE_MODEL`/`PHOTO_AI_FACE_TIMEOUT_MS` у `app`** добавлены по чек-листу Stage 2;
`PHOTO_AI_FACE_MODEL` уже используется `app.py` как дефолт face-модели, воркер начнёт читать оба
в Stage 4 (сейчас `ai_timeout_ms` в `worker.config` всё ещё 300000 — это ожидаемо).
- **`TORCH_VARIANT` в `docker-compose.gpu.yml` захардкожен**, а не берётся из `.env`: иначе
`TORCH_VARIANT=cpu` в `.env` молча собирал бы GPU-конфигурацию без CUDA. Значение изменено
`cu124 → cu126` при GPU-прогоне — причина в журнале раздела 3.
- **`MAX_INPUT_PIXELS` в compose заменён на канонический `PHOTO_AI_MAX_PIXELS`**; `app.py` по-прежнему
читает старое имя как алиас, так что существующий `.env` не ломается. Порт `8081` на loopback (`D1`)
сохранён.
- **`.env.example`/`README.md`** описывают ровно те переменные, которые подставляет compose; блок про
GPU-запуск и установку NVIDIA Container Toolkit добавлен в Stage 3 вместе с проверкой GPU-оверрайда
(решение `D6`), остальные документы — за Stage 6.
---
## 6. Stage 3. Face-режим (GFPGAN) в `app.py`
- [x] `face=off` — только ESRGAN (текущее поведение). — **сделано в Stage 1** (журнал раздела 1)
- [x] `face=face` — ESRGAN-фон + GFPGAN `paste_back`. — **сделано в Stage 1**
- [x] `face=all` — ESRGAN(+`wdn` для `general-x4v3`) + мягкий денойз фона + лица. — **сделано в Stage 1**
- [x] `strength` → `fidelity_weight` CodeFormer, `-w`; вне диапазона → `400`. — **сделано в Stage 1**
(ветка CodeFormer написана, но не проверена: модуль не вендорен, `D5`; проверен путь отказа)
- [x] Предупреждения (`warnings`: вход меньше 320×320, лиц не найдено, CodeFormer на CPU) в JSON-ответе.
Первые два были в Stage 1; **вход меньше 320×320 и CodeFormer на CPU добавлены при приёмке
Stage 3** — их не было в коде
- [x] **Приёмка:** портрет 640×480 в `face` → `faces_found=6`, лицо резче; фото без лиц → `faces_found=0`
и результат не хуже `x2plus`; искусственный OOM (`PHOTO_AI_TILE=2048` на 4 ГБ) деградирует до CPU
без падения сервиса.
### Журнал раздела 3 (проверено 2026-09-29)
Изменён `photo-ai/app.py` (лестница OOM на CUDA + два предупреждения) и `docker-compose.gpu.yml`
(GPU-оверрайд: cu126 + CDI). `worker.js`, `server.js`, схема БД и фронтенд не тронуты — они в Stage 4…6.
Проверки шли на работающем GPU (RTX 3050 Laptop, 4096 МБ, драйвер `615.71.09`, CUDA 12.6) — см.
журнал GPU-прогона в разделе 2.
| Проверка | Как | Результат |
|---|---|---|
| **Главная находка** | `PHOTO_AI_TILE=2048`, `x2plus face=all` на 4032×2268 | **до правки**: `500 Internal Server Error`, в логе `UnboundLocalError: local variable 'output_tile' referenced before assignment` (`realesrgan/utils.py:179`); **после**: `200`, `warnings: ["не хватило памяти при tile=2048"]`, 28.3 с |
| Причина | чтение кода `realesrgan/utils.py` и `gfpgan/utils.py` | обе библиотеки **проглатывают** `RuntimeError` в своих `try/except` вокруг вызова сети (`except RuntimeError as error: print('Error', error)`) и идут дальше, поэтому `output_tile`/`restored_face` остаётся неприсвоенным. Для `run_guarded` это был уже не `RuntimeError` → ни лестницы тайлов, ни деградации на CPU, ни внятного текста |
| Как починено | `guard_forward()` в `app.py` оборачивает `forward` сетей, построенных нами (`RealESRGANer.model`, `restorer.gfpgan`, CodeFormer `net`): `RuntimeError` с признаком OOM превращается в `TileOOM` | `TileOOM` не наследует `RuntimeError`, поэтому проглатывающие `except` его пропускают; `is_oom` и `run_guarded` (обе точки) ловят `TileOOM` явно |
| Лестница тайлов на GPU | инъекция `TileOOM('CUDA out of memory')` вместо `process_image` | `tile=2048 → 1024 → 512`, затем `degrade_to_cpu` и повтор: `warnings` = 4 пункта, `device: cpu`, `half: false`; при повторе с уже-CPU — честная `500` «не хватило памяти даже при tile=512: пересмотрите PHOTO_AI_TILE или PHOTO_AI_MAX_PIXELS» |
| `x2plus face=all`, tile=2048 | полное фото | `200`, 6 лиц, единственное предупреждение — про тайл; сервис не упал, следующие запросы проходят |
| 640×480, три режима | портрет, приведённый к 640×480 | `off` 1.0 с / `face` 3.8 с / `all` 2.2 с, `faces_found=6`, `device=cuda:0` |
| Фото без лиц | синтетический фон 800×600 | `face` → `faces_found=0`, warning «лица не найдены, фон обработан апскейлом», 0.8 с; байты результата **равны** `face=off` (39861) — без деградации качества |
| Вход меньше 320×320 | 256×256 | два предупреждения: «вход меньше 320×320 — лица могут не найтись» + «лица не найдены» |
| `strength` | `0.5` с gfpgan / `1.4` | `400` «strength применяется только к CodeFormer, для gfpgan оставьте 0.7» / `400` «strength должен быть 0..1» |
| CodeFormer | `face_model=codeformer` | `400` «модель лиц codeformer не установлена в образ, доступен gfpgan» (`D5`) |
| **I1** | 6 сценариев (`jpg`/`.jpeg`/`.png`+q70/`.webp`+q100/`x4v3`/`animevideo-v3`) на новом и на Stage 2 образе, оба на CPU | **6/6 `MATCH` байт-в-байт** (`420757881a31f12dec174e4873e260dd`, `cdbf59d1eb460d26a3b83af2e0543d82`, `654928b60ec43dbd41c6644121041a75`, `557e76d79869230058e1acf75017c670`, `9d3d731c8858bacbba1ee88bf3f373e5`) |
| I7 | `ast.parse`, поиск комментариев | чисто, комментариев 0 |
Решения и находки, которые нужно знать дальше:
- **Лестница OOM не работала ни на одном GPU** — только на CPU, где OOM приходит из нашего кода и
не проглатывается. Это наш главный аргумент за то, что GPU-ветку надо было прогнать, а не собрать.
- **`guard_forward` вешается на экземпляр** (`model.forward`), идемпотентен по флагу
`_photo_ai_guarded` и не трогает класс — обёртка не накапливается при пересборке пула. Сетей,
которые строит не мы (retinaface в facexlib, ESRGANer внутри GFPGANer), обёртка не покрывает: там
OOM уходит нашим же `except RuntimeError`, и это правильно.
- **Проглатывание в gfpgan** (`except RuntimeError` вокруг `self.gfpgan(...)`) было даже опаснее
тихого: при OOM лицо молча оставалось исходным, задание завершалось «успешно» без единого
признака в `warnings`. Теперь такой случай уходит в лестницу тайлов, а если не помогло — в
деградацию на CPU.
- **4 ГБ VRAM — это про `PHOTO_AI_LOAD_ALL` и `PHOTO_AI_TILE`, а не про лестницу.** `x2plus` и `gfpgan`
влезают (свободно ~100–300 МБ), `general-x4v3` втрое тяжелее. Проверено: после `x4v3 face=all`
поднимается `tile=2048`, но дефолтные 256 проходят без единого предупреждения.
- **`tile` по-прежнему не залипает** — после OOM в `finally` восстанавливается `PHOTO_AI_TILE`, и
`degrade_to_cpu` больше не сбрасывает операторское значение.
---
### Журнал GPU-прогона (закрывает строку «GPU-пуск» раздела 2)
Стоп-условие снято: `nvidia-ctk` и спека `/etc/cdi/nvidia.yaml` появились на хосте, поэтому GPU-оверрейд
переведён с классического резервирования (`driver: nvidia, count: 1`) на CDI (`device_ids:
nvidia.com/gpu=all`) — так демон перезапускать не нужно, а `/etc/docker/daemon.json` на хосте нет.
| Проверка | Как | Результат |
|---|---|---|
| Сборка GPU-образа | `docker compose -f docker-compose.yml -f docker-compose.gpu.yml build photo-ai` | `whatido-photo-ai:cu126`, 12 ГБ; `torch 2.14.0+cu126`, `cuda: 12.6` — те же версии, что и в CPU-образе |
| Старт | оверрайд + `up -d photo-ai` | `healthy`, в лог `устройство: cuda:0 (NVIDIA GeForce RTX 3050 Laptop GPU), half=True` |
| `/health` | curl | `device: cuda:0`, `half: true`, `driver: 615.71.09`, `cuda: 12.6`, `vram_total_mb: 3822`, `ready: true` |
| Обратная совместимость | базовый `docker compose up -d photo-ai` без оверрайда | сервис возвращается на CPU, `PHOTO_AI_DEVICE=auto` |
Решения, которые нужно знать дальше:
- **`TORCH_VARIANT` в оверрайде захардкожен (`cu126`), а не берётся из `.env`:** `TORCH_VARIANT=cpu`
в `.env` иначе молча собрал бы GPU-конфигурацию без CUDA. Причина смены `cu124 → cu126`: в индексе
`cu124` последний torch — `2.6.0`, а `cu126` даёт ровно `2.14.0`/`0.29.0`, как CPU-образ.
- **GPU-образ тегируется отдельно** (`whatido-photo-ai:cu126`), поэтому сборка GPU-варианта не
перетирает `whatido-photo-ai:latest` и откат на CPU — обычный `docker compose up -d photo-ai`.
- **Ветка CUDA в плане остаётся непроверенной ровно в одном месте** — печать realesных GPU-байтов:
на CPU/CUDA результаты x2 совпадают побайтно, но `half=True` на CUDA считает в fp16, поэтому
байты CPU и GPU для одной картинки не равны (это не регресс I1: эталон снимается на CPU).
---
## 7. Stage 4. `worker.js` + `server.js`
### 7.1 `worker.js` (`createPhotoEnhanceWorker`)
- [ ] Таймауты из env: `PHOTO_AI_TIMEOUT_MS` (дефолт 300000), `PHOTO_AI_FACE_TIMEOUT_MS` (дефолт 600000)
для `face != 'off'`. Таймаут выбирается на основе `params`, а не глобально.
- [ ] `runAiEnhance(srcKey, params)`: отправляет `image`, `scale`, `model`, `face`, `face_model`, `strength`
из `params` (дефолты при пустых `params`); ставит `Accept: application/json`; принимает и JSON
(base64 → буфер), и сырой `image/jpeg` (старый photo-ai) без ошибки.
- [ ] `503` / `Retry-After` / сетевая недоступность → `sleep` c экспоненциальной задержкой, `return false`,
**`attempts` не инкрементится**; лимит мягких повторов (например 60) → одна честная ошибка
с понятным текстом (I5).
- [ ] `AbortError` от `AbortSignal.timeout` → сообщение «ИИ-сервис не ответил за N с» — это уже
«жёсткая» ошибка с попытками.
- [ ] `processOne`: `action` ∈ {`ai`, `ai_face`, `ai_upscale`} → AI-путь; `enhance` → sharp.
Пустые `params` при `ai_face` → `face='face'`.
- [ ] `applyResult`: в `logAudit(..., 'photo.job.preview', {...})` добавить `model`, `face`, `face_model`,
`device`, `elapsed_ms`, `faces_found`, `warnings`. Текст уведомления `photo.job.done` — по факту
режима (`job.action === 'ai_face'` → «Фото обработано нейросетью с восстановлением лиц»).
- [ ] `CONFIG` воркера: + `face_model`, `default_model`, `face_timeout_ms` (попадают в
`GET /api/photo-jobs/status` → `worker.config`).
### 7.2 `server.js`
- [ ] `POST /api/entries/:id/photo/enhance-ai` (`server.js:5114`): принять `{model, face, face_model, strength}`;
валидация инлайн-хелперами и явными списками (как `PUT /api/settings`), невалидное → `400`.
`params = JSON.stringify({model, face, face_model, strength})`; `action = face === 'off' ? 'ai' : 'ai_face'`.
**Пустое тело → сегодняшнее поведение** (`params = NULL`, `action='ai'`) — I2.
- [ ] `PHOTO_JOB_ACTIONS` (`server.js:2254`): + `'ai_face'`, `'ai_upscale'` (см. `D3`). Схема
`action VARCHAR(20)` вмещает новые значения — миграция не нужна.
- [x] `photoAiHealth()` (см. `D2`) + проксирование в `GET /api/photo-jobs/status` → `service`
(`reachable`, `device`, `device_name`, `ready`, `vram_total_mb`, `vram_free_mb`, `models`, `face_models`,
`loaded`). Недоступен → `{reachable:false}`, ответ 200. — **сделано 2026-09-28** (контракт и проверка
в `D2`; поля `device`/`device_name`/`vram_*` появятся вместе с расширенным `/health` в Stage 1)
- [ ] `GET /api/photo-ai/health` (`requireAdmin`) — прямой прокси `/health` photo-ai для оператора.
- [ ] `getStackInfo()` (`server.js:769`) — блок `photo_ai` (`engine: 'Real-ESRGAN + GFPGAN'`, `driver`,
`device`, `device_name`, `vram_total_mb`, `models`). Без credentials, только hostname.
- [ ] Настройки (`PUT /api/settings`, рядом `server.js:1710`): `photo_ai_face_mode` ∈ `off|face|all` (дефолт `off`),
`photo_ai_face_model` ∈ `gfpgan|codeformer` (дефолт `gfpgan`), `photo_ai_device_pref` ∈ `auto|cuda|cpu`
(дефолт `auto`, только для UI/доков — фактическое устройство задаёт контейнер).
- [ ] Дефолты новых настроек: `db/init.sql`, `db/migration.sql` (`INSERT ... ON CONFLICT DO NOTHING`),
`keys`/`defaults` в `/api/public-settings` (`server.js:1663–1664`). `photo_ai_enabled` (строка 1677)
сохранить.
- [ ] `createPhotoEnhanceWorker({...})` (`server.js:6045`): + `faceTimeoutMs: PHOTO_AI_FACE_TIMEOUT_MS`,
`defaultFaceModel: PHOTO_AI_FACE_MODEL`.
- [ ] `warnings` от photo-ai сохранять в аудит/уведомление, чтобы оператор видел, что face-режим не
сработал не из-за ошибки.
- [ ] **Приёмка:** задание с `face='face'` проходит `pending → processing → done`; `params` сохранены;
аудит содержит модель/устройство/время; остановка photo-ai на лету → задание дорабатывается после
возврата сервиса **без** `error` (I5); результат применяется и откатывается как раньше.
---
## 8. Stage 5. Фронтенд
- [ ] `public/journal.html` + `public/js/journal.js`: рядом с «🤖 ИИ» селект режима
(«Универсально (x2)», «Быстро (x4)», «Лица (GFPGAN)», «Лица + фон»); значение уходит в
`POST .../enhance-ai` телом `{model, face, face_model}`. Дефолт — из `photo_ai_face_mode`
(`/api/public-settings`, `loadEnhanceEngine`); при `device === 'cpu'` — подсказка «на CPU медленно».
Кнопка по-прежнему скрыта при `photo_ai_enabled === 'false'` (I3).
- [ ] `public/js/worker.js`: `PHOTO_ACTION_LABELS` + `ai_face: 'ИИ + лица'`, `ai_upscale: 'ИИ-апскейл'`;
в модалке сравнения — `model`, `device`, `faces_found`, `elapsed_ms` (данные из `params`/аудита, I6);
строка состояния учитывает `service.reachable === false` и `service.device`.
- [ ] `public/settings.html` + `public/js/settings.js`: блок «Фото-ИИ» — селект face-режима, селект
face-модели, селект желаемого устройства + строка «фактическое: …» с подсветкой расхождения;
read-only статус (`device_name`, VRAM, список моделей) из `/api/photo-jobs/status`.
Новые id внести в `DIRTY_FIELDS` (`settings.js:1`) и в payload (`settings.js:739`).
- [ ] `public/js/settings.js:372` `renderStackInfo()` — карточка «Фото-ИИ» из `stack.photo_ai`.
- [ ] `public/js/audit.js` — метки для новых кодов аудита (иначе в UI будет сырой код).
- [ ] **Приёмка:** из журнала доступны все 4 режима; после постановки видно «В очереди», затем сравнение
«Было/Стало» с моделью/устройством; в настройках видно фактическое устройство и VRAM.
---
## 9. Stage 6. Документация и тесты
- [ ] `AGENTS.md`: раздел про photo-ai (реестр моделей, device-политика, новые env, GPU-override,
контракт `/enhance` и `/health`), строки в таблице «File Map» для `photo-ai/app.py`,
`photo-ai/Dockerfile`, `docker-compose.gpu.yml`.
- [ ] `README.md`: установка NVIDIA Container Toolkit, запуск с `docker-compose.gpu.yml`,
проверка `GET /api/photo-jobs/status` → `service.device`, ручной вызов `/enhance` (с учётом `D1`).
- [ ] `.env.example`: все переменные из §7 плана с комментариями.
- [ ] `api.smoketest.js` (по правилу `AGENTS.md` — контракт API фиксируется в том же изменении):
- `POST /api/entries/:id/photo/enhance-ai` с `model: 'нет такой'` → `400`;
- `face: 'face'` без `PHOTO_AI_URL` → `503` (условно: если `ai_configured === false`);
- `GET /api/photo-jobs/status` содержит ключ `service`.
- [ ] **Приёмка:** `node api.smoketest.js` проходит; `node --check server.js`, `node --check worker.js`,
`python -c "import ast;ast.parse(...)"` для `app.py`; `docker compose config` валиден для обоих
compose-файлов; документация совпадает с кодом.
---
## 10. Новые переменные окружения (итог)
| Переменная | Умолчание | Где используется |
|---|---|---|
| `PHOTO_AI_URL` | `http://photo-ai:8080` (compose), пусто = выкл. | `server.js`, воркер |
| `PHOTO_AI_MAX_PIXELS` | `4000000` | `app.py` (алиас `MAX_INPUT_PIXELS`) |
| `PHOTO_AI_DEVICE` | `auto` | `app.py` (`auto|cuda|cpu`) |
| `PHOTO_AI_TILE` | `256` | `app.py` |
| `PHOTO_AI_FACE_MODEL` | `gfpgan` | `app.py`, дефолт воркера/UI |
| `PHOTO_AI_LOAD_ALL` | `0` | `app.py` |
| `PHOTO_AI_FACE_MODE` | `off` | дефолт UI |
| `PHOTO_AI_JPEG_QUALITY` | `92` | `app.py` |
| `PHOTO_AI_TIMEOUT_MS` | `300000` | `worker.js` (сейчас хардкод) |
| `PHOTO_AI_FACE_TIMEOUT_MS` | `600000` | `worker.js` |
| `PHOTO_AI_SOFT_MAX_RETRIES` | `60` | `worker.js` — лимит мягких повторов (I5) |
| `PHOTO_AI_SOFT_BACKOFF_MS` | `10000` | `worker.js` — первая пауза мягкого повтора |
| `PHOTO_AI_SOFT_BACKOFF_MAX_MS` | `300000` | `worker.js` — потолок паузы |
| `PHOTO_AI_MODELS_DIR` | `/models` | `app.py` (внутри контейнера) |
---
## 11. Порядок и правила выполнения
- [ ] Этапы выполняются строго 0 → 1 → 2 → 3 → 4 → 5 → 6. Каждый этап закрывается своей приёмкой
**до** начала следующего.
- [ ] Каждый завершённый чекбокс отмечать (`- [x]`) в этом файле по мере выполнения.
- [ ] Изменения в `AGENTS.md`/`.env.example`/`README.md` делать **в том же** изменении, что и код,
который они описывают (правило `AGENTS.md` про рассинхрон документации).
- [ ] Коммит на этап, а не на весь план: `app.py`+`Dockerfile`+compose → воркер/сервер → фронт → доки/тесты.
- [ ] Стоп-условия (сообщить оператору, не «чинить» самостоятельно): требуется `sudo` на хосте;
требуется установка `nvidia-container-toolkit`; нашёлся конфликт миграции БД; текущий
`/enhance` перестал давать эталонный результат (I1).
## 12. Готово, когда
- [ ] `photo-ai` стартует на CPU и на CUDA, устройство выбирается автоматически, `/health` показывает
фактическое устройство, имя GPU, VRAM, доступные и загруженные модели.
- [ ] Сценарий «🤖 ИИ» без параметров даёт результат, эквивалентный текущему.
- [ ] Доступны режимы: универсальный x2, быстрый x4, только лица (GFPGAN), лица + фон.
- [ ] Face-задание проходит полный цикл, результат виден в сравнении, применяется и откатывается.
- [ ] Отсутствие GPU, отсутствие `nvidia-ctk` и недоступный `photo-ai` не ломают приложение.
- [ ] `node api.smoketest.js` проходит; `AGENTS.md`, `README.md`, `.env.example` описывают новые
переменные и GPU-запуск.