diff --git a/.env.example b/.env.example index 6205191..ed645dd 100644 --- a/.env.example +++ b/.env.example @@ -26,4 +26,8 @@ CLOUDFLARE_TUNNEL_TOKEN= CLOUDFLARE_TUNNEL_URL=http://app:3003 # Максимальное ожидание WireGuard handshake перед fallback на прямой запуск # туннеля без VPN (сек). Если VPN-провайдер не отвечает — сайт всё равно поднимется. -WG_HANDSHAKE_TIMEOUT=60 \ No newline at end of file +WG_HANDSHAKE_TIMEOUT=60 + +# ИИ-улучшение фото (Real-ESRGAN), пусто = контейнер photo-ai +PHOTO_AI_URL= +PHOTO_AI_MAX_PIXELS=4000000 diff --git a/db/init.sql b/db/init.sql index dbe8511..a5d614c 100644 --- a/db/init.sql +++ b/db/init.sql @@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS entries ( ai_checked_at TIMESTAMPTZ, ai_error TEXT, photo_path VARCHAR(255), + photo_original_path VARCHAR(255), deleted_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now() ); diff --git a/db/migration.sql b/db/migration.sql index 003efce..4fe8bd9 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -199,3 +199,5 @@ $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS trg_entries_notify ON entries; CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries FOR EACH ROW EXECUTE FUNCTION notify_entries_changed(); + +ALTER TABLE entries ADD COLUMN IF NOT EXISTS photo_original_path VARCHAR(255); diff --git a/docker-compose.yml b/docker-compose.yml index 9e202cf..c129c2e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,7 @@ services: AI_MODEL: ${AI_MODEL:-qwen2.5-1.5b-instruct-q4_k_m.gguf} AI_PROMPT: ${AI_PROMPT:-} AI_REQUEST_TIMEOUT_MS: ${AI_REQUEST_TIMEOUT_MS:-120000} + PHOTO_AI_URL: ${PHOTO_AI_URL:-http://photo-ai:8080} NODE_ENV: production TZ: Europe/Moscow depends_on: @@ -113,7 +114,20 @@ services: - "8080:8080" + # ИИ-улучшение фотографий (Real-ESRGAN x2, CPU). + # Поднимается вместе со стеком; если не нужен — PHOTO_AI_URL пустой в .env. + photo-ai: + build: ./photo-ai + container_name: photo-ai + restart: unless-stopped + environment: + MODEL_PATH: /models/RealESRGAN_x2plus.pth + MAX_INPUT_PIXELS: ${PHOTO_AI_MAX_PIXELS:-4000000} + TZ: Europe/Moscow + volumes: + - photo-ai-models:/models volumes: pgdata: + photo-ai-models: diff --git a/photo-ai/Dockerfile b/photo-ai/Dockerfile new file mode 100644 index 0000000..168748b --- /dev/null +++ b/photo-ai/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.10-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu + +RUN pip install --no-cache-dir realesrgan==0.3.0 fastapi "uvicorn[standard]" python-multipart opencv-python-headless numpy + +RUN python - <<'EOF' +import basicsr, os +p = os.path.join(os.path.dirname(basicsr.__file__), 'data', 'degradations.py') +s = open(p).read() +s = s.replace('torchvision.transforms.functional_tensor', 'torchvision.transforms.functional') +open(p, 'w').write(s) +print('basicsr patch applied') +EOF + +COPY app.py . + +ENV MODEL_PATH=/models/RealESRGAN_x2plus.pth + +VOLUME /models + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/photo-ai/app.py b/photo-ai/app.py new file mode 100644 index 0000000..25399ab --- /dev/null +++ b/photo-ai/app.py @@ -0,0 +1,73 @@ +import io +import os +import asyncio +import threading +import urllib.request + +from fastapi import FastAPI, File, Form, UploadFile +from fastapi.responses import Response +import numpy as np +import cv2 +import torch +from basicsr.archs.rrdbnet_arch import RRDBNet +from realesrgan import RealESRGANer + +MODEL_PATH = os.environ.get('MODEL_PATH', '/models/RealESRGAN_x2plus.pth') +MODEL_URL = 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth' +MAX_PIXELS = int(os.environ.get('MAX_INPUT_PIXELS', str(4_000_000))) + +lock = threading.Lock() +upsampler = None +app = FastAPI() + + +def ensure_model(): + if not os.path.exists(MODEL_PATH): + os.makedirs(os.path.dirname(MODEL_PATH), exist_ok=True) + tmp = MODEL_PATH + '.tmp' + urllib.request.urlretrieve(MODEL_URL, tmp) + os.replace(tmp, MODEL_PATH) + + +def load_model(): + global upsampler + ensure_model() + model = RRDBNet(num_in_ch=3, num_out_ch=3, scale=2, num_feat=64, num_block=23, num_grow_ch=32) + upsampler = RealESRGANer( + scale=2, + model_path=MODEL_PATH, + model=model, + tile=256, + tile_pad=10, + pre_pad=0, + half=False, + device='cpu', + ) + + +@app.on_event('startup') +async def startup(): + await asyncio.to_thread(load_model) + + +@app.get('/health') +def health(): + return {'ok': upsampler is not None} + + +@app.post('/enhance') +async def enhance(image: UploadFile = File(...), scale: int = Form(2)): + data = await image.read() + img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR) + if img is None: + return Response('bad image', status_code=400) + if img.shape[0] * img.shape[1] > MAX_PIXELS: + r = (MAX_PIXELS / (img.shape[0] * img.shape[1])) ** 0.5 + img = cv2.resize(img, (int(img.shape[1] * r), int(img.shape[0] * r)), interpolation=cv2.INTER_AREA) + outscale = min(max(int(scale), 2), 4) + with lock: + out, _ = upsampler.enhance(img, outscale=outscale) + ok, enc = cv2.imencode('.jpg', out, [int(cv2.IMWRITE_JPEG_QUALITY), 92]) + if not ok: + return Response('encode failed', status_code=500) + return Response(enc.tobytes(), media_type='image/jpeg') diff --git a/public/journal.html b/public/journal.html index 13335c4..0edcab9 100644 --- a/public/journal.html +++ b/public/journal.html @@ -187,6 +187,8 @@