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.
606 lines
67 KiB
Markdown
606 lines
67 KiB
Markdown
# 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-запуск.
|