Files
WhatIDo/PLAN_PHOTO_FACE_AI.md
dev 403574fe79 chore(photo-ai): раздел 0 — жёсткие инварианты I1–I7 зафиксированы
План фото-ИИ с восстановлением лиц разбит на этапы; этот коммит закрывает
раздел 0 — семь инвариантов, которые нельзя ломать дальше. Два из них были
нарушены в текущем коде и исправлены здесь.

I5 (мягкие ошибки не сжигают попытки). Раньше любой сбой photo-ai —
503, обрыв сети, таймаут — попадал в общий catch, инкрементил attempts и
через три попытки переводил задание в error. Теперь ошибки разделены:
5xx/429/425/408 и сетевая недоступность возвращают задание в pending без
инкремента attempts, с экспоненциальной паузой 10 с → 300 с; лимит мягких
повторов (по умолчанию 60) даёт одну честную ошибку с понятным текстом.
Таймаут AbortSignal.timeout — жёсткая ошибка с попытками, как и раньше.
Счётчик мягких повторов живёт в памяти процесса и в счётчиках воркера,
метаданные повтора — в audit_log.target (soft_attempt/soft_limit), без
новых колонок. Новый аудит-код photo.job.soft_retry и подпись в audit.js.
Переменные PHOTO_AI_SOFT_MAX_RETRIES, PHOTO_AI_SOFT_BACKOFF_MS,
PHOTO_AI_SOFT_BACKOFF_MAX_MS описаны в .env.example и отдаются в
worker.config в GET /api/photo-jobs/status.

I7 (никаких прямых fs.* по uploads/). runAiEnhance писал результат
fs.writeFileSync в uploads/ и только потом persist в S3; enhanceWithSharp
делал то же через sharp toFile. Оба теперь считают буфер и пишут его
через storage.put — драйвер выбирает сам, локальной копии не остаётся.
Из worker.js убраны require('fs'), require('path') и параметр uploadsDir.

I3 (photo-ai не обязателен). photo_ai_enabled вычислялся внутри
cacheWrap('public-settings'), поэтому после перезапуска с пустым
PHOTO_AI_URL кнопка «🤖 ИИ» оставалась видимой до истечения кэша (60 с),
хотя enhance-ai уже отдавал 503. Флаг вынесен из кэша: он выводится из
PHOTO_AI_URL в памяти процесса и всегда актуален.

Проверено на стенде (журнал — в TODO_PHOTO_FACE_AI.md, раздел 0):
- I1: эталон /enhance снят на 5 фото (3 реальных, 2 синтетических),
  два независимых прогона и прогон после правок совпали байт-в-байт
  (sha256), /health отдаёт ok. Скрипты и эталон — в backups/ (вне git)
- I2: задание с params IS NULL и action='ai' дошло до done при
  attempts=0, результат отдан из S3 (200)
- I3: с пустым PHOTO_AI_URL photo_ai_enabled=false сразу после старта,
  enhance-ai → 503, остальные маршруты API живы
- I4: nvidia-ctk и nvidia-container-runtime на хосте отсутствуют, runtime
  только runc — фото-ИИ поднялся на CPU, /health не падает. Проверка
  PHOTO_AI_DEVICE переносится на приёмку Stage 1 (переменной ещё нет)
- I6: db/ не тронут, состав колонок photo_jobs прежний
- I7: node --check для всех изменённых JS, комментариев в диффе нет,
  весь SQL параметризован

api.smoketest.js: контракт фото-воркера — согласованность
photo_ai_enabled и ai_configured, ключи мягких повторов в worker.config,
503/404 для enhance-ai на несуществующей записи (тест не создаёт реальных
заданий). Вместе с планом и чек-листом этапов.
2026-09-28 23:19:16 +03:00

40 KiB
Raw Permalink Blame History

План: Улучшение лиц на фотографиях (Real-ESRGAN + GFPGAN/CodeFormer) с автовыбором GPU/CPU

Статус: план, реализация не начата. Документ описывает целевую архитектуру, изменения по файлам, порядок внедрения и критерии приёмки. Реализацию начинать после знакомства с этим файлом.


1. Постановка задачи

Сейчас система улучшает фотографии одной моделью RealESRGAN_x2plus (x2, CPU, half=False, device='cpu', захардкожено в photo-ai/app.py:44). Модель хорошо восстанавливает текстуры (одежда, фон, бумага), но лица при апскейле часто получают артефакты, «пластиковую» кожу и искажённые черты, потому что у RealESRGAN_x2plus нет приора на структуру лица.

Нужно:

  1. Добавить модели восстановления лиц — GFPGAN v1.4 и/или CodeFormer (оба — face restoration с prior-сетью, работают в связке с RealESRGANer как bg_upsampler).
  2. Дать пользователю выбор модели для обработки: универсальная x2, face-модель, комбинация (фон + лица), быстрая VGG-модель realesr-general-x4v3.
  3. Поддержать работу на GPU и на CPU с автоматическим выбором устройства: есть рабочий CUDA — используем GPU, нет — молча и без падений уходим на CPU.
  4. Не допустить простоя GPU-контейнера: пока модели грузятся — сервис отвечает 503, воркер ждёт, а не теряет задания.
  5. Не сломать существующие контракты: photo_jobs, /enhance, PHOTO_AI_URL, автономную работу без photo-ai (PHOTO_AI_URL пустой → кнопка «ИИ» недоступна).

Ограничения окружения (проверено на текущем хосте)

Параметр Значение Следствие
GPU NVIDIA GeForce RTX 3050 ..., 4096 MiB VRAM, драйвер 615.71.09, CUDA UMD 13.4 GPU-режим реален, но 4 ГБ VRAM — тесно, нужен tile и запас
/dev/dri card1, card2, renderD128, renderD129 iGPU тоже виден, но torch будет использовать CUDA
nvidia-ctk не установлен нужен NVIDIA Container Toolkit на хосте, иначе --gpus не заработает
Runtime Docker только runc (нет nvidia) требуется установка toolkit + docker compose override
CPU Intel i5-12500H, 16 потоков CPU-режим приемлем как fallback, но медленный
RAM 15 GiB (занято ~9 ГБ), swap 31 GiB CPU-модели + torch требуют ~2–3 ГБ; следить за OOM
Диск 59 ГБ свободно на /home веса: x2plus 64 МБ + GFPGAN 333 МБ + CodeFormer 360 МБ + wdn 64 МБ — ок
Docker / Compose 29.8.1 / 5.5.1 поддерживают deploy.resources.reservations.devices (Compose v5)

Важно: GPU-режим не должен быть обязательным условием запуска. Если toolkit не установлен, compose-файл с devices не поднимется — поэтому GPU выносим в отдельный override-файл.


2. Что уже есть (точки интеграции)

Место Что делает Что меняем
photo-ai/app.py FastAPI, POST /enhance (image, scale), одна модель, device='cpu' Расширяем до реестра моделей + автовыбор device + POST /enhance с model/face
photo-ai/Dockerfile python:3.10-slim, torch CPU-only, правка basicsr/data/degradations.py Разделяем на CPU-базу и GPU-базу (ARG), добавляем gfpgan, facexlib
docker-compose.yml (photo-ai) build ./photo-ai, MODEL_PATH, MAX_INPUT_PIXELS, том photo-ai-models Добавляем env PHOTO_AI_DEVICE, PHOTO_AI_FACE_MODEL, PHOTO_AI_TILE, healthcheck
worker.js → createPhotoEnhanceWorker runAiEnhance(srcKey) шлёт image + scale=2, ждёт image/jpeg (таймаут 300 с, 3 попытки) Передаём model/face/strength из job.params, разбираем JSON-ответ, 503 ждёт без траты попыток
server.js → POST /api/entries/:id/photo/enhance-ai INSERT INTO photo_jobs (entry_id, action, status, params) VALUES ($1,'ai','pending',NULL) Принимаем model/face/face_model/strength, валидируем, пишем в params (колонка уже есть)
server.js → GET /api/photo-jobs/status ai_configured, ai_url, worker, counts Добавляем service.device, service.models, service.face_models, service.ready
server.js → PHOTO_JOB_ACTIONS белый список действий, action VARCHAR(20) Новые действия ai_face, ai_upscale в двух местах (валидация + restore)
public/js/journal.js (кнопка «🤖 ИИ») Ставит задание action='ai' Выбор модели: универсальная / быстрая / лица / обе
public/js/worker.js + worker.html Таблица заданий, PHOTO_ACTION_LABELS, сравнение «Было/Стало» Новые метки, показ модели/устройства/времени обработки
settings.html / settings.js photo_enhance_engine (auto/server/client) Новые настройки: photo_ai_face_mode, photo_ai_device_pref

Текущее поведение, которое сохраняем байт-в-байт: /enhance с scale=2 без указания модели → та же картинка, что и сегодня (x2plus, JPEG q92). Это нужно, чтобы старые photo_jobs со params = NULL и все закешированные превью не изменились.


3. Модели: что именно добавляем

3.1 Каталог моделей

Ключ Класс Веса Размер Назначение
x2plus RRDBNet(scale=2) RealESRGAN_x2plus.pth 64 МБ текущая, универсальный апскейл x2, дефолт
general-x4v3 SRVGGNetCompact(upscale=4) realesr-general-x4v3.pth 4.7 МБ быстрый апскейл x4 + денойз через DNI (realesr-general-wdn-x4v3.pth)
animevideo-v3 SRVGGNetCompact(upscale=4) realesr-animevideov3.pth 2.4 МБ быстрый x4, для скриншотов/иллюстраций
gfpgan GFPGANer(arch='clean', channel_multiplier=2) GFPGANv1.4.pth 333 МБ восстановление лиц, bg_upsampler = выбранный ESRGAN
codeformer CodeFormer codeformer.pth + detection_Resnet50_Final.pth + parsing_parsenet.pth 360 МБ + ~110 МБ восстановление лиц, регулируемая сила fidelity_weight (-w), лучше на сильных искажениях

Рекомендация: GFPGAN v1.4 как основная face-модель (меньше весов, стабильнее на 4 ГБ VRAM, это модель по умолчанию в апстриме inference_realesrgan.py через --face_enhance), CodeFormer — опционально, как альтернатива с регулируемой силой. На CPU CodeFormer практически неработоспособен по времени (facexlib + parsing) — оставляем его «только GPU, если включён явно».

3.2 Режимы обработки (face_mode)

face_mode Что происходит Модель Когда использовать
off только апскейл фона, лицо не трогается ESRGAN текущее поведение, фон/текстуры
face апскейл фона + только лица вставлены восстановленными (paste-back) ESRGAN + GFPGAN/CodeFormer портреты, крупные лица
all как face, плюс мягкий денойз фона ESRGAN(+wdn) + GFPGAN зашумлённые снимки с веб-камеры 640×480

Технически GFPGAN работает так: детектирует лица (facexlib/RetinaFace), кропает → восстанавливает → paste-back в альбом. Если лиц не найдено — возвращает чистый результат bg_upsampler: это безопасный no-op и не должно считаться ошибкой.

3.3 Почему face-модель нельзя ставить «всегда»

  1. Скорость. Детекция + face-restore + paste-back даёт +40…150 % ко времени. На CPU это разница между ~40 с и ~90 с на фото 640×480.
  2. Гарантий нет. GFPGAN «дорисовывает» лица по приору: на сильно замытых, боковых или закрытых лицах он может сделать человека похожим на другого. Для журнала посещаемости ошибка идентификации недопустима, поэтому face-режим — явный выбор пользователя, а не молчаливый дефолт.
  3. VRAM. GFPGAN + x2plus одновременно держат два графа на GPU; на 4 ГБ нужен tile <= 256.

3.4 Точные URL весов (скачиваются при первом запуске)

https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-x4v3.pth
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-wdn-x4v3.pth
https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-animevideov3.pth
https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.4.pth
https://github.com/sczhou/CodeFormer/releases/download/v0.1.0/codeformer.pth

Каждый файл кладём в /models/weights/<name>.pth (том photo-ai-models), загрузка через .tmp + os.replace и проверку минимального размера — иначе оборванная закачка оставит «битые» веса, и сервис будет падать при загрузке.


4. Автоматический выбор GPU/CPU

4.1 Приоритет выбора (в app.py при старте)

1. PHOTO_AI_DEVICE=cuda|cpu|auto (env, дефолт auto)
2. если auto:
   a. torch.cuda.is_available() and torch.cuda.device_count() > 0
      -> device = 'cuda:0', half = True (fp16 быстрее и экономит VRAM)
   b. иначе если torch.backends.mps.is_available()
      -> device = 'mps', half = False (Apple Silicon, на случай dev-машины)
   c. иначе -> device = 'cpu', half = False
3. если явно cuda, но cuda недоступна -> НЕ падать: WARN в лог и уйти на cpu
   (контейнер обязан подниматься даже без GPU — требование отказоустойчивости)
4. явно cpu — всегда cpu, даже если GPU есть (для отладки и воспроизводимости)

4.2 Прогрев, ленивая загрузка и деградация

  • load_model() вызывается в startup (сейчас через asyncio.to_thread), модели грузятся лениво по требованию и кешируются в MODELS dict под threading.Lock. Первый запрос к новой модели оплачивает её загрузку (x2plus 64 МБ грузится быстрее, чем GFPGAN 333 МБ).
  • Пока нужная модель не готова, /enhance возвращает 503 + Retry-After: 5, а /health отдаёт {"ok": true, "ready": false, "loading": ["gfpgan"]}. Воркер на 503 не тратит попытку из PHOTO_MAX_ATTEMPTS: он ждёт и повторяет (см. §5.4).
  • CUDA out of memory при инференсе — ловим RuntimeError, уменьшаем tile вдвое (256 → 128 → 64) и повторяем один раз; если снова OOM — переключаемся на cpu, инвалидируем модель и повторяем. Если и на CPU не получилось — 500 с понятным текстом.
  • Прогрев (warm-up) на синтетическом шуме 64×64 сразу после загрузки: убирает «первый запрос в 3 раза дольше» и немедленно выявляет OOM.

4.3 Что показывать оператору

GET /health (расширяем, обратно совместимо: поле ok остаётся):

{
  "ok": true,
  "ready": true,
  "device": "cuda:0",
  "device_name": "NVIDIA GeForce RTX 3050 ...",
  "half": true,
  "tile": 256,
  "driver": "615.71.09",
  "cuda": "13.4",
  "vram_total_mb": 4096,
  "vram_free_mb": 3780,
  "models": ["x2plus", "general-x4v3", "animevideo-v3"],
  "face_models": ["gfpgan", "codeformer"],
  "loaded": ["x2plus", "gfpgan"],
  "max_pixels": 4000000
}

server.js проксирует это в GET /api/photo-jobs/status → service и в блок «Статус стека» (getStackInfo(), public/settings.html), чтобы было видно, на чём реально считает фото-ИИ.

4.4 Docker: как отдать GPU контейнеру

Два обязательных шага на хосте (сейчас не выполнены — nvidia-ctk отсутствует):

# 1. NVIDIA Container Toolkit
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
sudo nvidia-ctk runtime configure --runtime=docker && sudo systemctl restart docker

# 2. Проверка
docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi

Compose делим на два файла, чтобы GPU был опцией, а не требованием (по образцу существующего docker-compose.minio.yml):

  • docker-compose.yml — photo-ai без GPU (CPU-образ, как сейчас). Стек поднимается на любой машине.
  • docker-compose.gpu.yml (новый) — override:
services:
  photo-ai:
    build:
      context: ./photo-ai
      args:
        TORCH_VARIANT: cu124
    environment:
      PHOTO_AI_DEVICE: cuda
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]

Запуск GPU-режима:

docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai

Если photo-ai уже запущен в CPU-режиме — сначала docker compose stop photo-ai, затем команда выше (иначе Compose ругается на конфликт конфигурации сервиса).


5. Изменения по файлам

5.1 photo-ai/app.py (переписываем, ~250–300 строк)

Новая структура:

ENV: PHOTO_AI_DEVICE, PHOTO_AI_FACE_MODEL, PHOTO_AI_TILE, PHOTO_AI_MAX_PIXELS,
     PHOTO_AI_MODELS_DIR, PHOTO_AI_LOAD_ALL, PHOTO_AI_WARMUP, PHOTO_AI_JPEG_QUALITY
pick_device() -> (device, half, device_name)          # §4.1
MODEL_REGISTRY = { 'x2plus': {...}, 'general-x4v3': {...}, 'animevideo-v3': {...} }
FACE_REGISTRY  = { 'gfpgan': {...}, 'codeformer': {...} }
class ModelPool:                                      # Lock, dict, lazy load, OOM-ретрай
    get_esrgan(name) -> RealESRGANer
    get_face(name, bg_upsampler) -> GFPGANer | CodeFormer
encode_jpeg(out, quality)
@app.get('/health')                                    # расширенный контракт §4.3
@app.get('/models')                                    # список моделей + устройство
@app.post('/enhance')                                  # image, scale, model, face, face_model, strength

Ключевые детали реализации:

  • POST /enhance принимает те же image и scale, плюс новые необязательные поля: model (дефолт x2plus), face (off|face|all, дефолт off), face_model (gfpgan|codeformer), strength (0.0–1.0, только CodeFormer, дефолт 0.7), jpeg_quality (70–100, дефолт 92). Неизвестная модель → 400 со списком допустимых.
  • Полная обратная совместимость: без model/face → путь «x2plus, JPEG q92», как сегодня.
  • face != off → face_enhancer.enhance(img, has_aligned=False, only_center_face=False, paste_back=True); в ответ добавляем faces_found.
  • Два формата ответа: JSON ({ok, image_base64, model, face, face_model, faces_found, device, elapsed_ms, warnings}) при Accept: application/json — новый путь для воркера; сырой image/jpeg без заголовка — текущее поведение (совместимость с ручными curl).
  • upsampler.enhance() уже делает тайлинг сам — tile/tile_pad=10 остаются, tile берём из env.
  • half=True только при CUDA; dni_weight — только для general-x4v3 при denoise_strength != 1.
  • Расширения определяем по имени файла, а не по UploadFile.content_type (браузер и FormData в worker.js шлют image/jpeg для любого исходника — сейчас это уже так, сохраняем).

5.2 photo-ai/Dockerfile

FROM python:3.10-slim
ARG TORCH_VARIANT=cpu            # cpu | cu124
ARG TORCH_INDEX=https://download.pytorch.org/whl/${TORCH_VARIANT}
  • TORCH_VARIANT=cpu → torch torchvision --index-url .../whl/cpu (как сейчас); cu124 → тот же pip с .../whl/cu124. Образ один, вариант — аргумент сборки.
  • Добавить gfpgan==1.3.8 и facexlib==0.3.0 (CodeFormer — из TencentARC, пакет/вендоринг). Проверить доступность пакетов в зеркале pip заранее — это риск сборки, если недоступны, вендорим исходники в photo-ai/vendor/ и копируем каталог в образ.
  • libgl1 libglib2.0-0 уже есть — facexlib их требует.
  • Патч basicsr/data/degradations.py (torchvision.transforms.functional_tensor → functional) сохранить — без него basicsr падает на torch >= 2.0.
  • ENV PHOTO_AI_MODELS_DIR=/models; старый MODEL_PATH продолжаем читать как алиас, чтобы существующий .env/том не сломался.
  • Размер образа: CPU ~1.2 ГБ → ~2.5 ГБ; cu124 ~6–7 ГБ. Учитывать при --build.
  • Тома: photo-ai-models уже есть — все веса в /models/weights/*.pth.

5.3 docker-compose.yml

photo-ai:
  environment:
    PHOTO_AI_DEVICE: ${PHOTO_AI_DEVICE:-auto}
    PHOTO_AI_FACE_MODEL: ${PHOTO_AI_FACE_MODEL:-gfpgan}
    PHOTO_AI_TILE: ${PHOTO_AI_TILE:-256}
    PHOTO_AI_MAX_PIXELS: ${PHOTO_AI_MAX_PIXELS:-4000000}
    PHOTO_AI_LOAD_ALL: ${PHOTO_AI_LOAD_ALL:-0}
    PHOTO_AI_JPEG_QUALITY: ${PHOTO_AI_JPEG_QUALITY:-92}
    MODEL_PATH: /models/RealESRGAN_x2plus.pth
  healthcheck:
    test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3).status==200 else 1)\""]
    interval: 30s
    timeout: 5s
    retries: 5
    start_period: 300s

start_period: 300s — загрузка x2plus + GFPGAN на CPU занимает минуты; короткий период даст unhealthy на старте. PHOTO_AI_LOAD_ALL=1 предзагружает все модели (для GPU-сервера с запасом RAM), по умолчанию 0 — ленивая загрузка.

5.4 worker.js (функция createPhotoEnhanceWorker)

  • runAiEnhance(srcKey, params):
    • читает params.model, params.face, params.face_model, params.strength;
    • отправляет их вместе с image и scale; ставит Accept: application/json, разбирает JSON-ответ, декодирует base64 в буфер;
    • если сервис вернул image/jpeg (старая версия photo-ai) — работает как сейчас, без ошибок;
    • 503 (Retry-After) обрабатывает отдельно: sleep 10 с, return false — без инкремента attempts (мягкий повтор, задание не сгорает);
    • AbortError от AbortSignal.timeout(PHOTO_AI_TIMEOUT_MS) → сообщение «ИИ-сервис не ответил за N с» (уже ошибка с попытками);
    • в аудит пишет модель/устройство/время: logAudit(null, 'photo.job.preview', { ..., model, face, device, elapsed_ms }).
  • processOne: job.action === 'ai' || job.action === 'ai_face' → AI-путь; enhance → sharp. Если params пусты, action='ai_face' даёт дефолт face='face'.
  • CONFIG воркера дополняем face_model, default_model, face_timeout_ms — они попадают в GET /api/photo-jobs/status.worker.config.
  • Для face-режима на CPU вводим отдельный, больший таймаут: PHOTO_AI_FACE_TIMEOUT_MS (дефолт 600 000) вместо 300 000.

5.5 server.js

  1. POST /api/entries/:id/photo/enhance-ai (строка 5114) — тело { model, face, face_model, strength }:
    • model ∈ ['x2plus', 'general-x4v3', 'animevideo-v3'];
    • face ∈ ['off', 'face', 'all'];
    • face_model ∈ ['gfpgan', 'codeformer'];
    • strength — число 0…1, только для CodeFormer;
    • params = JSON.stringify({ model, face, face_model, strength }) в photo_jobs.params;
    • action = face === 'off' ? 'ai' : 'ai_face';
    • 400 при невалидных значениях; пустое тело → сегодняшнее поведение (params = NULL, action = 'ai'). Валидация — инлайн-хелперами (optInt) и явными списками, как в PUT /api/settings.
  2. PHOTO_JOB_ACTIONS — добавить 'ai_face', 'ai_upscale'. Схема не меняется (action VARCHAR(20)), но белый список используется в двух местах: ensurePhotoJobsTable() (~1238–1256) и при разборе restore-данных (~2259) — обновить оба.
  3. GET /api/photo-jobs/status (строка 5782) — прокинуть service.device, service.device_name, service.ready, service.vram_free_mb, service.models, service.face_models из GET /health photo-ai (таймаут 5 с, по образцу aiHealthCheck()). Запрос делать без падения: photo-ai недоступен → service = { reachable: false }.
  4. Новый GET /api/photo-ai/health (requireAdmin) — прямой прокси /health для оператора. В getStackInfo() добавить блок photo_ai (device, device_name, vram_total_mb, models) — рендерится в «Статус стека» на public/settings.html.
  5. Настройки — валидация рядом со строкой 1710:
    • photo_ai_face_mode ∈ ['off', 'face', 'all'], дефолт off;
    • photo_ai_device_pref ∈ ['auto', 'cuda', 'cpu'], дефолт auto — только для UI и документации: фактическое устройство определяет контейнер через env, UI показывает, совпадает ли желаемое с фактическим (если нет — подсветить).
    • дефолты дописать в db/init.sql, db/migration.sql (INSERT ... ON CONFLICT DO NOTHING) и в keys/defaults /api/public-settings (строка 1663–1664);
    • photo_ai_enabled там уже отдаётся (строка 1677) — сохранить.
  6. worker.js вызывается с новыми параметрами: в createPhotoEnhanceWorker({...}) (строка 6045) добавить faceTimeoutMs: PHOTO_AI_FACE_TIMEOUT_MS, defaultFaceModel: PHOTO_AI_FACE_MODEL.
  7. Предупреждения сервиса (warnings, например «вход меньше 320×320») сохранять в аудит, чтобы оператор понимал, что face-режим не сработал не из-за ошибки.

5.6 Фронтенд

  • public/js/journal.js (кнопка «🤖 ИИ», ~строка 620) — рядом dropdown: «Универсально (x2)», «Быстро (x4)», «Лица (GFPGAN)», «Лица + фон». Значение уходит в POST /api/entries/:id/photo/enhance-ai телом { model, face, face_model }. Дефолт — из photo_ai_face_mode (публичные настройки уже читаются на этой странице).
  • public/js/worker.js — PHOTO_ACTION_LABELS дополнить (ai_face: «ИИ + лица», ai_upscale: «ИИ-апскейл»); в модалке сравнения «Было/Стало» показать model, device, faces_found, elapsed_ms. Новых колонок в БД не нужно — данные берём из params и аудита.
  • public/settings.html + public/js/settings.js — блок «Фото-ИИ»: селект face-режима, селект устройства («желаемое» + строка «фактическое»), read-only статус (device_name, VRAM, список моделей) из /api/photo-jobs/status. Новые поля добавить в DIRTY_FIELDS (строка 1 settings.js) и в сборку payload (строка ~739).
  • public/js/audit.js — новые коды действий аудита прописать в словарь меток, иначе в UI будет сырой код (photo.job.preview уже есть, при добавлении photo.job.ai — добавить и там).

5.7 Документация и тесты

  • AGENTS.md — раздел про photo-ai: реестр моделей, device-политика, новые env, GPU-override, обновлённый контракт /enhance и /health.
  • README.md — установка NVIDIA Container Toolkit, команда запуска с docker-compose.gpu.yml, проверка GET /api/photo-jobs/status → service.device.
  • .env.example — новые переменные с комментариями (см. §7).
  • api.smoketest.js — добавить проверки:
    • POST /api/entries/:id/photo/enhance-ai с model: 'нет такой' → 400;
    • face: 'face' без PHOTO_AI_URL → 503;
    • GET /api/photo-jobs/status содержит service (или ai_configured: false). Это соответствует правилу из AGENTS.md: контракт авторизации и API фиксируется в smoke-тесте в том же изменении.

6. Порядок внедрения (этапы)

Каждый этап заканчивается проверяемым результатом и не ломает предыдущий.

Этап 0. Подготовка (0.5 дня)

  • Установить NVIDIA Container Toolkit, проверить docker run --gpus all ... nvidia-smi.
  • Зафиксировать текущее состояние .env (PHOTO_AI_URL= пусто или http://photo-ai:8080).
  • Сохранить эталон «до»: 3–5 фото (портрет, групповое, без лиц, зашумлённое), прогнать curl -F image=@photo.jpg -F scale=2 http://localhost:8080/enhance.

Приёмка: nvidia-smi внутри контейнера видит RTX 3050; эталонные JPEG сохранены для сравнения на следующих этапах.

Этап 1. app.py: реестр моделей + автовыбор device (1–1.5 дня)

  • pick_device(), ModelPool, расширенный /health, ленивая загрузка x2plus, warm-up.
  • Старый /enhance по поведению не меняется (проверить визуально и по размеру файла).

Приёмка: /health отдаёт device, device_name, half; PHOTO_AI_DEVICE=cpu при рабочей CUDA даёт cpu; PHOTO_AI_DEVICE=cuda без toolkit даёт cpu + WARN, сервис поднимается.

Этап 2. Dockerfile + compose (0.5–1 день)

  • ARG TORCH_VARIANT, docker-compose.gpu.yml, healthcheck, новые env, алиас MODEL_PATH.

Приёмка: CPU- и GPU-сборка стартуют; /api/photo-jobs/status показывает service.device = "cuda:0" в GPU-режиме и "cpu" в CPU-режиме; при пустом PHOTO_AI_URL приложение полностью работает без photo-ai.

Этап 3. GFPGAN — face-режим (1–2 дня)

  • FACE_REGISTRY, поля face/face_model/strength в /enhance, JSON-ответ, faces_found, OOM-ретрай и fallback на CPU.

Приёмка: на тестовом портрете 640×480 в режиме face лицо резче, faces_found = 1; на фото без лиц — faces_found = 0 и результат не хуже, чем x2plus; искусственный OOM (tile=1024 на 4 ГБ) деградирует до CPU без падения сервиса.

Этап 4. Воркер и API (1 день)

  • worker.js: params → модель/face, JSON-разбор, 503-ожидание без траты попыток, аудит.
  • server.js: валидация params, action = 'ai_face', service в статусе, новые настройки.

Приёмка: задание через API с face='face' доходит до done, параметры сохранены в photo_jobs.params, в аудите — модель/устройство/время; остановка photo-ai на лету даёт задание, которое дообработается после возврата сервиса (без error).

Этап 5. Фронтенд (1 день)

  • Выбор модели в журнале, статус устройства в настройках, новые метки в воркере.

Приёмка: из журнала доступны все 4 варианта; после постановки видно «В очереди», затем сравнение «Было/Стало»; в настройках показано фактическое устройство и VRAM.

Этап 6. Docs + smoke (0.5 дня)

  • AGENTS.md, README.md, .env.example, api.smoketest.js.

Приёмка: node api.smoketest.js проходит; тесты валидации возвращают 400; документация совпадает с кодом.

Итого: ~5–7 рабочих дней. Этапы 1–3 дают ценность уже без фронтенда (ручные вызовы curl).


7. Новые переменные окружения

# === Фото-ИИ (Real-ESRGAN + восстановление лиц) ===
# Адрес сервиса; пусто = фото-ИИ выключен (кнопка «ИИ» недоступна)
PHOTO_AI_URL=
# Максимум входных пикселей (даунскейл перед обработкой)
PHOTO_AI_MAX_PIXELS=4000000
# Устройство: auto | cuda | cpu. auto = CUDA, если доступна, иначе CPU
PHOTO_AI_DEVICE=auto
# Тайл для апскейла: меньше = меньше VRAM, но медленнее (256 на 4 ГБ, 512+ при 8 ГБ+)
PHOTO_AI_TILE=256
# Модель лиц: gfpgan | codeformer | none
PHOTO_AI_FACE_MODEL=gfpgan
# Предзагружать все модели при старте (1 — да, нужно больше RAM/VRAM)
PHOTO_AI_LOAD_ALL=0
# Режим лиц по умолчанию для UI: off | face | all
PHOTO_AI_FACE_MODE=off
# Качество JPEG результата (70–100)
PHOTO_AI_JPEG_QUALITY=92
# Отдельный таймаут для face-режима, мс (на CPU медленно)
PHOTO_AI_FACE_TIMEOUT_MS=600000

8. Риски и как их закрываем

Риск Вероятность Митигация
nvidia-container-toolkit не установлен / политика хоста запрещает средняя GPU — отдельный override-файл; CPU-путь остаётся дефолтом и полностью рабочим; в /health видно фактическое устройство
4 ГБ VRAM не хватает для GFPGAN + x2plus высокая tile=256 по умолчанию, авто-снижение до 128/64 при OOM, half=True только на CUDA, при повторном OOM — fallback на CPU
GFPGAN «портит» лица (артефакты идентичности) средняя face-режим только по явному выбору; результат попадает в photo_jobs.after_path и не применяется автоматически (нужно нажать «Применить»), история и «Вернуть оригинал» сохраняются
Долгая загрузка весов (333 МБ) на первом запросе высокая ленивая загрузка + 503/Retry-After вместо ошибки; PHOTO_AI_LOAD_ALL=1 для прогрева; том photo-ai-models сохраняет веса между перезапусками; start_period: 300s в healthcheck
Сборка ломается на pip install gfpgan/facexlib (пакета нет в зеркале) средняя проверить доступность пакетов до начала работ; при отсутствии — вендорить исходники в photo-ai/vendor/ и копировать каталог в образ
Рост образа до ~6–7 ГБ (cu124) средняя CPU-образ по умолчанию, GPU-образ собирается отдельно; на /home свободно 59 ГБ
CPU-инференс с GFPGAN медленнее таймаута воркера средняя отдельный PHOTO_AI_FACE_TIMEOUT_MS (600 с) для face-режима; в UI предупреждение «на CPU медленно»; для слабых машин — off
Регресс текущего /enhance низкая контракт по умолчанию не меняется; эталонное сравнение на этапах 1–2; старые photo_jobs.params = NULL продолжают работать
Нехватка RAM (15 ГБ, занято ~9 ГБ) при загрузке моделей на CPU средняя PHOTO_AI_LOAD_ALL=0, кеш моделей с вытеснением (LRU, максимум 2), контроль через docker stats
Воркер зацикливается на «вечно недоступном» сервисе низкая при 503/unreachable — экспоненциальный sleep, лимит мягких повторов (например 60) → затем одна честная ошибка с понятным текстом

9. Критерии готовности

  1. photo-ai стартует и на CPU-хосте, и с CUDA, определяя устройство автоматически; /health сообщает фактическое устройство, имя GPU, VRAM, доступные и загруженные модели.
  2. Существующий сценарий «🤖 ИИ» без параметров даёт результат, эквивалентный текущему.
  3. Доступны режимы: универсальный x2, быстрый x4, только лица (GFPGAN), лица + фон.
  4. Задание с face-моделью проходит полный цикл pending → processing → done; результат виден в сравнении «Было/Стало», применим вручную и откатывается.
  5. Отсутствие GPU, отсутствие nvidia-ctk и недоступный photo-ai не ломают приложение: PHOTO_AI_URL= пусто → кнопка «ИИ» недоступна; сервис упал → задания пережидают (503), не сжигая попытки; пустой .env полностью совместим.
  6. node api.smoketest.js проходит; AGENTS.md, README.md, .env.example описывают новые переменные и GPU-запуск.

10. Быстрые команды для проверки после реализации

# Статус устройства и моделей
docker compose exec -T app node -e "fetch(process.env.PHOTO_AI_URL+'/health').then(r=>r.json()).then(console.log)"
curl -s -H "X-Auth-Token: $TOKEN" http://localhost:3003/api/photo-jobs/status | python3 -m json.tool

# Прямой вызов с лицами (ручная проверка)
curl -s -X POST http://localhost:8080/enhance \
  -F image=@photo.jpg -F scale=2 -F model=x2plus -F face=face -F face_model=gfpgan \
  -H 'Accept: application/json' | python3 -c "import json,sys,base64; d=json.load(sys.stdin); open('out.jpg','wb').write(base64.b64decode(d['image_base64'])); print({k:v for k,v in d.items() if k!='image_base64'})"

# CPU-режим принудительно
docker compose stop photo-ai
PHOTO_AI_DEVICE=cpu docker compose -f docker-compose.yml up -d --build photo-ai

# GPU-режим
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build photo-ai

# Проверка обратной совместимости (без model/face — как раньше)
curl -s -X POST http://localhost:8080/enhance -F image=@photo.jpg -F scale=2 -o old_behavior.jpg

# Смоук
node api.smoketest.js