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:
+4
-1
@@ -47,9 +47,12 @@ PHOTO_AI_URL=http://photo-ai:8080
|
||||
PHOTO_AI_MAX_PIXELS=4000000
|
||||
# Устройство инференса: auto (CUDA, если контейнеру выдан GPU, иначе CPU), cuda, cpu.
|
||||
# Явный cuda без доступной CUDA не роняет сервис: WARN в лог и работа на CPU.
|
||||
# GPU-запуск подключается файлом docker-compose.gpu.yml.
|
||||
# GPU-вариант собирается оверрайдом (отдельный образ whatido-photo-ai:cu126, GPU через CDI):
|
||||
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
|
||||
# Подробности установки NVIDIA Container Toolkit — в README, раздел «Запуск на NVIDIA GPU».
|
||||
PHOTO_AI_DEVICE=auto
|
||||
# Размер тайла инференса (0 — без тайлов). Меньше тайл — меньше памяти, медленнее.
|
||||
# На GPU с 4 ГБ VRAM держите 256: при нехватке сервис сам пройдёт лестницу тайлов и уйдёт на CPU.
|
||||
PHOTO_AI_TILE=256
|
||||
# Модель восстановления лиц: gfpgan (по умолчанию) или codeformer.
|
||||
# codeformer доступен только если модуль вендорен в photo-ai/vendor.
|
||||
|
||||
@@ -123,7 +123,7 @@ REDIS_PASSWORD=случайная-длинная-строка
|
||||
|
||||
Сервис `photo-ai` (Real-ESRGAN + GFPGAN) поднимается вместе со стеком и **включён по умолчанию**:
|
||||
`PHOTO_AI_URL` в `docker-compose.yml` равен `http://photo-ai:8080`, кнопка «🤖 ИИ» активна,
|
||||
а задания обрабатывает фоновый воркер. Работает на CPU, GPU не требуется.
|
||||
а задания обрабатывает фоновый воркер. Базовая сборка работает на CPU, GPU не требуется.
|
||||
|
||||
| Переменная | По умолчанию | Назначение |
|
||||
|---|---|---|
|
||||
@@ -139,18 +139,66 @@ REDIS_PASSWORD=случайная-длинная-строка
|
||||
| `PHOTO_AI_SOFT_BACKOFF_MS` | `10000` | Первая пауза перед мягким повтором |
|
||||
| `PHOTO_AI_SOFT_BACKOFF_MAX_MS` | `300000` | Потолок паузы (задержка растёт вдвое) |
|
||||
|
||||
### Запуск на NVIDIA GPU
|
||||
|
||||
GPU не обязателен: без него сервис работает на CPU. Чтобы включить GPU-вариант, нужен драйвер NVIDIA
|
||||
и NVIDIA Container Toolkit.
|
||||
|
||||
```bash
|
||||
# 1. Toolkit (один раз, требует sudo)
|
||||
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
|
||||
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
|
||||
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
|
||||
| sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
|
||||
sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
|
||||
|
||||
# 2. GPU-образ (для устройств с поддержкой CDI спеку генерирует сам toolkit)
|
||||
sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml
|
||||
sudo systemctl restart docker
|
||||
|
||||
# 3. Запуск
|
||||
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
|
||||
curl http://127.0.0.1:8081/health
|
||||
```
|
||||
|
||||
`docker-compose.gpu.yml` собирает отдельный тег `whatido-photo-ai:cu126` (torch из индекса `cu126` —
|
||||
те же версии, что и в CPU-образе, отличаются только CUDA-библиотеки), поэтому CPU-образ
|
||||
`whatido-photo-ai:latest` не перетирается и переключение обратно — обычный `docker compose up -d photo-ai`.
|
||||
|
||||
GPU выдаётся контейнеру через CDI (`device_ids: nvidia.com/gpu=all`), поэтому править
|
||||
`/etc/docker/daemon.json` и перезапускать демон не нужно. Если nvidia-runtime уже зарегистрирован в
|
||||
демоне (`nvidia-ctk runtime configure --runtime=docker`), в оверрайде можно заменить это на
|
||||
классическое резервирование `driver: nvidia, count: 1` — результат тот же.
|
||||
|
||||
Проверка результата: в `/health` должны быть `device: cuda:0`, `half: true`, непустые
|
||||
`vram_total_mb`/`vram_free_mb`. На 4 ГБ (например, RTX 3050 Laptop) реально держатся одновременно
|
||||
`x2plus` и `gfpgan`: `vram_free_mb` после двух моделей — около 100–300 МБ, поэтому `PHOTO_AI_TILE`
|
||||
оставьте небольшим (`256`), а `PHOTO_AI_LOAD_ALL=1` на 4 ГБ лучше не включать — предзагрузка всех
|
||||
моделей подряд исчерпает VRAM. Если памяти не хватило, сервис сам проходит лестницу тайлов
|
||||
(`PHOTO_AI_TILE` → /2 → /4), затем переключается на CPU и возвращает результат с предупреждением
|
||||
в `warnings` — задание при этом не падает.
|
||||
|
||||
Порт `8081` на `127.0.0.1` — только loopback хоста, наружу ничего не публикуется (хостовый `8080` занят
|
||||
`text-corrector`). Ручные проверки сервиса:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8081/health
|
||||
curl -F "image=@photo.jpg" -F "scale=2" http://127.0.0.1:8081/enhance -o out.jpg
|
||||
curl -H "Accept: application/json" -F "image=@photo.jpg" -F "face=face" http://127.0.0.1:8081/enhance \
|
||||
| python3 -c "import json,sys; d=json.load(sys.stdin); print({k: d[k] for k in ('device','faces_found','elapsed_ms','warnings')})"
|
||||
```
|
||||
|
||||
Состояние сервиса и воркера — в `GET /api/photo-jobs/status` (admin): `service` отдаёт health фото-сервиса
|
||||
(`configured`, `reachable`, `latency_ms`, `error`), `ai_configured` — задан ли `PHOTO_AI_URL`,
|
||||
(`configured`, `reachable`, `device`, `device_name`, `half`, `tile`, `vram_total_mb`, `vram_free_mb`,
|
||||
`models`, `face_models`, `loaded`, `latency_ms`, `error`), `ai_configured` — задан ли `PHOTO_AI_URL`,
|
||||
`worker` — состояние очереди и конфигурация воркера.
|
||||
|
||||
Параметры `/enhance`: `image` (файл), `scale` (`2`..`4`), `model` (`x2plus`|`general-x4v3`|`animevideo-v3`),
|
||||
`face` (`off`|`face`|`all`), `face_model` (`gfpgan`|`codeformer`), `strength` (`0..1`, только CodeFormer),
|
||||
`jpeg_quality` (`70..100`). По умолчанию отдаётся сырой `image/jpeg`; заголовок
|
||||
`Accept: application/json` переключает на JSON с `image_base64`, `faces_found`, `device`, `elapsed_ms`
|
||||
и `warnings` (малое разрешение входа, лица не найдены, не хватило памяти, CodeFormer на CPU).
|
||||
|
||||
## Публичный доступ через Tailscale
|
||||
|
||||
Стек не требует внешнего IP и проброса портов: контейнер `tailscale` запускается с `network_mode: host`, входит в вашу tailnet-сеть и через **Serve** открывает приложение внутри tailnet, а через **Funnel** — в публичном интернете.
|
||||
|
||||
+82
-11
@@ -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`
|
||||
|
||||
+17
-8
@@ -2,22 +2,31 @@
|
||||
# Использование:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai
|
||||
#
|
||||
# Требуется драйвер NVIDIA и установленный NVIDIA Container Toolkit
|
||||
# (nvidia-ctk + nvidia-container-runtime). Без toolkit контейнер не стартует —
|
||||
# базовый docker-compose.yml остаётся CPU-сборкой и этот файл не подключается.
|
||||
# PHOTO_AI_DEVICE=cuda при недоступной CUDA не роняет сервис: app.py пишет WARN
|
||||
# и работает на CPU, /health при этом отвечает 200.
|
||||
# Требуется драйвер NVIDIA и NVIDIA Container Toolkit. GPU выдаётся контейнеру через CDI
|
||||
# (device_ids ниже соответствует спеку /etc/cdi/nvidia.yaml): этот путь не требует правки
|
||||
# /etc/docker/daemon.json и перезапуска демона. Если nvidia-runtime зарегистрирован в демоне
|
||||
# (nvidia-ctk runtime configure --runtime=docker), вместо CDI можно вернуть классический вид
|
||||
# резервирования: driver: nvidia, count: 1 — результат тот же.
|
||||
# TORCH_VARIANT=cu126, а не cu124: в индексе cu124 последний torch — 2.6.0, а cu126 даёт ровно
|
||||
# те же torch 2.14.0 / torchvision 0.29.0, что и CPU-образ, поэтому варианты сборки отличаются
|
||||
# только CUDA-библиотеками.
|
||||
# Образ тегируется отдельно (whatido-photo-ai:cu126), чтобы сборка GPU-варианта не перетирала
|
||||
# CPU-образ whatido-photo-ai:latest.
|
||||
# PHOTO_AI_DEVICE=cuda при недоступной CUDA не роняет сервис: app.py пишет WARN и работает
|
||||
# на CPU, /health при этом отвечает 200.
|
||||
services:
|
||||
photo-ai:
|
||||
image: whatido-photo-ai:cu126
|
||||
build:
|
||||
args:
|
||||
TORCH_VARIANT: cu124
|
||||
TORCH_VARIANT: cu126
|
||||
environment:
|
||||
PHOTO_AI_DEVICE: ${PHOTO_AI_DEVICE:-cuda}
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
- driver: cdi
|
||||
device_ids:
|
||||
- nvidia.com/gpu=all
|
||||
capabilities: [gpu]
|
||||
|
||||
+35
-4
@@ -38,6 +38,7 @@ FACE_MODES = ('off', 'face', 'all')
|
||||
DEFAULT_MODEL = 'x2plus'
|
||||
DEFAULT_STRENGTH = 0.7
|
||||
MIN_TILE = 64
|
||||
MIN_FACE_SIDE = 320
|
||||
POOL_LIMIT = 2
|
||||
WARMUP_SIZE = 64
|
||||
RETRY_AFTER_SEC = 5
|
||||
@@ -233,19 +234,41 @@ class ModelNotReady(EnhanceError):
|
||||
self.label = label
|
||||
|
||||
|
||||
class TileOOM(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def error_response(status_code, message, headers=None):
|
||||
return JSONResponse(status_code=status_code, content={'ok': False, 'error': message},
|
||||
headers=headers)
|
||||
|
||||
|
||||
def is_oom(err):
|
||||
if isinstance(err, torch.cuda.OutOfMemoryError):
|
||||
if isinstance(err, (torch.cuda.OutOfMemoryError, TileOOM)):
|
||||
return True
|
||||
text = str(err).lower()
|
||||
return any(mark in text for mark in ('out of memory', 'not enough memory',
|
||||
'alloc_cpu', "can't allocate memory"))
|
||||
|
||||
|
||||
def guard_forward(model):
|
||||
if getattr(model, '_photo_ai_guarded', False):
|
||||
return model
|
||||
forward = model.forward
|
||||
|
||||
def guarded(*args, **kwargs):
|
||||
try:
|
||||
return forward(*args, **kwargs)
|
||||
except RuntimeError as err:
|
||||
if not is_oom(err):
|
||||
raise
|
||||
raise TileOOM(str(err)) from err
|
||||
|
||||
model.forward = guarded
|
||||
model._photo_ai_guarded = True
|
||||
return model
|
||||
|
||||
|
||||
def tile_ladder(base):
|
||||
if base <= 0:
|
||||
return [base]
|
||||
@@ -459,7 +482,7 @@ def build_esrgan(name, denoise):
|
||||
scale=spec['scale'],
|
||||
model_path=model_path,
|
||||
dni_weight=dni_weight,
|
||||
model=spec['arch'](),
|
||||
model=guard_forward(spec['arch']()),
|
||||
tile=state['tile'],
|
||||
tile_pad=TILE_PAD,
|
||||
pre_pad=PRE_PAD,
|
||||
@@ -511,6 +534,7 @@ def build_face_gfpgan(spec, outscale, bg):
|
||||
bg_upsampler=bg.upsampler,
|
||||
device=torch.device(state['device']),
|
||||
)
|
||||
guard_forward(restorer.gfpgan)
|
||||
|
||||
def restore(img, strength):
|
||||
cropped, _restored, output = restorer.enhance(img, has_aligned=False, only_center_face=False,
|
||||
@@ -532,6 +556,7 @@ def build_face_codeformer(spec, outscale, bg):
|
||||
connect_list=['32', '64', '128', '256'], device=state['device'], fp16=False)
|
||||
net.load_state_dict(torch.load(path, map_location=lambda storage, loc: storage))
|
||||
net.eval()
|
||||
guard_forward(net)
|
||||
helper = face_helper(outscale)
|
||||
|
||||
def restore(img, strength):
|
||||
@@ -612,7 +637,7 @@ def run_guarded(img, outscale, model_name, face, face_name, strength, warnings):
|
||||
pool.set_tile(tile)
|
||||
try:
|
||||
return process_image(img, outscale, model_name, face, face_name, strength)
|
||||
except RuntimeError as err:
|
||||
except (RuntimeError, TileOOM) as err:
|
||||
if not is_oom(err):
|
||||
raise EnhanceError('ошибка модели: %s' % err, 500) from err
|
||||
log.warning('нехватка памяти при tile=%s: %s', tile, err)
|
||||
@@ -621,7 +646,7 @@ def run_guarded(img, outscale, model_name, face, face_name, strength, warnings):
|
||||
degrade_to_cpu(warnings)
|
||||
try:
|
||||
return process_image(img, outscale, model_name, face, face_name, strength)
|
||||
except RuntimeError as err:
|
||||
except (RuntimeError, TileOOM) as err:
|
||||
if is_oom(err):
|
||||
raise EnhanceError('не хватило памяти даже на CPU: %s' % err, 500) from err
|
||||
raise EnhanceError('ошибка модели на CPU: %s' % err, 500) from err
|
||||
@@ -819,6 +844,12 @@ async def enhance(request: Request,
|
||||
outscale = min(max(int(scale), 2), 4)
|
||||
fmt = output_format(image.filename)
|
||||
warnings = []
|
||||
if face_mode != 'off':
|
||||
if min(img.shape[0], img.shape[1]) < MIN_FACE_SIDE:
|
||||
warnings.append('вход меньше %d×%d — лица могут не найтись'
|
||||
% (MIN_FACE_SIDE, MIN_FACE_SIDE))
|
||||
if face_name == 'codeformer' and not state['device'].startswith('cuda'):
|
||||
warnings.append('CodeFormer на %s медленнее GFPGAN' % state['device'])
|
||||
output, faces_found = await asyncio.to_thread(run_guarded, img, outscale, model_name,
|
||||
face_mode, face_name, float(strength), warnings)
|
||||
payload, media_type = encode_image(output, fmt, int(quality))
|
||||
|
||||
Reference in New Issue
Block a user