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.
This commit is contained in:
dev
2026-09-29 12:02:03 +03:00
parent 7367d66ac3
commit 4c63a46d24
5 changed files with 188 additions and 26 deletions
+82 -11
View File
@@ -351,7 +351,7 @@ Stage 0 закрыт 2026-09-28, журнал проверок — «Журна
| 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-пуск | не выполнялся | `nvidia-ctk` на хосте отсутствует — стоп-условие: установка toolkit только с разрешения оператора |
| GPU-пуск | `up -d photo-ai` с оверрайдом, когда `nvidia-ctk` есть на хосте | **сделан 2026-09-29**, отдельный журнал «GPU-прогон» в разделе 6: `device=cuda:0`, `half=true`, `vram_total_mb=3822`. Изначально был стоп-условием (toolkit отсутствовал), условие снято |
Решения и находки, которые нужно знать дальше:
@@ -374,27 +374,98 @@ Stage 0 закрыт 2026-09-28, журнал проверок — «Журна
- **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` захардкожен (`cu124`)**, а не берётся из `.env`: иначе
`TORCH_VARIANT=cpu` в `.env` молча собирал бы GPU-конфигурацию без CUDA.
- **`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 6 (решение `D6`).
GPU-запуск и установку NVIDIA Container Toolkit добавлен в Stage 3 вместе с проверкой GPU-оверрайда
(решение `D6`), остальные документы — за Stage 6.
---
## 6. Stage 3. Face-режим (GFPGAN) в `app.py`
- [ ] `face=off` — только ESRGAN (текущее поведение).
- [ ] `face=face` — ESRGAN-фон + GFPGAN `paste_back`.
- [ ] `face=all` — ESRGAN(+`wdn` для `general-x4v3`) + мягкий денойз фона + лица.
- [ ] `strength` → `fidelity_weight` CodeFormer, `-w`; вне диапазона → `400`.
- [ ] Предупреждения (`warnings`: вход меньше 320×320, лиц не найдено, CodeFormer на CPU) в JSON-ответе.
- [ ] **Приёмка:** портрет 640×480 в `face` → `faces_found=1`, лицо резче; фото без лиц → `faces_found=0`
и результат не хуже `x2plus`; искусственный OOM (`PHOTO_AI_TILE=1024` на 4 ГБ) деградирует до CPU
- [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`