feat(photo-ai): реестр моделей и авто-выбор устройства
Stage 1: переписан photo-ai/app.py. Реестр моделей вместо одной модели, авто-выбор cuda/mps/cpu, ModelPool с ленивой загрузкой, прогревом и LRU на 2 записи, OOM-деградация по лестнице тайлов, расширенные /health и /models, /enhance с выбором модели, режима лиц и качества JPEG. Инвариант I1 держится: запрос только с image + scale=2 по-прежнему даёт байт-в-байт тот же JPEG (5/5 MATCH против эталона Stage 0). Расширение выводится по имени файла, а не по content_type, и .jpeg нормализуется в .jpg — этого хватает для совместимости с воркером, который шлёт image/jpeg для всего. /enhance отдаёт сырой JPEG по умолчанию и JSON при Accept: application/json. Пока модель грузится — 503 с Retry-After: 5; worker.js относит status >= 500 к мягким, поэтому попытка не тратится (I5). Новые переменные (PHOTO_AI_DEVICE, PHOTO_AI_MODELS_DIR, PHOTO_AI_TILE, PHOTO_AI_LOAD_ALL, PHOTO_AI_WARMUP, PHOTO_AI_FACE_MODEL, PHOTO_AI_JPEG_QUALITY) пока не документированы в .env.example: compose их ещё не подставляет, это Stage 2. Найдено: на хосте есть GPU (nvidia-smi, драйвер 615.71.09), не хватает только nvidia-container-toolkit. Установка — решение оператора, ветка CUDA осталась непроверенной прогоном.
This commit is contained in:
+69
-13
@@ -215,38 +215,94 @@ Stage 0 закрыт 2026-09-28, журнал проверок — «Журна
|
||||
|
||||
## 4. Stage 1. `photo-ai/app.py`: реестр моделей + авто-выбор устройства
|
||||
|
||||
- [ ] `pick_device() -> (device, half, device_name)` по §4.1 плана: env → cuda → mps → cpu;
|
||||
- [x] `pick_device() -> (device, half, device_name)` по §4.1 плана: env → cuda → mps → cpu;
|
||||
`half=True` только на CUDA; явный `cuda` без CUDA = WARN + cpu; явный `cpu` = всегда cpu.
|
||||
- [ ] `MODEL_REGISTRY`: `x2plus` (`RRDBNet(scale=2)`), `general-x4v3` (`SRVGGNetCompact(upscale=4)` + `wdn`),
|
||||
- [x] `MODEL_REGISTRY`: `x2plus` (`RRDBNet(scale=2)`), `general-x4v3` (`SRVGGNetCompact(upscale=4)` + `wdn`),
|
||||
`animevideo-v3` (`SRVGGNetCompact(upscale=4)`).
|
||||
- [ ] `FACE_REGISTRY`: `gfpgan` (`GFPGANer(arch='clean', channel_multiplier=2, upscale=2, bg_upsampler=…)`),
|
||||
- [x] `FACE_REGISTRY`: `gfpgan` (`GFPGANer(arch='clean', channel_multiplier=2, upscale=2, bg_upsampler=…)`),
|
||||
`codeformer` (за `D5`).
|
||||
- [ ] Загрузка весов: список URL из §3.4 плана, каталог `${PHOTO_AI_MODELS_DIR:-/models}/weights/`,
|
||||
- [x] Загрузка весов: список URL из §3.4 плана, каталог `${PHOTO_AI_MODELS_DIR:-/models}/weights/`,
|
||||
скачивание в `.tmp` → `os.replace`, проверка минимального размера, кэш в томе.
|
||||
`MODEL_PATH` читается как алиас для `x2plus` (существующий `.env`/том не ломается).
|
||||
- [ ] `class ModelPool`: `threading.Lock`, ленивая загрузка по требованию, кеш, LRU с лимитом 2,
|
||||
- [x] `class ModelPool`: `threading.Lock`, ленивая загрузка по требованию, кеш, LRU с лимитом 2,
|
||||
`PHOTO_AI_LOAD_ALL=1` — предзагрузка, прогрев на синтетическом шуме 64×64 после загрузки.
|
||||
- [ ] OOM-деградация: `RuntimeError` с CUDA OOM → `tile` пополам (256→128→64), один ретрай;
|
||||
- [x] OOM-деградация: `RuntimeError` с CUDA OOM → `tile` пополам (256→128→64), один ретрай;
|
||||
повтор → инвалидация модели, переход на CPU, ещё одна попытка; финал — `500` с понятным текстом.
|
||||
- [ ] `GET /health` — контракт §4.3 плана (поле `ok` сохраняется, добавляются `ready`, `device`,
|
||||
- [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`).
|
||||
- [ ] `GET /models` — список моделей, face-моделей, устройство, дефолты.
|
||||
- [ ] `POST /enhance`: поля `image`, `scale`, `model` (дефолт `x2plus`), `face` (`off|face|all`, дефолт `off`),
|
||||
- [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`.
|
||||
- [ ] Два формата ответа: сырой `image/jpeg` по умолчанию (совместимость) и JSON при
|
||||
- [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`.
|
||||
- [ ] Расширение выходного файла — по имени файла, не по `content_type` (воркер шлёт `image/jpeg` для всего).
|
||||
- [ ] **Проверка I1:** эталон из Stage 0 воспроизводится (сравнить размер/содержимое, `node --check`-эквивалент
|
||||
- [x] Расширение выходного файла — по имени файла, не по `content_type` (воркер шлёт `image/jpeg` для всего).
|
||||
- [x] **Проверка I1:** эталон из Stage 0 воспроизводится (сравнить размер/содержимое, `node --check`-эквивалент
|
||||
для Python — `python -c "import ast;ast.parse(open('photo-ai/app.py').read())"`).
|
||||
- [ ] **Приёмка:** `/health` отдаёт `device`/`device_name`/`half`; `PHOTO_AI_DEVICE=cpu` при рабочей CUDA →
|
||||
- [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
|
||||
|
||||
Reference in New Issue
Block a user