File size: 5,354 Bytes
8012660
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28869d7
 
 
 
 
 
8012660
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c5c50a9
 
 
 
 
8012660
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da80e3b
 
 
 
 
 
 
 
 
 
8012660
186f80a
2ca4df9
186f80a
 
 
2ca4df9
186f80a
07a34ee
 
 
 
 
 
2bb162a
 
 
 
 
07a34ee
 
2ca4df9
186f80a
 
 
 
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
from __future__ import annotations

import asyncio
import io
import os
import secrets
import threading
from contextlib import asynccontextmanager

from fastapi import FastAPI, File, Header, HTTPException, UploadFile
from PIL import Image, ImageOps, UnidentifiedImageError

from detectors import Detector, load_detectors

try:
    import spaces
except ImportError:  # Docker and ordinary CPU/GPU hosts do not need the Spaces runtime.
    spaces = None

if spaces is not None:
    @spaces.GPU(duration=1)
    def zerogpu_runtime_probe() -> bool:
        """Declares ZeroGPU compatibility; authenticated detector inference remains on CPU."""
        return True

MAX_IMAGE_BYTES = int(os.getenv("MAX_IMAGE_BYTES", str(15 * 1024 * 1024)))
Image.MAX_IMAGE_PIXELS = int(os.getenv("MAX_IMAGE_PIXELS", "60000000"))

device = "uninitialized"
detectors: list[Detector] = []
load_errors: dict[str, str] = {}
inference_lock = threading.Lock()


def authorize(authorization: str | None) -> None:
    expected = os.getenv("DETECTOR_AUTH_TOKEN", "")
    supplied = (authorization or "").removeprefix("Bearer ").strip()
    if not expected or not secrets.compare_digest(supplied, expected):
        raise HTTPException(status_code=401, detail="Unauthorized")


@asynccontextmanager
async def lifespan(_: FastAPI):
    global device, detectors, load_errors
    device, detectors, load_errors = await asyncio.to_thread(load_detectors)
    yield


app = FastAPI(title="Echo self-hosted AI image detector", version="1.0.0", lifespan=lifespan)


@app.get("/health")
async def health(authorization: str | None = Header(default=None)):
    authorize(authorization)
    ready = {detector.key: {"ready": True, "version": detector.version} for detector in detectors}
    for key, error in load_errors.items():
        ready[key] = {"ready": False, "error": error}
    return {
        "ok": len(detectors) == 2,
        "service_version": app.version,
        "device": device,
        "models": ready,
        "privacy": "public-image-bytes-only",
    }


def _run_inference(image: Image.Image) -> list[dict[str, object]]:
    with inference_lock:
        return [detector.predict(image).as_dict() for detector in detectors]


run_inference = (
    spaces.GPU(duration=120)(_run_inference)
    if spaces is not None and os.getenv("USE_ZEROGPU", "false").lower() == "true"
    else _run_inference
)


@app.post("/v1/detect")
async def detect(
    file: UploadFile = File(...),
    authorization: str | None = Header(default=None),
):
    authorize(authorization)
    if len(detectors) != 2:
        raise HTTPException(status_code=503, detail={"message": "Both detectors must be healthy", "models": load_errors})
    payload = await file.read(MAX_IMAGE_BYTES + 1)
    if not payload or len(payload) > MAX_IMAGE_BYTES:
        raise HTTPException(status_code=413, detail="Image exceeds configured size limit")
    try:
        image = Image.open(io.BytesIO(payload))
        image.verify()
        image = Image.open(io.BytesIO(payload)).convert("RGB")
        image = ImageOps.exif_transpose(image)
    except (UnidentifiedImageError, OSError, ValueError) as exc:
        raise HTTPException(status_code=422, detail="Unsupported or malformed image") from exc
    results = await asyncio.to_thread(run_inference, image)
    return {
        "service_version": app.version,
        "device": device,
        "models": results,
        "policy": {"minimum_models": 2, "service_combines_scores": False},
    }


if spaces is not None:
    import gradio as gr

    with gr.Blocks(title="Echo detector runtime") as platform_status:
        gr.Markdown("# Echo AI image detector\nAuthenticated provenance service. Inference endpoints are not exposed in this panel.")
        platform_output = gr.Textbox(label="Runtime", interactive=False)
        platform_probe = gr.Button("Check ZeroGPU runtime")
        platform_probe.click(zerogpu_runtime_probe, outputs=platform_output)


if __name__ == "__main__":
    if spaces is not None:
        device, detectors, load_errors = load_detectors()
        platform_status.launch(
            server_name="0.0.0.0",
            server_port=int(os.getenv("PORT", "7860")),
            prevent_thread_lock=True,
        )
        # Gradio installs a root catch-all route. Registering API routes normally
        # appends them after it, so the SPA would answer every API request with
        # HTML. Move Echo's routes to the front while keeping native Gradio
        # startup (required for free ZeroGPU Spaces).
        server_app = platform_status.server_app
        existing_route_count = len(server_app.router.routes)
        # A ZeroGPU Space's Node frontend forwards this prefix to Gradio's
        # Python server. Keep the Docker deployment's routes at the root while
        # exposing equivalent Space routes through the forwarded API prefix.
        server_app.add_api_route("/gradio_api/health", health, methods=["GET"])
        server_app.add_api_route("/gradio_api/v1/detect", detect, methods=["POST"])
        echo_routes = server_app.router.routes[existing_route_count:]
        server_app.router.routes = echo_routes + server_app.router.routes[:existing_route_count]
        platform_status.block_thread()
    else:
        import uvicorn

        uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "7860")), workers=1)