File size: 5,753 Bytes
93ef7ce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
"""
PixelForge AI — generative upscale engine (Hugging Face Space app).

Exposes:
  POST /upscale   — accepts raw image bytes (or multipart "image" file part),
                    returns upscaled image bytes. Query params: scale=2|4.
  GET  /health    — liveness probe; reports the selected engine (no secrets).

Engine selection (env, optional):
  UPSCALE_ENGINE  = "supir" (default, generative restoration) | "sdxl_tile"
                    (lighter generative fallback for small-GPU hardware)
  SPACE_ACCESS_TOKEN = if set, every /upscale request must send
                    `Authorization: Bearer <same value>`. The PixelForge site
                    provider sends its HF_TOKEN here — set this to the same
                    value to lock the endpoint down.

Designed for Hugging Face Spaces on the free ZeroGPU hardware tier (Docker
SDK). On ZeroGPU the GPU is attached per-request; this app wraps GPU work in
`gpu_context()` (the `spaces` package), so it also runs unchanged on a
dedicated GPU Space or a local GPU box.

NOTE: GPU inference cannot be exercised in the team sandbox (no GPU, ~4 GB
RAM). The model paths are written against the verified public APIs of SUPIR
(create_SUPIR_model / batchify_sample from the official codebase) and
diffusers (StableDiffusionXLControlNetPipeline); the README lists exactly what
still needs an HF account + first GPU request to confirm.
"""

from __future__ import annotations

import io
import logging
import os
import time
from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException, Request, UploadFile
from fastapi.responses import Response
from PIL import Image

from engines import UpscaleEngine, gpu_context
from engines.supir_engine import SupirEngine
from engines.sdxl_tile_engine import SdxlTileEngine

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
log = logging.getLogger("upscale.app")

ENGINE = os.environ.get("UPSCALE_ENGINE", "supir").strip().lower()
ACCESS_TOKEN = os.environ.get("SPACE_ACCESS_TOKEN", "").strip()
MAX_UPLOAD_BYTES = int(os.environ.get("MAX_UPLOAD_BYTES", str(25 * 1024 * 1024)))

_ENGINES: dict[str, UpscaleEngine] = {
    "supir": SupirEngine(),
    "sdxl_tile": SdxlTileEngine(),
}


def get_engine() -> UpscaleEngine:
    engine = _ENGINES.get(ENGINE)
    if engine is None:
        raise HTTPException(
            status_code=500,
            detail=f"unknown UPSCALE_ENGINE '{ENGINE}' (expected supir or sdxl_tile)",
        )
    return engine


def _check_auth(request: Request) -> None:
    """Optional bearer-token gate — used by the PixelForge site provider."""
    if not ACCESS_TOKEN:
        return
    header = request.headers.get("authorization", "")
    provided = header[7:].strip() if header.lower().startswith("bearer ") else ""
    import hmac

    if not provided or not hmac.compare_digest(provided, ACCESS_TOKEN):
        raise HTTPException(status_code=401, detail="unauthorized")


def _load_image(body: bytes) -> Image.Image:
    try:
        return Image.open(io.BytesIO(body))
    except Exception as exc:  # noqa: BLE001 — surface as 400, not 500
        raise HTTPException(status_code=400, detail=f"not a decodable image: {exc}") from exc


@asynccontextmanager
async def lifespan(_: FastAPI):
    log.info("PixelForge upscale Space starting (engine=%s, gpu=%s)", ENGINE, _gpu_available())
    # Warm-up is optional; SUPIR weights (~9.5 GB) download on first request
    # so the Space itself boots fast on ZeroGPU.
    if os.environ.get("PRELOAD", "0").strip().lower() in ("1", "true", "yes"):
        log.info("PRELOAD=1 — loading %s engine at startup", ENGINE)
        get_engine().load()
    yield
    log.info("PixelForge upscale Space stopped")


app = FastAPI(title="PixelForge AI — generative upscale engine", lifespan=lifespan)


def _gpu_available() -> bool:
    try:
        import torch

        return bool(torch.cuda.is_available())
    except Exception:
        return False


@app.get("/health")
def health():
    return {"status": "ok", "engine": ENGINE, "gpu": _gpu_available()}


@app.post("/upscale")
async def upscale(request: Request, scale: int = 4, file: UploadFile | None = None):
    _check_auth(request)
    if scale not in (2, 4):
        raise HTTPException(status_code=400, detail="scale must be 2 or 4")

    # Accept either a multipart file part named "image" or a raw image body.
    if file is not None:
        body = await file.read()
    else:
        content_type = request.headers.get("content-type", "").lower()
        if not (content_type.startswith("image/") or content_type.startswith("application/octet-stream")):
            raise HTTPException(
                status_code=415,
                detail="send raw image bytes (image/*) or multipart with an 'image' file part",
            )
        body = await request.body()

    if not body:
        raise HTTPException(status_code=400, detail="empty body")
    if len(body) > MAX_UPLOAD_BYTES:
        raise HTTPException(status_code=413, detail=f"image too large (max {MAX_UPLOAD_BYTES} bytes)")

    engine = get_engine()
    img = _load_image(body)

    t0 = time.time()
    log.info("upscale start: engine=%s scale=%s input=%s", engine.name(), scale, img.size)
    with gpu_context():
        out = engine.upscale(img, scale)
    log.info("upscale done: %s -> %s in %.1fs", img.size, out.size, time.time() - t0)

    buf = io.BytesIO()
    out.save(buf, format="PNG")
    return Response(
        content=buf.getvalue(),
        media_type="image/png",
        headers={"X-Upscale-Engine": engine.name()},
    )

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "7860")))