Spaces:
Running on Zero
Running on Zero
| 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: | |
| 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") | |
| 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) | |
| 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 | |
| ) | |
| 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) | |