File size: 6,260 Bytes
087643a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# 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()