sebbe60's picture
Update app.py
2bb162a verified
Raw
History Blame Contribute Delete
5.35 kB
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)