RAP_DocLayout_DetectionD / gradio_app.py
RISHIVEL's picture
space deploy: fix zerogpu pickling
087643a
Raw History Blame Contribute Delete
6.26 kB
# Gradio UI for Hugging Face Spaces - same Detector and reasoning pipeline
# as the FastAPI app and streamlit_app.py, just a different front end.
# HF's current Space creation flow only offers Gradio/Docker/Static as
# SDKs (no standalone Streamlit option), so this is the path that works
# without a paid plan.
from __future__ import annotations
import os
import gradio as gr
from dotenv import load_dotenv
load_dotenv()
from PIL import Image, ImageDraw
from app.constants import CLASS_NAMES, MODEL_VERSION
from app.detector import Detector
from app.reasoning.pipeline import answer_question
from scripts._render_utils import load_label_font
try:
# only meaningful on an actual HF ZeroGPU Space - it's what tells the
# platform to allocate a real GPU for the duration of the decorated
# call, then release it. A no-op import failure anywhere else (local
# runs, Docker) just means we fall back to whatever device torch
# picks on its own.
import spaces
_gpu_decorator = spaces.GPU
except ImportError:
def _gpu_decorator(fn):
return fn
PALETTE = [
"#e6194b", "#3cb44b", "#ffe119", "#4363d8", "#f58231", "#911eb4",
"#46f0f0", "#f032e6", "#bcf60c", "#fabebe", "#008080",
]
_detector: Detector | None = None
def get_detector() -> Detector:
# module-level singleton, same reasoning as main.py's get_detector -
# loads once per process, not per request. Left completely unmodified
# (no monkeypatching) - ZeroGPU's @spaces.GPU spawns a separate worker
# process for the actual GPU call, and everything crossing that
# boundary gets pickled. A bound method captured in a closure (my
# first two attempts) either can't be pickled at all, or - when it
# can - resolves to whatever the instance's current attribute value
# is on the other side, which is fragile either way. Passing only
# plain data (the image, a float) sidesteps the whole problem.
global _detector
if _detector is None:
_detector = Detector()
try:
_detector.load()
except FileNotFoundError:
pass # surfaced in the UI instead of crashing the app
return _detector
@_gpu_decorator
def _predict_on_gpu(image, conf: float = 0.25):
# The only thing decorated with @spaces.GPU, and it has to be exactly
# this: a real top-level function (ZeroGPU scans source at startup
# for one - dynamic decoration isn't detected), taking only plain,
# picklable arguments. It reaches the detector via the module-level
# getter rather than having one passed in.
return get_detector().predict(image, conf=conf)
class _GPUDetectorProxy:
"""Stands in for a Detector wherever one is expected (run_ask passes
this to the reasoning pipeline) but routes predict() through the
properly decorated module-level function above instead of calling
the real method directly - same reason as the getter's docstring."""
@property
def is_loaded(self) -> bool:
return get_detector().is_loaded
@property
def query_budget(self) -> int:
return get_detector().query_budget
def predict(self, image, conf: float = 0.25):
return _predict_on_gpu(image, conf=conf)
def draw_detections(image: Image.Image, detections) -> Image.Image:
annotated = image.copy()
draw = ImageDraw.Draw(annotated)
font = load_label_font(18)
for det in detections:
colour = PALETTE[det.class_id % len(PALETTE)]
box = det.bbox
draw.rectangle([box.x1, box.y1, box.x2, box.y2], outline=colour, width=3)
label = f"{det.class_name} {det.confidence:.2f}"
text_box = draw.textbbox((box.x1, box.y1), label, font=font)
draw.rectangle(
[text_box[0] - 2, text_box[1] - 2, text_box[2] + 2, text_box[3] + 2],
fill=colour,
)
draw.text((box.x1, box.y1), label, font=font, fill="white")
return annotated
def run_detect(image: Image.Image):
if not get_detector().is_loaded:
return None, "Model not loaded - check MODEL_PATH/MODEL_URL.", None
if image is None:
return None, "Upload an image first.", None
detections, inference_ms = _predict_on_gpu(image)
annotated = draw_detections(image, detections)
table = [[d.class_name, round(d.confidence, 3)] for d in sorted(detections, key=lambda d: -d.confidence)]
summary = f"{len(detections)} detections in {inference_ms:.1f} ms"
return annotated, summary, table
def run_ask(image: Image.Image, question: str):
if not get_detector().is_loaded:
return "Model not loaded - check MODEL_PATH/MODEL_URL.", {}
if image is None or not question:
return "Upload an image and enter a question.", {}
if not os.environ.get("GROQ_API_KEY"):
return "GROQ_API_KEY not set - the reasoning layer needs it.", {}
response = answer_question(image=image, question=question, detector=_GPUDetectorProxy())
return response.answer, response.reasoning_trace
with gr.Blocks(title="Document Layout Detection") as demo:
gr.Markdown(f"# Constrained Document Layout Detection\nRT-DETR fine-tuned on DocLayNet - `{MODEL_VERSION}`")
gr.Markdown(f"Classes: {', '.join(CLASS_NAMES)}")
with gr.Tab("Detect"):
with gr.Row():
detect_input = gr.Image(type="pil", label="Upload a document page")
detect_output = gr.Image(type="pil", label="Detections")
detect_button = gr.Button("Run detection")
detect_summary = gr.Textbox(label="Summary", interactive=False)
detect_table = gr.Dataframe(headers=["class", "confidence"], label="Detections")
detect_button.click(run_detect, inputs=detect_input, outputs=[detect_output, detect_summary, detect_table])
with gr.Tab("Ask"):
ask_input = gr.Image(type="pil", label="Upload a document page")
ask_question = gr.Textbox(label="Question", placeholder="How many tables are on this page?")
ask_button = gr.Button("Ask")
ask_answer = gr.Textbox(label="Answer", interactive=False)
ask_trace = gr.JSON(label="Reasoning trace")
ask_button.click(run_ask, inputs=[ask_input, ask_question], outputs=[ask_answer, ask_trace])
if __name__ == "__main__":
demo.launch()