Spaces:
Running on Zero
Running on Zero
Download app/main.py from RISHIVEL/RAP_DocLayout_DetectionD: direct link, hf CLI and curl.
- Browser
- Download file 5.27 kB
-
https://huggingface.co/spaces/RISHIVEL/RAP_DocLayout_DetectionD/resolve/main/app/main.py
- Command line
-
hf download hf://spaces/RISHIVEL/RAP_DocLayout_DetectionD/app/main.py
-
curl -L -o main.py https://huggingface.co/spaces/RISHIVEL/RAP_DocLayout_DetectionD/resolve/main/app/main.py
5.27 kB
| # FastAPI app: /health, /detect, /ask. Kept thin on purpose - upload | |
| # validation, error mapping, logging, then hand off to Detector or the | |
| # reasoning pipeline, both already tested elsewhere. | |
| from __future__ import annotations | |
| import os | |
| from contextlib import asynccontextmanager | |
| # loads .env if one exists (local dev) - does nothing if it doesn't (Docker, | |
| # HF Spaces, Kaggle all set real env vars/secrets instead) | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile | |
| from fastapi.responses import JSONResponse | |
| from PIL import Image, UnidentifiedImageError | |
| from app.constants import MODEL_VERSION | |
| from app.detector import Detector | |
| from app.logging_config import configure_logging, log_request | |
| from app.reasoning.pipeline import answer_question | |
| from app.schemas import DetectResponse | |
| logger = configure_logging() | |
| # Read once at import, not per-request. Tests override this directly | |
| # rather than faking an env var. | |
| MAX_UPLOAD_BYTES = int(os.environ.get("MAX_UPLOAD_MB", "10")) * 1024 * 1024 | |
| _detector_instance: Detector | None = None | |
| def get_detector() -> Detector: | |
| """FastAPI dependency - same Detector instance for every route. | |
| Not a bare global so tests can override it with a stub.""" | |
| global _detector_instance | |
| if _detector_instance is None: | |
| _detector_instance = Detector() | |
| return _detector_instance | |
| async def lifespan(app: FastAPI): | |
| """Tries to load weights at startup but doesn't crash if that fails - | |
| a service that's up and reporting "model not loaded" is way easier to | |
| debug than one that won't start at all.""" | |
| try: | |
| get_detector().load() | |
| logger.info("startup: model loaded", extra={"extra_fields": {}}) | |
| except Exception as error: | |
| logger.error( | |
| "startup: model failed to load, API will report 503 on inference routes", | |
| extra={"extra_fields": {"error": str(error)}}, | |
| ) | |
| yield | |
| app = FastAPI(title="Constrained Document Layout Detection API", lifespan=lifespan) | |
| async def handle_unexpected_error(request: Request, exc: Exception): | |
| """Catches anything a route didn't handle and returns a safe generic | |
| message instead of leaking a stack trace. Full error still goes to logs.""" | |
| logger.error( | |
| "unhandled exception", | |
| extra={"extra_fields": {"path": str(request.url), "error": str(exc)}}, | |
| ) | |
| return JSONResponse( | |
| status_code=500, | |
| content={"detail": "Internal server error. See server logs for details."}, | |
| ) | |
| def _read_and_validate_image(contents: bytes, content_type: str | None) -> Image.Image: | |
| """Size check, then content-type check, then an actual decode attempt - | |
| a file can claim image/png and still be garbage.""" | |
| if len(contents) > MAX_UPLOAD_BYTES: | |
| raise HTTPException( | |
| status_code=413, | |
| detail=f"Upload exceeds the {MAX_UPLOAD_BYTES // (1024 * 1024)} MB limit.", | |
| ) | |
| if content_type is None or not content_type.startswith("image/"): | |
| raise HTTPException( | |
| status_code=415, | |
| detail=f"Expected an image upload, got content-type '{content_type}'.", | |
| ) | |
| try: | |
| import io | |
| return Image.open(io.BytesIO(contents)).convert("RGB") | |
| except UnidentifiedImageError: | |
| raise HTTPException(status_code=415, detail="Could not decode the upload as an image.") | |
| def health(detector: Detector = Depends(get_detector)): | |
| return {"status": "ok", "model_loaded": detector.is_loaded, "model_version": MODEL_VERSION} | |
| async def detect(file: UploadFile = File(...), detector: Detector = Depends(get_detector)): | |
| with log_request(logger, "/detect") as ctx: | |
| if not detector.is_loaded: | |
| raise HTTPException( | |
| status_code=503, | |
| detail="Model is not loaded. Check MODEL_PATH and server startup logs.", | |
| ) | |
| contents = await file.read() | |
| image = _read_and_validate_image(contents, file.content_type) | |
| detections, inference_ms = detector.predict(image) | |
| ctx["detection_count"] = len(detections) | |
| return DetectResponse( | |
| detections=detections, | |
| image_size={"width": image.width, "height": image.height}, | |
| inference_ms=round(inference_ms, 2), | |
| model_version=MODEL_VERSION, | |
| ) | |
| async def ask( | |
| file: UploadFile = File(...), | |
| question: str = Form(..., min_length=1, max_length=500), | |
| detector: Detector = Depends(get_detector), | |
| ): | |
| """Part B. All the actual routing/evidence/guardrail/synthesis logic | |
| lives in app.reasoning.pipeline - this is just upload validation + wiring.""" | |
| with log_request(logger, "/ask", question_length=len(question)) as ctx: | |
| contents = await file.read() | |
| image = _read_and_validate_image(contents, file.content_type) | |
| response = answer_question(image=image, question=question, detector=detector) | |
| ctx["used_detection"] = response.used_detection | |
| ctx["insufficient_information"] = response.insufficient_information | |
| return response | |