RISHIVEL commited on
Commit
087643a
·
0 Parent(s):

space deploy: fix zerogpu pickling

Browse files
.dockerignore ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Keeps the build context small and the image free of anything that
2
+ # shouldn't be baked in - datasets, checkpoints, and secrets especially.
3
+ .git/
4
+ .pytest_cache/
5
+ __pycache__/
6
+ *.pyc
7
+ .env
8
+ data/
9
+ runs/
10
+ reports/
11
+ notebooks/
12
+ docs/
13
+ tests/
14
+ memo/
15
+ *.ipynb
16
+ .venv/
17
+ venv/
.env.example ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # The reasoning layer in app/reasoning/ calls Groq directly through their
2
+ # plain SDK. No agentic framework is involved - see requirements.txt and
3
+ # the compliance note in the README.
4
+ GROQ_API_KEY=gsk_your_key_here
5
+
6
+ # Two models, because the two calls are doing different jobs.
7
+ #
8
+ # The router is a classification problem with a fixed output schema, so I
9
+ # want the small fast model and I pin the output with strict json_schema.
10
+ # Synthesis is open-ended writing, so the larger model earns its latency.
11
+ # Both support Groq's strict structured outputs (constrained decoding).
12
+ GROQ_ROUTER_MODEL=openai/gpt-oss-20b
13
+ GROQ_SYNTHESIS_MODEL=openai/gpt-oss-120b
14
+
15
+ # Where the fine-tuned RT-DETR weights live. See README for the download link.
16
+ MODEL_PATH=./weights/best.pt
17
+
18
+ # Optional: if MODEL_PATH doesn't exist, download it from here first. Set
19
+ # this on a fresh Space/container instead of manually copying the weights
20
+ # file in every time.
21
+ # MODEL_URL=https://github.com/RISHIVELS/doc_layout_detection/releases/download/weights-v1/best.pt
22
+
23
+ # Reject uploads larger than this. Document scans are rarely above a few MB,
24
+ # so this is generous and still stops someone posting a 2 GB file at the API.
25
+ MAX_UPLOAD_MB=10
.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Datasets and weights are far too large for git. The README documents
2
+ # exactly how to regenerate the dataset and where to download the weights.
3
+ data/
4
+ runs/
5
+ *.pt
6
+ *.pth
7
+ reports/failures/
8
+ reports/category_splits/
9
+
10
+ # Secrets
11
+ .env
12
+
13
+ # Python
14
+ __pycache__/
15
+ *.py[cod]
16
+ .venv/
17
+ venv/
18
+ .pytest_cache/
19
+ *.egg-info/
20
+
21
+ # OS / editor
22
+ .DS_Store
23
+ Thumbs.db
24
+ .ipynb_checkpoints/
Dockerfile ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CPU inference by default - a forward pass doesn't need a GPU, and this
2
+ # way the image runs anywhere without CUDA drivers.
3
+ FROM python:3.11-slim
4
+
5
+ WORKDIR /app
6
+
7
+ # libgl/libglib for Pillow + matplotlib's font rendering (used for
8
+ # legible label renders in scripts/_render_utils.py)
9
+ RUN apt-get update && apt-get install -y --no-install-recommends \
10
+ libglib2.0-0 \
11
+ libgl1 \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ # requirements first so this layer only rebuilds when deps actually change
15
+ COPY requirements.txt .
16
+
17
+ # CPU-only torch wheel - the default PyPI package pulls the full CUDA
18
+ # toolkit (multiple GB) for an image that never touches a GPU
19
+ RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu \
20
+ && pip install --no-cache-dir -r requirements.txt
21
+
22
+ COPY app/ app/
23
+ COPY scripts/_render_utils.py scripts/__init__.py scripts/
24
+
25
+ # Weights aren't baked into the image (too large for git, see README for
26
+ # the download link) - mounted or fetched at container start instead
27
+ ENV MODEL_PATH=/app/weights/best.pt
28
+ ENV MAX_UPLOAD_MB=10
29
+
30
+ RUN useradd --create-home appuser && mkdir -p /app/weights && chown -R appuser:appuser /app
31
+ USER appuser
32
+
33
+ EXPOSE 8000
34
+
35
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
36
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=3)" || exit 1
37
+
38
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
README.md ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Document Layout Detection
3
+ emoji: 📄
4
+ colorFrom: blue
5
+ colorTo: gray
6
+ sdk: gradio
7
+ app_file: gradio_app.py
8
+ pinned: false
9
+ ---
10
+
11
+ # Constrained Document Layout Detection
12
+
13
+ RT-DETR fine-tuned on DocLayNet, plus a hand-written reasoning layer for
14
+ natural-language questions about a page's layout.
15
+
16
+ Full source, training scripts, and the FastAPI API this demo shares its
17
+ code with: see the main repo.
18
+
19
+ ## Space secrets needed
20
+
21
+ - `GROQ_API_KEY` - for the Ask tab's reasoning layer
22
+ - `MODEL_URL` - direct download link for the trained weights (e.g. a
23
+ GitHub Release asset). Downloaded automatically on first run if
24
+ `MODEL_PATH` doesn't already exist - no need to push the weights file
25
+ into the Space's git repo.
app/__init__.py ADDED
File without changes
app/constants.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Single source of truth for the class list. Had it duplicated across the
2
+ # dataset script, training yaml and API code early on and they drifted -
3
+ # no error, just silently wrong labels. Everything imports from here now.
4
+ from __future__ import annotations
5
+
6
+ # DocLayNet's 0-indexed alphabetical order. Verified against rendered pages
7
+ # (see prepare_dataset.py --verify), don't trust the dataset card blindly.
8
+ # Don't reorder - these ids are baked into the label files and the weights.
9
+ CLASS_NAMES: list[str] = [
10
+ "Caption",
11
+ "Footnote",
12
+ "Formula",
13
+ "List-item",
14
+ "Page-footer",
15
+ "Page-header",
16
+ "Picture",
17
+ "Section-header",
18
+ "Table",
19
+ "Text",
20
+ "Title",
21
+ ]
22
+
23
+ CLASS_TO_ID: dict[str, int] = {name: idx for idx, name in enumerate(CLASS_NAMES)}
24
+ ID_TO_CLASS: dict[int, str] = {idx: name for name, idx in CLASS_TO_ID.items()}
25
+
26
+ NUM_CLASSES: int = len(CLASS_NAMES)
27
+
28
+ MODEL_VERSION: str = "rtdetr-l-doclaynet-v1"
29
+
30
+ # The 6 doc types DocLayNet covers. Used to break mAP down per category at
31
+ # eval time - one aggregate number can hide a model that's just good at
32
+ # financial reports (the biggest slice) and bad at everything else.
33
+ DOC_CATEGORIES: list[str] = [
34
+ "financial_reports",
35
+ "scientific_articles",
36
+ "laws_and_regulations",
37
+ "government_tenders",
38
+ "manuals",
39
+ "patents",
40
+ ]
41
+
42
+ # RT-DETR has a fixed number of object queries, so it can only ever emit
43
+ # this many boxes per image. Dense pages can exceed it - measured in eval
44
+ # so that shows up as "query saturation", not unexplained recall loss.
45
+ DEFAULT_QUERY_BUDGET: int = 300
app/detector.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Thin wrapper around Ultralytics so nothing else in the app imports it
2
+ # directly - lets me stub this out in tests without a GPU or real weights.
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from app.constants import DEFAULT_QUERY_BUDGET, ID_TO_CLASS
10
+ from app.schemas import BBox, Detection
11
+
12
+
13
+ class Detector:
14
+ """Loads RT-DETR on first use, converts raw output to Detection objects."""
15
+
16
+ def __init__(self, weights_path: str | None = None):
17
+ self._weights_path = weights_path or os.environ.get("MODEL_PATH", "./weights/best.pt")
18
+ self._model = None
19
+ self.query_budget = DEFAULT_QUERY_BUDGET
20
+
21
+ @property
22
+ def is_loaded(self) -> bool:
23
+ return self._model is not None
24
+
25
+ def load(self) -> None:
26
+ """Lazy load - keeps a bad MODEL_PATH from crashing the whole app
27
+ at startup. Checks the file exists first so the error is readable
28
+ instead of a torch.load stack trace.
29
+
30
+ If the weights aren't on disk but MODEL_URL is set, downloads them
31
+ first - this is what lets a fresh HF Space or Docker container just
32
+ work without manually copying a 66MB file into every host."""
33
+ if self.is_loaded:
34
+ return
35
+
36
+ path = Path(self._weights_path)
37
+ if not path.exists():
38
+ model_url = os.environ.get("MODEL_URL")
39
+ if model_url:
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ import urllib.request
42
+
43
+ urllib.request.urlretrieve(model_url, path)
44
+ else:
45
+ raise FileNotFoundError(
46
+ f"Model weights not found at '{self._weights_path}'. "
47
+ "Set MODEL_PATH to an existing file, or set MODEL_URL to "
48
+ "download from - see the README for the weights link."
49
+ )
50
+
51
+ from ultralytics import RTDETR
52
+
53
+ self._model = RTDETR(self._weights_path)
54
+
55
+ def predict(self, image, conf: float = 0.25) -> tuple[list[Detection], float]:
56
+ """Runs inference, returns (detections, inference_ms). Timing is
57
+ measured here, not in the route handler, so it's not conflated with
58
+ request/network overhead.
59
+
60
+ conf=0.25 is intentionally permissive - the guardrail decides what
61
+ counts as confident enough, not this default."""
62
+ if not self.is_loaded:
63
+ self.load()
64
+
65
+ started = time.perf_counter()
66
+ results = self._model.predict(image, conf=conf, verbose=False)[0]
67
+ elapsed_ms = (time.perf_counter() - started) * 1000
68
+
69
+ detections = [
70
+ Detection(
71
+ class_name=ID_TO_CLASS.get(int(class_id), f"unknown_{int(class_id)}"),
72
+ class_id=int(class_id),
73
+ confidence=float(confidence),
74
+ bbox=BBox(x1=float(x1), y1=float(y1), x2=float(x2), y2=float(y2)),
75
+ )
76
+ for class_id, confidence, (x1, y1, x2, y2) in zip(
77
+ results.boxes.cls.tolist(),
78
+ results.boxes.conf.tolist(),
79
+ results.boxes.xyxy.tolist(),
80
+ )
81
+ ]
82
+
83
+ return detections, elapsed_ms
app/logging_config.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Structured JSON logs, one line per request - lets me grep/jq for stuff
2
+ # later ("how many /ask requests hit the guardrail today") instead of
3
+ # re-running things to find out.
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import logging
8
+ import sys
9
+ import time
10
+ import uuid
11
+ from contextlib import contextmanager
12
+
13
+
14
+ class _JsonFormatter(logging.Formatter):
15
+ def format(self, record: logging.LogRecord) -> str:
16
+ payload = {
17
+ "timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
18
+ "level": record.levelname,
19
+ "message": record.getMessage(),
20
+ }
21
+ # extra={...} fields ride along here instead of needing a custom
22
+ # formatter per call site
23
+ for key, value in getattr(record, "extra_fields", {}).items():
24
+ payload[key] = value
25
+ return json.dumps(payload)
26
+
27
+
28
+ def configure_logging() -> logging.Logger:
29
+ logger = logging.getLogger("rap_doclayout")
30
+ if logger.handlers:
31
+ # avoid double handlers on repeated calls (app reload, test imports)
32
+ return logger
33
+
34
+ handler = logging.StreamHandler(sys.stdout)
35
+ handler.setFormatter(_JsonFormatter())
36
+ logger.addHandler(handler)
37
+ logger.setLevel(logging.INFO)
38
+ return logger
39
+
40
+
41
+ @contextmanager
42
+ def log_request(logger: logging.Logger, endpoint: str, **fields):
43
+ """Logs one line per request with latency, success or failure either
44
+ way.
45
+
46
+ Usage:
47
+ with log_request(logger, "/detect") as ctx:
48
+ ...
49
+ ctx["detection_count"] = len(detections)
50
+ """
51
+ request_id = uuid.uuid4().hex[:12]
52
+ started = time.perf_counter()
53
+ ctx = {"request_id": request_id, "endpoint": endpoint, **fields}
54
+ error: Exception | None = None
55
+ try:
56
+ yield ctx
57
+ except Exception as exc:
58
+ error = exc
59
+ raise
60
+ finally:
61
+ ctx["latency_ms"] = round((time.perf_counter() - started) * 1000, 2)
62
+ if error is not None:
63
+ ctx["error"] = str(error)
64
+ logger.error("request failed", extra={"extra_fields": ctx})
65
+ else:
66
+ logger.info("request completed", extra={"extra_fields": ctx})
app/main.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FastAPI app: /health, /detect, /ask. Kept thin on purpose - upload
2
+ # validation, error mapping, logging, then hand off to Detector or the
3
+ # reasoning pipeline, both already tested elsewhere.
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from contextlib import asynccontextmanager
8
+
9
+ # loads .env if one exists (local dev) - does nothing if it doesn't (Docker,
10
+ # HF Spaces, Kaggle all set real env vars/secrets instead)
11
+ from dotenv import load_dotenv
12
+
13
+ load_dotenv()
14
+
15
+ from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
16
+ from fastapi.responses import JSONResponse
17
+ from PIL import Image, UnidentifiedImageError
18
+
19
+ from app.constants import MODEL_VERSION
20
+ from app.detector import Detector
21
+ from app.logging_config import configure_logging, log_request
22
+ from app.reasoning.pipeline import answer_question
23
+ from app.schemas import DetectResponse
24
+
25
+ logger = configure_logging()
26
+
27
+ # Read once at import, not per-request. Tests override this directly
28
+ # rather than faking an env var.
29
+ MAX_UPLOAD_BYTES = int(os.environ.get("MAX_UPLOAD_MB", "10")) * 1024 * 1024
30
+
31
+ _detector_instance: Detector | None = None
32
+
33
+
34
+ def get_detector() -> Detector:
35
+ """FastAPI dependency - same Detector instance for every route.
36
+ Not a bare global so tests can override it with a stub."""
37
+ global _detector_instance
38
+ if _detector_instance is None:
39
+ _detector_instance = Detector()
40
+ return _detector_instance
41
+
42
+
43
+ @asynccontextmanager
44
+ async def lifespan(app: FastAPI):
45
+ """Tries to load weights at startup but doesn't crash if that fails -
46
+ a service that's up and reporting "model not loaded" is way easier to
47
+ debug than one that won't start at all."""
48
+ try:
49
+ get_detector().load()
50
+ logger.info("startup: model loaded", extra={"extra_fields": {}})
51
+ except Exception as error:
52
+ logger.error(
53
+ "startup: model failed to load, API will report 503 on inference routes",
54
+ extra={"extra_fields": {"error": str(error)}},
55
+ )
56
+ yield
57
+
58
+
59
+ app = FastAPI(title="Constrained Document Layout Detection API", lifespan=lifespan)
60
+
61
+
62
+ @app.exception_handler(Exception)
63
+ async def handle_unexpected_error(request: Request, exc: Exception):
64
+ """Catches anything a route didn't handle and returns a safe generic
65
+ message instead of leaking a stack trace. Full error still goes to logs."""
66
+ logger.error(
67
+ "unhandled exception",
68
+ extra={"extra_fields": {"path": str(request.url), "error": str(exc)}},
69
+ )
70
+ return JSONResponse(
71
+ status_code=500,
72
+ content={"detail": "Internal server error. See server logs for details."},
73
+ )
74
+
75
+
76
+ def _read_and_validate_image(contents: bytes, content_type: str | None) -> Image.Image:
77
+ """Size check, then content-type check, then an actual decode attempt -
78
+ a file can claim image/png and still be garbage."""
79
+ if len(contents) > MAX_UPLOAD_BYTES:
80
+ raise HTTPException(
81
+ status_code=413,
82
+ detail=f"Upload exceeds the {MAX_UPLOAD_BYTES // (1024 * 1024)} MB limit.",
83
+ )
84
+
85
+ if content_type is None or not content_type.startswith("image/"):
86
+ raise HTTPException(
87
+ status_code=415,
88
+ detail=f"Expected an image upload, got content-type '{content_type}'.",
89
+ )
90
+
91
+ try:
92
+ import io
93
+
94
+ return Image.open(io.BytesIO(contents)).convert("RGB")
95
+ except UnidentifiedImageError:
96
+ raise HTTPException(status_code=415, detail="Could not decode the upload as an image.")
97
+
98
+
99
+ @app.get("/health")
100
+ def health(detector: Detector = Depends(get_detector)):
101
+ return {"status": "ok", "model_loaded": detector.is_loaded, "model_version": MODEL_VERSION}
102
+
103
+
104
+ @app.post("/detect", response_model=DetectResponse)
105
+ async def detect(file: UploadFile = File(...), detector: Detector = Depends(get_detector)):
106
+ with log_request(logger, "/detect") as ctx:
107
+ if not detector.is_loaded:
108
+ raise HTTPException(
109
+ status_code=503,
110
+ detail="Model is not loaded. Check MODEL_PATH and server startup logs.",
111
+ )
112
+
113
+ contents = await file.read()
114
+ image = _read_and_validate_image(contents, file.content_type)
115
+
116
+ detections, inference_ms = detector.predict(image)
117
+ ctx["detection_count"] = len(detections)
118
+
119
+ return DetectResponse(
120
+ detections=detections,
121
+ image_size={"width": image.width, "height": image.height},
122
+ inference_ms=round(inference_ms, 2),
123
+ model_version=MODEL_VERSION,
124
+ )
125
+
126
+
127
+ @app.post("/ask")
128
+ async def ask(
129
+ file: UploadFile = File(...),
130
+ question: str = Form(..., min_length=1, max_length=500),
131
+ detector: Detector = Depends(get_detector),
132
+ ):
133
+ """Part B. All the actual routing/evidence/guardrail/synthesis logic
134
+ lives in app.reasoning.pipeline - this is just upload validation + wiring."""
135
+ with log_request(logger, "/ask", question_length=len(question)) as ctx:
136
+ contents = await file.read()
137
+ image = _read_and_validate_image(contents, file.content_type)
138
+
139
+ response = answer_question(image=image, question=question, detector=detector)
140
+ ctx["used_detection"] = response.used_detection
141
+ ctx["insufficient_information"] = response.insufficient_information
142
+
143
+ return response
app/reasoning/__init__.py ADDED
File without changes
app/reasoning/evidence.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Detections -> structured summary. Pure functions only, no I/O, no model
2
+ # calls, nothing that touches pixels - this is what makes it true that the
3
+ # LLM downstream can't hallucinate what's on the page: it never sees it,
4
+ # only this summary.
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+
9
+ from app.schemas import Detection
10
+
11
+
12
+ @dataclass
13
+ class ConfStats:
14
+ max: float
15
+ mean: float
16
+ min: float
17
+
18
+
19
+ @dataclass
20
+ class Relation:
21
+ """A spatial fact between two regions, e.g. Caption inside a Table.
22
+ Own type instead of a dict so a typo'd key fails loudly, not silently."""
23
+
24
+ kind: str
25
+ subject: str
26
+ object: str
27
+
28
+
29
+ @dataclass
30
+ class Evidence:
31
+ class_counts: dict[str, int]
32
+ confidence_stats: dict[str, ConfStats]
33
+ total_detections: int
34
+ reading_order: list[str]
35
+ relations: list[Relation] = field(default_factory=list)
36
+ # Raw scores per class - guardrail's ambiguous-band rule needs the full
37
+ # distribution, not just max/mean/min.
38
+ confidences_by_class: dict[str, list[float]] = field(default_factory=dict)
39
+
40
+
41
+ def _box_area(detection: Detection) -> float:
42
+ b = detection.bbox
43
+ return (b.x2 - b.x1) * (b.y2 - b.y1)
44
+
45
+
46
+ def _contains(outer: Detection, inner: Detection, threshold: float = 0.85) -> bool:
47
+ """True if inner sits mostly (85%+) inside outer. Not 100% - annotators
48
+ don't draw pixel-perfect boxes, a caption box a few px outside its
49
+ table shouldn't make the relationship disappear."""
50
+ ox1, oy1, ox2, oy2 = outer.bbox.x1, outer.bbox.y1, outer.bbox.x2, outer.bbox.y2
51
+ ix1, iy1, ix2, iy2 = inner.bbox.x1, inner.bbox.y1, inner.bbox.x2, inner.bbox.y2
52
+
53
+ overlap_x1, overlap_y1 = max(ox1, ix1), max(oy1, iy1)
54
+ overlap_x2, overlap_y2 = min(ox2, ix2), min(oy2, iy2)
55
+ if overlap_x2 <= overlap_x1 or overlap_y2 <= overlap_y1:
56
+ return False
57
+
58
+ overlap_area = (overlap_x2 - overlap_x1) * (overlap_y2 - overlap_y1)
59
+ inner_area = _box_area(inner)
60
+ return inner_area > 0 and (overlap_area / inner_area) >= threshold
61
+
62
+
63
+ def build_evidence(detections: list[Detection], image_width: int, image_height: int) -> Evidence:
64
+ """Main entry point: detections -> Evidence.
65
+
66
+ image_width/height aren't used yet - kept for when a relation needs
67
+ page-edge position (e.g. "is this near the top" for Page-header)."""
68
+ class_counts: dict[str, int] = {}
69
+ confidences_by_class: dict[str, list[float]] = {}
70
+
71
+ for detection in detections:
72
+ class_counts[detection.class_name] = class_counts.get(detection.class_name, 0) + 1
73
+ confidences_by_class.setdefault(detection.class_name, []).append(detection.confidence)
74
+
75
+ confidence_stats = {
76
+ class_name: ConfStats(
77
+ max=max(values), mean=sum(values) / len(values), min=min(values)
78
+ )
79
+ for class_name, values in confidences_by_class.items()
80
+ }
81
+
82
+ # Top-to-bottom, then left-to-right. Not real reading-order logic - a
83
+ # two-column page would interleave columns wrong under this sort. Good
84
+ # enough for DocLayNet's mostly-single-column pages, noted in the memo.
85
+ ordered = sorted(detections, key=lambda det: (det.bbox.y1, det.bbox.x1))
86
+ reading_order = [det.class_name for det in ordered]
87
+
88
+ relations: list[Relation] = []
89
+ for outer in detections:
90
+ for inner in detections:
91
+ if outer is inner:
92
+ continue
93
+ if _box_area(inner) >= _box_area(outer):
94
+ continue # can't "contain" something the same size or bigger
95
+ if _contains(outer, inner):
96
+ relations.append(Relation(kind="contains", subject=outer.class_name, object=inner.class_name))
97
+
98
+ return Evidence(
99
+ class_counts=class_counts,
100
+ confidence_stats=confidence_stats,
101
+ total_detections=len(detections),
102
+ reading_order=reading_order,
103
+ relations=relations,
104
+ confidences_by_class=confidences_by_class,
105
+ )
app/reasoning/guardrail.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Decides if the evidence is good enough to answer confidently - before the
2
+ # LLM writes anything. Kept as a separate deterministic check because asking
3
+ # a model "are you confident?" just gets more text, not a real signal - it's
4
+ # equally fluent at a confident-sounding guess as an honest refusal.
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+
9
+ from app.reasoning.evidence import Evidence
10
+
11
+ # Below this, a single detection isn't trustworthy enough to answer from.
12
+ # 0.50 rather than something stricter like 0.7 - correctly localised
13
+ # regions on this dataset tend to score well above 0.6 in my own runs.
14
+ CONFIDENCE_FLOOR = 0.50
15
+
16
+ # For counting: don't report an exact number if too much of it sits in this
17
+ # shaky band. Below 0.25 is just noise; 0.25-0.50 is "plausible but I
18
+ # wouldn't bet on the count."
19
+ AMBIGUOUS_BAND = (0.25, CONFIDENCE_FLOOR)
20
+ AMBIGUOUS_BAND_FRACTION = 0.30
21
+
22
+
23
+ @dataclass
24
+ class GuardrailVerdict:
25
+ triggered: bool
26
+ rule: str | None = None
27
+ detail: dict = field(default_factory=dict)
28
+
29
+
30
+ def _confidences_for(evidence: Evidence, target_classes: list[str]) -> list[float]:
31
+ """Max confidence per target class, ignoring unrelated classes on the page."""
32
+ values: list[float] = []
33
+ for class_name in target_classes:
34
+ stats = evidence.confidence_stats.get(class_name)
35
+ if stats is None:
36
+ continue
37
+ values.append(stats.max)
38
+ return values
39
+
40
+
41
+ def evaluate_guardrail(
42
+ evidence: Evidence,
43
+ target_classes: list[str],
44
+ task_type: str,
45
+ saturated: bool = False,
46
+ ) -> GuardrailVerdict:
47
+ """Four rules, checked in order, most specific first - some can overlap
48
+ and I want the more useful explanation to win (e.g. "found nothing"
49
+ beats "page was saturated" even when both are technically true)."""
50
+ raw_confidences = [
51
+ value
52
+ for class_name in target_classes
53
+ for value in evidence.confidences_by_class.get(class_name, [])
54
+ ]
55
+ target_counts = {
56
+ class_name: evidence.class_counts.get(class_name, 0)
57
+ for class_name in target_classes
58
+ }
59
+ total_target_detections = sum(target_counts.values())
60
+
61
+ # nothing of the target class found at all
62
+ if total_target_detections == 0:
63
+ return GuardrailVerdict(
64
+ triggered=True,
65
+ rule="no_detections",
66
+ detail={"target_classes": target_classes},
67
+ )
68
+
69
+ max_confidences = _confidences_for(evidence, target_classes)
70
+ observed_max = max(max_confidences) if max_confidences else 0.0
71
+
72
+ # found something, but nothing clears the confidence floor
73
+ if observed_max < CONFIDENCE_FLOOR:
74
+ return GuardrailVerdict(
75
+ triggered=True,
76
+ rule="all_below_tau",
77
+ detail={"tau": CONFIDENCE_FLOOR, "observed_max": observed_max},
78
+ )
79
+
80
+ # counting only: too much of the count sits in the shaky band.
81
+ # doesn't apply to "is there one?" - one confident hit is enough for that.
82
+ if task_type == "count" and raw_confidences:
83
+ low, high = AMBIGUOUS_BAND
84
+ in_band = sum(1 for c in raw_confidences if low <= c < high)
85
+ if raw_confidences and (in_band / len(raw_confidences)) > AMBIGUOUS_BAND_FRACTION:
86
+ return GuardrailVerdict(
87
+ triggered=True,
88
+ rule="ambiguous_band",
89
+ detail={
90
+ "band": list(AMBIGUOUS_BAND),
91
+ "fraction_in_band": round(in_band / len(raw_confidences), 2),
92
+ },
93
+ )
94
+
95
+ # page had more regions than RT-DETR's query budget could emit -
96
+ # a count here is unreliable regardless of confidence
97
+ if saturated:
98
+ return GuardrailVerdict(triggered=True, rule="query_saturated", detail={})
99
+
100
+ return GuardrailVerdict(triggered=False)
app/reasoning/pipeline.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Wires Part B together: route -> maybe detect -> evidence -> guardrail ->
2
+ # synthesize. All the actual decisions live in those modules, each tested
3
+ # separately - this file is just the order they run in.
4
+ from __future__ import annotations
5
+
6
+ from app.reasoning.evidence import build_evidence
7
+ from app.reasoning.guardrail import evaluate_guardrail
8
+ from app.reasoning.router import route as route_question
9
+ from app.reasoning.synthesize import synthesize
10
+ from app.schemas import AskResponse
11
+
12
+
13
+ def answer_question(image, question: str, detector, client=None) -> AskResponse:
14
+ """Runs the full pipeline for one question against one image.
15
+
16
+ detector/client are injectable - tests stub both, no GPU or Groq key
17
+ needed. See test_pipeline.py."""
18
+ route = route_question(question, client=client)
19
+
20
+ detections = []
21
+ if route.needs_detection:
22
+ # only run the detector if the router actually says it's needed -
23
+ # keeps out-of-scope questions from getting unrelated detections
24
+ detections, _inference_ms = detector.predict(image)
25
+
26
+ evidence = build_evidence(detections, image_width=0, image_height=0)
27
+
28
+ # stub detectors in tests don't have a query_budget - check first
29
+ saturated = (
30
+ route.needs_detection
31
+ and hasattr(detector, "query_budget")
32
+ and evidence.total_detections >= detector.query_budget
33
+ )
34
+
35
+ verdict = evaluate_guardrail(
36
+ evidence, target_classes=route.target_classes,
37
+ task_type=route.task_type, saturated=saturated,
38
+ ) if route.needs_detection else evaluate_guardrail(evidence, [], route.task_type)
39
+
40
+ # insufficient_information comes from the guardrail's verdict, not from
41
+ # the LLM's wording - see test_guardrail_verdict_overrides_whatever_the_llm_says
42
+ if route.needs_detection:
43
+ answer_text = synthesize(question, evidence, verdict, route, client=client)
44
+ insufficient = verdict.triggered
45
+ else:
46
+ # out-of-scope questions skip synthesis entirely - router's reason
47
+ # already explains why, no need for a second LLM call
48
+ answer_text = (
49
+ f"I can't answer that from this image's layout: {route.reason}"
50
+ )
51
+ insufficient = True
52
+
53
+ return AskResponse(
54
+ answer=answer_text,
55
+ used_detection=route.needs_detection,
56
+ insufficient_information=insufficient,
57
+ detections=detections,
58
+ evidence={
59
+ "class_counts": evidence.class_counts,
60
+ "total_detections": evidence.total_detections,
61
+ } if route.needs_detection else None,
62
+ reasoning_trace={
63
+ "router": {
64
+ "needs_detection": route.needs_detection,
65
+ "target_classes": route.target_classes,
66
+ "task_type": route.task_type,
67
+ "reason": route.reason,
68
+ "source": route.source,
69
+ },
70
+ "guardrail": {
71
+ "triggered": verdict.triggered,
72
+ "rule": verdict.rule,
73
+ "detail": verdict.detail,
74
+ },
75
+ },
76
+ )
app/reasoning/router.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Decides if a question needs the detector at all. Two layers:
2
+ # 1. Cheap regex prefilter for the obvious cases (greetings, general
3
+ # knowledge) - no point spending a Groq call on "what's the capital of
4
+ # France".
5
+ # 2. LLM call (Groq, strict json_schema) for everything else. Routing
6
+ # genuinely needs language understanding - a keyword list can't tell
7
+ # "how many tables" from "is this page well organised".
8
+ #
9
+ # Key thing baked into the prompt: the model knows it's routing for a
10
+ # *layout* detector, not general vision. "What's the invoice total?"
11
+ # mentions the image but the detector can't read text - only a prompt that
12
+ # says so explicitly catches that and routes it out_of_scope.
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+
18
+ from app.constants import CLASS_NAMES
19
+ from app.schemas import RouteDecision
20
+
21
+ # Kept short and specific on purpose - if a pattern is even slightly
22
+ # ambiguous it belongs with the LLM. A wrong prefilter call is worse than a
23
+ # slow one since it skips the LLM's judgement entirely.
24
+ _GREETING_PATTERN = re.compile(
25
+ r"^\s*(hi|hello|hey|thanks|thank you)[\s!.,]*$", re.IGNORECASE
26
+ )
27
+ _META_PATTERN = re.compile(
28
+ r"\b(what can you do|who are you|what are you|how do you work)\b", re.IGNORECASE
29
+ )
30
+ _GENERAL_KNOWLEDGE_PATTERN = re.compile(
31
+ r"\b(capital of|president of|weather|what year is it|who won)\b", re.IGNORECASE
32
+ )
33
+
34
+
35
+ def prefilter(question: str) -> RouteDecision | None:
36
+ """Only ever returns "definitely no detection needed", never "yes" -
37
+ deciding detection IS needed requires actually understanding the
38
+ sentence, which is the LLM's job. None means defer to it."""
39
+ stripped = question.strip()
40
+
41
+ if _GREETING_PATTERN.match(stripped):
42
+ return RouteDecision(
43
+ needs_detection=False, target_classes=[], task_type="out_of_scope",
44
+ reason="Greeting, not a question about the image.", source="prefilter",
45
+ )
46
+
47
+ if _META_PATTERN.search(stripped) or _GENERAL_KNOWLEDGE_PATTERN.search(stripped):
48
+ return RouteDecision(
49
+ needs_detection=False, target_classes=[], task_type="out_of_scope",
50
+ reason="General-knowledge or meta question, unrelated to image content.",
51
+ source="prefilter",
52
+ )
53
+
54
+ return None
55
+
56
+
57
+ _ROUTER_SCHEMA = {
58
+ "name": "route_decision",
59
+ "strict": True,
60
+ "schema": {
61
+ "type": "object",
62
+ "properties": {
63
+ "needs_detection": {"type": "boolean"},
64
+ "target_classes": {
65
+ "type": "array",
66
+ "items": {"type": "string", "enum": CLASS_NAMES},
67
+ },
68
+ "task_type": {
69
+ "type": "string",
70
+ "enum": ["count", "presence", "compare", "describe", "out_of_scope"],
71
+ },
72
+ "reason": {"type": "string"},
73
+ },
74
+ "required": ["needs_detection", "target_classes", "task_type", "reason"],
75
+ "additionalProperties": False,
76
+ },
77
+ }
78
+
79
+ _SYSTEM_PROMPT = f"""You route questions for a document layout detection API.
80
+
81
+ The detector can ONLY report the position and type of structural regions on a
82
+ document page. It knows WHERE things are, never WHAT they say. Its full
83
+ vocabulary of classes is exactly this list, nothing more:
84
+ {", ".join(CLASS_NAMES)}
85
+
86
+ Decide:
87
+ - needs_detection: does answering this actually require knowing what regions
88
+ are on the page? Questions about text CONTENT the detector cannot read
89
+ (amounts, names, dates, what a paragraph says, who signed something) are
90
+ OUT OF SCOPE even though they mention the document - set needs_detection to
91
+ false and task_type to "out_of_scope" for these. Do not guess at content the
92
+ detector cannot see.
93
+ - target_classes: which of the classes above the question is actually about.
94
+ Empty if needs_detection is false.
95
+ - task_type: "count" (how many), "presence" (is there a/any), "compare"
96
+ (relationships between regions), "describe" (general layout question), or
97
+ "out_of_scope".
98
+ - reason: one short sentence explaining the decision.
99
+ """
100
+
101
+
102
+ def route(question: str, client=None) -> RouteDecision:
103
+ """Prefilter first, then the LLM if it deferred. `client` is injectable
104
+ for tests/pipeline to swap in a stub - see pipeline.py.
105
+
106
+ Fails closed to out_of_scope on any LLM error (bad key, timeout, etc)
107
+ rather than guessing detection is needed - skipping a real question is
108
+ worse than declining an answerable one."""
109
+ prefiltered = prefilter(question)
110
+ if prefiltered is not None:
111
+ return prefiltered
112
+
113
+ if client is None:
114
+ from groq import Groq
115
+
116
+ client = Groq(api_key=os.environ.get("GROQ_API_KEY"))
117
+
118
+ model = os.environ.get("GROQ_ROUTER_MODEL", "openai/gpt-oss-20b")
119
+
120
+ try:
121
+ response = client.chat.completions.create(
122
+ model=model,
123
+ messages=[
124
+ {"role": "system", "content": _SYSTEM_PROMPT},
125
+ {"role": "user", "content": question},
126
+ ],
127
+ response_format={"type": "json_schema", "json_schema": _ROUTER_SCHEMA},
128
+ temperature=0,
129
+ )
130
+ import json
131
+
132
+ parsed = json.loads(response.choices[0].message.content)
133
+ return RouteDecision(
134
+ needs_detection=parsed["needs_detection"],
135
+ target_classes=parsed["target_classes"],
136
+ task_type=parsed["task_type"],
137
+ reason=parsed["reason"],
138
+ source="llm",
139
+ )
140
+ except Exception as error:
141
+ return RouteDecision(
142
+ needs_detection=False,
143
+ target_classes=[],
144
+ task_type="out_of_scope",
145
+ reason=f"Router failed closed after an error: {error}",
146
+ source="llm",
147
+ )
app/reasoning/synthesize.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Writes the final answer text from evidence only - never sees the image.
2
+ # Only job here is phrasing: router already decided whether to detect,
3
+ # guardrail already decided if the evidence is trustworthy. Uses the bigger
4
+ # Groq model (gpt-oss-120b) since this is open-ended writing, unlike
5
+ # routing which is closed classification the small model handles fine.
6
+ from __future__ import annotations
7
+
8
+ import os
9
+
10
+ from app.reasoning.evidence import Evidence
11
+ from app.reasoning.guardrail import GuardrailVerdict
12
+ from app.schemas import RouteDecision
13
+
14
+
15
+ def _evidence_to_json(evidence: Evidence) -> dict:
16
+ """Flattens Evidence to plain JSON - the only thing the LLM ever sees
17
+ about the image. No pixels, so it can't describe anything it didn't
18
+ actually detect."""
19
+ return {
20
+ "class_counts": evidence.class_counts,
21
+ "confidence_stats": {
22
+ name: {"max": stats.max, "mean": round(stats.mean, 3), "min": stats.min}
23
+ for name, stats in evidence.confidence_stats.items()
24
+ },
25
+ "total_detections": evidence.total_detections,
26
+ "reading_order": evidence.reading_order,
27
+ "relations": [
28
+ {"kind": r.kind, "subject": r.subject, "object": r.object}
29
+ for r in evidence.relations
30
+ ],
31
+ }
32
+
33
+
34
+ _SYSTEM_PROMPT = """You write short, plain-language answers about a document
35
+ page's layout, based ONLY on the structured evidence JSON you are given. You
36
+ have not seen the image - do not describe anything beyond what the evidence
37
+ contains.
38
+
39
+ If you are told the evidence is insufficient, say so plainly and explain why
40
+ in one sentence, using the guardrail detail provided. Do not hedge around it
41
+ or try to answer anyway - an explicit "I can't answer confidently" is the
42
+ correct and expected response in that case, not a failure to be worked around.
43
+
44
+ Otherwise, answer the question directly and concisely using the evidence.
45
+ Do not invent counts, classes, or relationships that are not in the evidence.
46
+ """
47
+
48
+
49
+ def synthesize(
50
+ question: str,
51
+ evidence: Evidence,
52
+ verdict: GuardrailVerdict,
53
+ route: RouteDecision,
54
+ client=None,
55
+ ) -> str:
56
+ """Produces the final answer text. Guardrail's verdict goes in as a
57
+ stated fact, not something the model re-judges - if triggered, it's
58
+ told "explain why this is insufficient", not asked "are you sure?"."""
59
+ context = {
60
+ "question": question,
61
+ "task_type": route.task_type,
62
+ "evidence": _evidence_to_json(evidence),
63
+ "guardrail": {"insufficient": verdict.triggered, "rule": verdict.rule, "detail": verdict.detail},
64
+ }
65
+
66
+ if client is None:
67
+ from groq import Groq
68
+
69
+ client = Groq(api_key=os.environ.get("GROQ_API_KEY"))
70
+
71
+ model = os.environ.get("GROQ_SYNTHESIS_MODEL", "openai/gpt-oss-120b")
72
+
73
+ try:
74
+ import json
75
+
76
+ response = client.chat.completions.create(
77
+ model=model,
78
+ messages=[
79
+ {"role": "system", "content": _SYSTEM_PROMPT},
80
+ {"role": "user", "content": json.dumps(context)},
81
+ ],
82
+ temperature=0.2,
83
+ )
84
+ return response.choices[0].message.content.strip()
85
+ except Exception as error:
86
+ # Groq down/timeout/bad key - fall back to a plain template built
87
+ # from the guardrail/evidence instead of a stack trace, so the API
88
+ # stays honest even when the writing step is unavailable.
89
+ if verdict.triggered:
90
+ return (
91
+ f"I cannot answer confidently: {verdict.rule}. "
92
+ f"(Answer synthesis unavailable: {error})"
93
+ )
94
+ counts = ", ".join(f"{v} {k}" for k, v in evidence.class_counts.items()) or "nothing"
95
+ return f"Detected: {counts}. (Answer synthesis unavailable: {error})"
app/schemas.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Request/response shapes for both endpoints, all in one place so /detect,
2
+ # /ask and the tests aren't each building their own dict by hand.
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel, Field, field_validator
8
+
9
+
10
+ class BBox(BaseModel):
11
+ """Corner-format box (not centre+size) - easier for a caller to draw."""
12
+
13
+ x1: float
14
+ y1: float
15
+ x2: float
16
+ y2: float
17
+
18
+ # Catches a mixed-up width/x2 conversion before it ships as an
19
+ # inside-out box - happened once, don't want it happening again.
20
+ @field_validator("x2")
21
+ @classmethod
22
+ def x2_after_x1(cls, x2: float, info) -> float:
23
+ x1 = info.data.get("x1")
24
+ if x1 is not None and x2 <= x1:
25
+ raise ValueError(f"x2 ({x2}) must be greater than x1 ({x1})")
26
+ return x2
27
+
28
+ @field_validator("y2")
29
+ @classmethod
30
+ def y2_after_y1(cls, y2: float, info) -> float:
31
+ y1 = info.data.get("y1")
32
+ if y1 is not None and y2 <= y1:
33
+ raise ValueError(f"y2 ({y2}) must be greater than y1 ({y1})")
34
+ return y2
35
+
36
+
37
+ class Detection(BaseModel):
38
+ """One region the detector found, with its class and how sure it is."""
39
+
40
+ class_name: str
41
+ class_id: int
42
+ bbox: BBox
43
+ confidence: float = Field(ge=0.0, le=1.0)
44
+
45
+
46
+ class ImageSize(BaseModel):
47
+ width: int
48
+ height: int
49
+
50
+
51
+ class DetectResponse(BaseModel):
52
+ """Full response for POST /detect. model_version tags which checkpoint
53
+ produced this, useful once there's more than one."""
54
+
55
+ detections: list[Detection]
56
+ image_size: ImageSize
57
+ inference_ms: float
58
+ model_version: str
59
+
60
+
61
+ class AskRequest(BaseModel):
62
+ """Image comes in as multipart, not JSON - this is just the question."""
63
+
64
+ question: str = Field(min_length=1, max_length=500)
65
+
66
+
67
+ class ConfidenceStats(BaseModel):
68
+ max: float
69
+ mean: float
70
+ min: float
71
+
72
+
73
+ class RouteDecision(BaseModel):
74
+ """What the router decided before any detection runs. task_type is a
75
+ closed set, not a free string - guardrail.py branches on it directly,
76
+ and a typo would just silently skip the guardrail."""
77
+
78
+ needs_detection: bool
79
+ target_classes: list[str]
80
+ task_type: Literal["count", "presence", "compare", "describe", "out_of_scope"]
81
+ reason: str
82
+ source: Literal["prefilter", "llm"]
83
+
84
+
85
+ class GuardrailVerdict(BaseModel):
86
+ """Whether the evidence is good enough to answer, and why not if not.
87
+ detail stays a free dict since each rule explains itself differently."""
88
+
89
+ triggered: bool
90
+ rule: str | None = None
91
+ detail: dict = Field(default_factory=dict)
92
+
93
+
94
+ class AskResponse(BaseModel):
95
+ """Full response for POST /ask. insufficient_information is set from
96
+ the guardrail's verdict in code - never from the LLM self-reporting
97
+ confidence, since that's one prompt away from a confident-sounding
98
+ guess."""
99
+
100
+ answer: str
101
+ used_detection: bool
102
+ insufficient_information: bool
103
+ detections: list[Detection] = Field(default_factory=list)
104
+ evidence: dict | None = None
105
+ reasoning_trace: dict = Field(default_factory=dict)
deploy/huggingface_space_README.md ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Document Layout Detection
3
+ emoji: 📄
4
+ colorFrom: blue
5
+ colorTo: gray
6
+ sdk: gradio
7
+ app_file: gradio_app.py
8
+ pinned: false
9
+ ---
10
+
11
+ # Constrained Document Layout Detection
12
+
13
+ RT-DETR fine-tuned on DocLayNet, plus a hand-written reasoning layer for
14
+ natural-language questions about a page's layout.
15
+
16
+ Full source, training scripts, and the FastAPI API this demo shares its
17
+ code with: see the main repo.
18
+
19
+ ## Space secrets needed
20
+
21
+ - `GROQ_API_KEY` - for the Ask tab's reasoning layer
22
+ - `MODEL_URL` - direct download link for the trained weights (e.g. a
23
+ GitHub Release asset). Downloaded automatically on first run if
24
+ `MODEL_PATH` doesn't already exist - no need to push the weights file
25
+ into the Space's git repo.
gradio_app.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Gradio UI for Hugging Face Spaces - same Detector and reasoning pipeline
2
+ # as the FastAPI app and streamlit_app.py, just a different front end.
3
+ # HF's current Space creation flow only offers Gradio/Docker/Static as
4
+ # SDKs (no standalone Streamlit option), so this is the path that works
5
+ # without a paid plan.
6
+ from __future__ import annotations
7
+
8
+ import os
9
+
10
+ import gradio as gr
11
+ from dotenv import load_dotenv
12
+
13
+ load_dotenv()
14
+
15
+ from PIL import Image, ImageDraw
16
+
17
+ from app.constants import CLASS_NAMES, MODEL_VERSION
18
+ from app.detector import Detector
19
+ from app.reasoning.pipeline import answer_question
20
+ from scripts._render_utils import load_label_font
21
+
22
+ try:
23
+ # only meaningful on an actual HF ZeroGPU Space - it's what tells the
24
+ # platform to allocate a real GPU for the duration of the decorated
25
+ # call, then release it. A no-op import failure anywhere else (local
26
+ # runs, Docker) just means we fall back to whatever device torch
27
+ # picks on its own.
28
+ import spaces
29
+
30
+ _gpu_decorator = spaces.GPU
31
+ except ImportError:
32
+ def _gpu_decorator(fn):
33
+ return fn
34
+
35
+ PALETTE = [
36
+ "#e6194b", "#3cb44b", "#ffe119", "#4363d8", "#f58231", "#911eb4",
37
+ "#46f0f0", "#f032e6", "#bcf60c", "#fabebe", "#008080",
38
+ ]
39
+
40
+ _detector: Detector | None = None
41
+
42
+
43
+ def get_detector() -> Detector:
44
+ # module-level singleton, same reasoning as main.py's get_detector -
45
+ # loads once per process, not per request. Left completely unmodified
46
+ # (no monkeypatching) - ZeroGPU's @spaces.GPU spawns a separate worker
47
+ # process for the actual GPU call, and everything crossing that
48
+ # boundary gets pickled. A bound method captured in a closure (my
49
+ # first two attempts) either can't be pickled at all, or - when it
50
+ # can - resolves to whatever the instance's current attribute value
51
+ # is on the other side, which is fragile either way. Passing only
52
+ # plain data (the image, a float) sidesteps the whole problem.
53
+ global _detector
54
+ if _detector is None:
55
+ _detector = Detector()
56
+ try:
57
+ _detector.load()
58
+ except FileNotFoundError:
59
+ pass # surfaced in the UI instead of crashing the app
60
+ return _detector
61
+
62
+
63
+ @_gpu_decorator
64
+ def _predict_on_gpu(image, conf: float = 0.25):
65
+ # The only thing decorated with @spaces.GPU, and it has to be exactly
66
+ # this: a real top-level function (ZeroGPU scans source at startup
67
+ # for one - dynamic decoration isn't detected), taking only plain,
68
+ # picklable arguments. It reaches the detector via the module-level
69
+ # getter rather than having one passed in.
70
+ return get_detector().predict(image, conf=conf)
71
+
72
+
73
+ class _GPUDetectorProxy:
74
+ """Stands in for a Detector wherever one is expected (run_ask passes
75
+ this to the reasoning pipeline) but routes predict() through the
76
+ properly decorated module-level function above instead of calling
77
+ the real method directly - same reason as the getter's docstring."""
78
+
79
+ @property
80
+ def is_loaded(self) -> bool:
81
+ return get_detector().is_loaded
82
+
83
+ @property
84
+ def query_budget(self) -> int:
85
+ return get_detector().query_budget
86
+
87
+ def predict(self, image, conf: float = 0.25):
88
+ return _predict_on_gpu(image, conf=conf)
89
+
90
+
91
+ def draw_detections(image: Image.Image, detections) -> Image.Image:
92
+ annotated = image.copy()
93
+ draw = ImageDraw.Draw(annotated)
94
+ font = load_label_font(18)
95
+
96
+ for det in detections:
97
+ colour = PALETTE[det.class_id % len(PALETTE)]
98
+ box = det.bbox
99
+ draw.rectangle([box.x1, box.y1, box.x2, box.y2], outline=colour, width=3)
100
+ label = f"{det.class_name} {det.confidence:.2f}"
101
+ text_box = draw.textbbox((box.x1, box.y1), label, font=font)
102
+ draw.rectangle(
103
+ [text_box[0] - 2, text_box[1] - 2, text_box[2] + 2, text_box[3] + 2],
104
+ fill=colour,
105
+ )
106
+ draw.text((box.x1, box.y1), label, font=font, fill="white")
107
+
108
+ return annotated
109
+
110
+
111
+ def run_detect(image: Image.Image):
112
+ if not get_detector().is_loaded:
113
+ return None, "Model not loaded - check MODEL_PATH/MODEL_URL.", None
114
+ if image is None:
115
+ return None, "Upload an image first.", None
116
+
117
+ detections, inference_ms = _predict_on_gpu(image)
118
+ annotated = draw_detections(image, detections)
119
+ table = [[d.class_name, round(d.confidence, 3)] for d in sorted(detections, key=lambda d: -d.confidence)]
120
+ summary = f"{len(detections)} detections in {inference_ms:.1f} ms"
121
+ return annotated, summary, table
122
+
123
+
124
+ def run_ask(image: Image.Image, question: str):
125
+ if not get_detector().is_loaded:
126
+ return "Model not loaded - check MODEL_PATH/MODEL_URL.", {}
127
+ if image is None or not question:
128
+ return "Upload an image and enter a question.", {}
129
+ if not os.environ.get("GROQ_API_KEY"):
130
+ return "GROQ_API_KEY not set - the reasoning layer needs it.", {}
131
+
132
+ response = answer_question(image=image, question=question, detector=_GPUDetectorProxy())
133
+ return response.answer, response.reasoning_trace
134
+
135
+
136
+ with gr.Blocks(title="Document Layout Detection") as demo:
137
+ gr.Markdown(f"# Constrained Document Layout Detection\nRT-DETR fine-tuned on DocLayNet - `{MODEL_VERSION}`")
138
+ gr.Markdown(f"Classes: {', '.join(CLASS_NAMES)}")
139
+
140
+ with gr.Tab("Detect"):
141
+ with gr.Row():
142
+ detect_input = gr.Image(type="pil", label="Upload a document page")
143
+ detect_output = gr.Image(type="pil", label="Detections")
144
+ detect_button = gr.Button("Run detection")
145
+ detect_summary = gr.Textbox(label="Summary", interactive=False)
146
+ detect_table = gr.Dataframe(headers=["class", "confidence"], label="Detections")
147
+ detect_button.click(run_detect, inputs=detect_input, outputs=[detect_output, detect_summary, detect_table])
148
+
149
+ with gr.Tab("Ask"):
150
+ ask_input = gr.Image(type="pil", label="Upload a document page")
151
+ ask_question = gr.Textbox(label="Question", placeholder="How many tables are on this page?")
152
+ ask_button = gr.Button("Ask")
153
+ ask_answer = gr.Textbox(label="Answer", interactive=False)
154
+ ask_trace = gr.JSON(label="Reasoning trace")
155
+ ask_button.click(run_ask, inputs=[ask_input, ask_question], outputs=[ask_answer, ask_trace])
156
+
157
+ if __name__ == "__main__":
158
+ demo.launch()
memo/BUILD_LOG.md ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build log
2
+
3
+ Working notes from building this, kept as I went rather than written after
4
+ the fact.
5
+
6
+ ## Reading the brief
7
+
8
+ Weighting is not what the title suggests: 25% is Part A hidden-set
9
+ performance, the rest is dataset sourcing, evaluation honesty, failure
10
+ analysis, Part B correctness, code quality, and reproducibility. Roughly
11
+ 40% is written reasoning about the work, not the work itself. That shaped
12
+ everything below — I optimized for a system I could explain precisely,
13
+ not for the highest number.
14
+
15
+ Hard gates: no agentic frameworks anywhere, RT-DETR with my own training
16
+ code, at least one non-COCO class or Part A scores zero, and training has
17
+ to be reproducible or Part A caps at 50%.
18
+
19
+ ## Picking the domain
20
+
21
+ First idea was construction PPE (helmet/vest detection) — dropped it
22
+ because `person` is a COCO class, so part of the score would be inherited
23
+ from the pretrained backbone rather than earned. Also already taken by
24
+ another candidate. Traffic was worse on the same axis — car, truck, person,
25
+ traffic light are all COCO classes.
26
+
27
+ Landed on document layout detection: all 11 DocLayNet classes (Table,
28
+ Text, Title, etc.) are non-COCO, so every point of mAP is earned. It also
29
+ gives Part B a real architectural refusal case instead of a tuned
30
+ threshold — "what's the invoice total" is a question about content a
31
+ layout detector structurally cannot answer, not a low-confidence guess.
32
+ Adjacent to what RAP builds (RAPFlow does document extraction), which
33
+ made the domain choice defensible rather than arbitrary.
34
+
35
+ Risk I accepted: DocLayNet is rendered/scanned pages, not camera photos.
36
+ If the hidden eval set is phone photos of paper, this model will do
37
+ worse. Mitigated with rotation/perspective augmentation, not eliminated.
38
+
39
+ ## Dataset
40
+
41
+ `pierreguillou/DocLayNet-base` — a 10% subset of IBM's DocLayNet, same
42
+ annotation quality, 8k images instead of 80k. Full dataset is ~30GB and
43
+ takes 45 min just to download; not worth it against a one-day budget.
44
+
45
+ Used the author's train/val/test split rather than re-splitting myself —
46
+ reproducible, comparable to published work. Measured the one risk that
47
+ comes with that (pages from the same source PDF leaking across the split)
48
+ rather than assuming it away: 0% overlap.
49
+
50
+ The real gotcha in this dataset: `bboxes_block` repeats each region's box
51
+ once per text line, so a 40-region page reads as ~1,100 duplicate
52
+ annotations if you don't dedupe. Wrote `dedupe_annotations()` to collapse
53
+ on `(box, category)` before writing YOLO labels.
54
+
55
+ ## Reasoning layer — why Groq, and the architecture
56
+
57
+ Switched from an original OpenAI plan to Groq: faster, generous free
58
+ tier, and it supports `json_schema` with `strict: true` — the router's
59
+ output is structurally guaranteed to match my schema (constrained
60
+ decoding), not just prompted-and-hoped-for. `llama-3.3-70b-versatile` was
61
+ deprecated for free tier in June 2026, so the router runs `gpt-oss-20b`
62
+ and synthesis runs `gpt-oss-120b`.
63
+
64
+ Design point I care about most: the guardrail that decides
65
+ `insufficient_information` is plain deterministic Python, evaluated
66
+ *before* the LLM writes anything. Asking a model "are you confident?"
67
+ just gets more text — it's equally fluent at a confident-sounding guess
68
+ as an honest refusal. The verdict is handed to the LLM as a fact, not
69
+ asked as a question.
70
+
71
+ ## Training config choices
72
+
73
+ `fliplr`, `flipud`, `mosaic` all forced to 0 — Ultralytics defaults these
74
+ on for photographs, but a mirrored or four-way-composited document page
75
+ is not a document. Kept small-angle rotation and mild perspective, since
76
+ those model real scanner/camera skew and are the only hedge against the
77
+ domain-shift risk above.
78
+
79
+ `lr0=1e-4` not the usual `1e-2` — DETR-family transformer components are
80
+ unstable at high LR, and I'm fine-tuning from COCO weights, not training
81
+ from scratch.
82
+
83
+ Cut epochs from 30 to 15 partway through after losing the first training
84
+ run (see below) and needing to fit a much tighter remaining window —
85
+ noting the pivot rather than hiding it.
86
+
87
+ ## Kaggle infrastructure issues
88
+
89
+ Several real environment problems hit in the first hour of actually
90
+ running this — none were modelling bugs, all were dependency/platform
91
+ collisions:
92
+
93
+ - **`datasets>=4.0.0`** dropped support for legacy "loading script"
94
+ datasets entirely; this dataset repo is one. Pinned `datasets<4.0.0`.
95
+ - Loading a script-based dataset prompts for confirmation
96
+ (`trust_remote_code`), which hangs forever under Kaggle's non-interactive
97
+ commit mode. Passed `trust_remote_code=True` explicitly, after reading
98
+ what the script actually does.
99
+ - The dataset repo's own schema declares box coordinates as `int64`; the
100
+ real values are floats. Older `pyarrow` silently truncated them, newer
101
+ `pyarrow` correctly refuses. Fixed by overriding the load schema to
102
+ `float64` rather than editing someone else's script.
103
+ - Ultralytics auto-registers a Ray Tune callback whenever `ray` is
104
+ importable, with no check for whether Tune is running. Kaggle ships ray
105
+ pre-installed; the callback crashed at the end of epoch 1 calling a
106
+ renamed internal Ray API. Fixed with `SETTINGS["raytune"] = False`.
107
+ - A notebook cell did `%cd` into a directory and then `rm -rf`'d it on
108
+ the next run, corrupting the shell. Fixed by stepping out first.
109
+ - Label-check renders used PIL's default ~10px font — unreadable once
110
+ resized. Switched to a real font (matplotlib's bundled DejaVu, since
111
+ that's a dependency I already have).
112
+
113
+ **One of these actually cost a full training run**: the session was
114
+ running interactively rather than via Save & Run All (Commit), and Kaggle
115
+ recycled the container after a period of inactivity, wiping the dataset
116
+ and every checkpoint through epoch 10 (which had reached mAP50 0.69).
117
+ Rebuilt from scratch with epochs cut to 15 to fit the remaining time, and
118
+ switched to committed runs for good.
119
+
120
+ ## API, Docker, Streamlit
121
+
122
+ FastAPI wraps the same `Detector`/reasoning pipeline used everywhere else
123
+ — no separate logic path. Caught one real bug via the tests before it
124
+ shipped: the detector dependency was written as a bare default argument
125
+ (`= get_detector()`) instead of `Depends(get_detector)`, which would have
126
+ silently defeated test overrides while looking completely normal in
127
+ production.
128
+
129
+ Docker: CPU-only torch wheel (no CUDA toolkit bloat for a container that
130
+ only serves inference), non-root user. Built and actually ran it —
131
+ confirmed the app starts cleanly with no weights present and `/health`
132
+ reports that instead of crashing.
133
+
134
+ Streamlit app reuses the exact same `Detector` and pipeline as the API,
135
+ for Hugging Face Spaces — no duplicated logic, just a UI on top.
136
+
137
+ ## Real results
138
+
139
+ Final run: 15 epochs, Tesla T4, 2.27h. mAP50 0.622, mAP50-95 0.412.
140
+
141
+ The most useful single finding: `Footnote` (47 instances) scored 0.182
142
+ mAP50, `Page-footer` — a similarly thin, similarly-positioned class —
143
+ scored 0.843. Difference is `Page-footer` has 9x more training instances
144
+ and a very consistent position. Revises my pre-training hypothesis from
145
+ "thin objects fail" to "thin *and rare* objects fail; thin-but-frequent
146
+ ones are fine."
147
+
148
+ `Picture` had decent recall (0.622) but weak precision (0.416) —
149
+ mined-failure images show composite regions (a diagram plus its embedded
150
+ formula text) getting split into multiple overlapping boxes instead of
151
+ one region.
152
+
153
+ Live-tested the full pipeline against the trained weights on a real,
154
+ previously-unseen page: asking a content question ("what law is this
155
+ about") correctly routed to `out_of_scope` before detection ever ran —
156
+ the real example used in the memo.
memo/MEMO.md ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Memo — Constrained Document Layout Detection & Reasoning API
2
+
3
+ ## 1. Domain, dataset, and why
4
+
5
+ I chose document layout analysis — detecting structural regions (tables,
6
+ headings, captions, formulas) on a document page — over more common choices
7
+ like traffic or PPE detection for three reasons. First, all candidate
8
+ classes are non-COCO, so no pretrained checkpoint can score without real
9
+ fine-tuning; PPE/traffic domains lean on COCO's `person`/`car` classes for
10
+ a chunk of their apparent performance. Second, it produces a genuinely
11
+ architectural "insufficient information" case for Part B (below), not a
12
+ tuned confidence threshold. Third, it's adjacent to what Rapid Acceleration
13
+ Partners builds — RAPFlow's stated purpose is automated extraction from
14
+ unstructured documents, and layout detection is the perception stage
15
+ underneath that.
16
+
17
+ **Dataset:** `pierreguillou/DocLayNet-base` (Hugging Face), a 10% subset of
18
+ IBM Research's DocLayNet — 8,057 pages, expert-annotated, CDLA-Permissive-1.0
19
+ license, spanning 6 document categories (financial reports, scientific
20
+ articles, laws/regulations, tenders, manuals, patents). I used this subset
21
+ over the full 80k-page release specifically for the time budget: the full
22
+ release is ~30 GB and takes ~45 minutes just to download, which was not
23
+ defensible against a one-day window. Same annotation quality, ~8x smaller.
24
+
25
+ One real data hazard: the dataset's `bboxes_block` field repeats each
26
+ region's box once per text line it contains, so a naive read of a 40-region
27
+ page produces ~1,100 duplicate annotations. `scripts/prepare_dataset.py`
28
+ deduplicates on `(box, category)` before writing YOLO labels and reports
29
+ the dedupe ratio per split.
30
+
31
+ ## 2. Split strategy
32
+
33
+ I used the dataset author's published train/val/test split (6,910/648/499)
34
+ rather than re-stratifying myself. This is the reproducible choice —
35
+ comparable to published DocLayNet baselines — but it means inheriting
36
+ whatever leakage that split has. I measured it rather than assuming:
37
+ **0% of test pages share a source PDF with the training set** (see
38
+ `reports/metrics.json:split_leakage`). The split is clean.
39
+
40
+ ## 3. Evaluation and what it does / doesn't tell you
41
+
42
+ Test-set results (499 pages, RT-DETR-L, 15 epochs, Tesla T4, 2.27h):
43
+ **mAP50 = 0.622, mAP50-95 = 0.412, precision = 0.706, recall = 0.605.**
44
+
45
+ I report more than one number because a single aggregate mAP cannot answer
46
+ what I actually need to know. Per-document-category mAP50 ranges from
47
+ 0.495 (`laws_and_regulations`) to 0.745 (`scientific_articles`) — an honest
48
+ signal about which document styles generalize, not just an aggregate. I
49
+ also measured RT-DETR's query-budget saturation directly rather than
50
+ letting it hide inside a recall number: **0% of test pages exceeded the
51
+ 300-query budget** (max regions on any page: 95), so saturation is not a
52
+ factor in these results.
53
+
54
+ **What mAP does not tell you:** it measures box localization and
55
+ classification, nothing about reading order, and nothing about whether a
56
+ detected `Table`'s internal structure is usable downstream. A model can
57
+ score well here and still be useless for extracting an actual table's rows.
58
+
59
+ ## 4. Five failure cases
60
+
61
+ **1. `Footnote` — near-total recall collapse (recall 0.048, mAP50 0.182).**
62
+ I predicted before training that small/thin classes would suffer at 640px
63
+ input. Only half right: `Footnote` has just 47 training instances across
64
+ 18 images — rarity, not size, is the dominant factor (see #2's contrast).
65
+
66
+ **2. `Page-footer` almost failed the same way, but didn't (mAP50 0.843).**
67
+ Equally thin, similarly positioned at the page margin, but 408 instances
68
+ across 381 images (~9x more than `Footnote`) and a highly consistent
69
+ position. This is the most useful negative result in the run: it revised
70
+ my hypothesis from "small objects fail" to "small *and rare* objects fail;
71
+ small-but-frequent-and-positionally-consistent ones are fine."
72
+
73
+ **3. Dense repeated-entry layouts produce duplicate, jittery boxes**
74
+ (`reports/failures/01_test_000035_err120.png`, financial_reports, the
75
+ worst page overall: 38 missed, 82 false positives). This page is a bank's
76
+ organizational directory — ~25 near-identical repeated `Section-header`/
77
+ `Text` entry pairs stacked in two columns. Ground truth has one clean box
78
+ per entry; the prediction shows heavily overlapping, offset duplicate boxes
79
+ at scattered confidence (0.25–0.85). At 640px, the model appears unable to
80
+ cleanly separate many visually near-identical, tightly packed instances —
81
+ a genuine limitation of a fixed-query architecture on repetitive layouts,
82
+ not a training bug.
83
+
84
+ **4. Composite `Picture` regions get fragmented into overlapping sub-boxes**
85
+ (`reports/failures/04_test_000425_err61.png`, patents: 0 missed, 0
86
+ misclassified, 61 false positives — the cleanest signature in the run).
87
+ Each ground-truth `Picture` is one chemical structure diagram; the model
88
+ predicts 2–3 overlapping `Picture` boxes per diagram instead of one,
89
+ apparently treating the diagram's internal sub-drawing and inline formula
90
+ text as separable parts. This directly explains `Picture`'s low precision
91
+ (0.416) despite reasonable recall (0.622) — it's finding pictures, just not
92
+ as single coherent regions.
93
+
94
+ **5. `Page-header` confused with `Text` on running headers** — observed
95
+ live on a random, previously-unseen legal-code test page (not from the
96
+ mined failure set): a running header/citation line ("...Official Gazette
97
+ of the Republic...") was labelled `Text` at low confidence (0.34–0.46),
98
+ well below the model's confident paragraph-level `Text` calls (0.68–0.96).
99
+ The low confidence itself is informative — the model is uncertain about
100
+ exactly the region it should be classifying differently.
101
+
102
+ ## 5. Part B — when it detects, and the honest refusal
103
+
104
+ The router (regex prefilter → Groq LLM, strict `json_schema`) decides
105
+ `needs_detection` before anything else runs. The prompt tells it explicitly
106
+ that the detector's vocabulary is layout-only — it knows *where* regions
107
+ are, never *what text says*. A question like "what is the invoice total"
108
+ sounds document-related but is content, not layout, so it routes to
109
+ `out_of_scope` before detection ever runs.
110
+
111
+ **Real, live-tested example:** asked *"What law is this document about?"*
112
+ against an actual test page, the system responded:
113
+
114
+ > *"I can't answer that from this image's layout: Question asks about
115
+ > document content, which the detector cannot read."*
116
+
117
+ `insufficient_information=True` here is set in code from the router/
118
+ guardrail state, not from the LLM self-reporting confidence — a
119
+ confident-sounding wrong answer is one bad prompt away if the LLM judges
120
+ its own certainty. The deterministic guardrail (4 rules: no detections,
121
+ all below a 0.50 confidence floor, too much of a count in an ambiguous
122
+ 0.25–0.50 band, or query-budget saturation) is what actually decides,
123
+ and it can't be talked out of triggering by fluent-sounding synthesis text.
124
+ Full trace for every response is included in the API output for auditing.
notebooks/kaggle_train.ipynb ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "12c7fcd3",
6
+ "metadata": {},
7
+ "source": [
8
+ "# RT-DETR on DocLayNet"
9
+ ]
10
+ },
11
+ {
12
+ "cell_type": "markdown",
13
+ "id": "dd021a99",
14
+ "metadata": {},
15
+ "source": [
16
+ "## hardware check\n"
17
+ ]
18
+ },
19
+ {
20
+ "cell_type": "code",
21
+ "execution_count": null,
22
+ "id": "dc05ca98",
23
+ "metadata": {},
24
+ "outputs": [],
25
+ "source": [
26
+ "!nvidia-smi"
27
+ ]
28
+ },
29
+ {
30
+ "cell_type": "markdown",
31
+ "id": "9fbb842f",
32
+ "metadata": {},
33
+ "source": [
34
+ "## Install\n"
35
+ ]
36
+ },
37
+ {
38
+ "cell_type": "code",
39
+ "execution_count": null,
40
+ "id": "85123b54",
41
+ "metadata": {},
42
+ "outputs": [],
43
+ "source": [
44
+ "!pip install -q ultralytics==8.3.40 \"datasets<4.0.0\"\n"
45
+ ]
46
+ },
47
+ {
48
+ "cell_type": "markdown",
49
+ "id": "8d277bd9",
50
+ "metadata": {},
51
+ "source": [
52
+ "## Pull the repo"
53
+ ]
54
+ },
55
+ {
56
+ "cell_type": "code",
57
+ "execution_count": null,
58
+ "id": "d57e231e",
59
+ "metadata": {},
60
+ "outputs": [],
61
+ "source": [
62
+ "REPO_URL = \"https://github.com/RISHIVELS/doc_layout_detection.git\"\n",
63
+ "\n",
64
+ "# %cd into a directory this cell is about to rm -rf breaks the shell's\n",
65
+ "# own working directory on the *next* run of this cell (getcwd fails\n",
66
+ "# because the path it thinks it is standing in no longer exists). So I\n",
67
+ "# step out to a stable parent directory before deleting anything.\n",
68
+ "%cd /kaggle/working\n",
69
+ "!rm -rf /kaggle/working/repo\n",
70
+ "!git clone -q $REPO_URL /kaggle/working/repo\n",
71
+ "%cd /kaggle/working/repo\n",
72
+ "!git log --oneline -1\n"
73
+ ]
74
+ },
75
+ {
76
+ "cell_type": "markdown",
77
+ "id": "388321a3",
78
+ "metadata": {},
79
+ "source": [
80
+ "## Build the dataset\n",
81
+ "\n",
82
+ "~3.8GB first run."
83
+ ]
84
+ },
85
+ {
86
+ "cell_type": "code",
87
+ "execution_count": null,
88
+ "id": "f8106d41",
89
+ "metadata": {},
90
+ "outputs": [],
91
+ "source": [
92
+ "!python scripts/prepare_dataset.py --out /kaggle/working/data/doclaynet --verify 12"
93
+ ]
94
+ },
95
+ {
96
+ "cell_type": "code",
97
+ "execution_count": null,
98
+ "id": "b31d2185",
99
+ "metadata": {},
100
+ "outputs": [],
101
+ "source": [
102
+ "from pathlib import Path\n",
103
+ "from IPython.display import display\n",
104
+ "from PIL import Image\n",
105
+ "\n",
106
+ "checks = sorted(Path(\"/kaggle/working/data/doclaynet/label_check\").glob(\"*.png\"))\n",
107
+ "print(f\"{len(checks)} pages to check\\n\")\n",
108
+ "\n",
109
+ "for path in checks[:4]:\n",
110
+ " display(Image.open(path).resize((620, 620)))"
111
+ ]
112
+ },
113
+ {
114
+ "cell_type": "markdown",
115
+ "id": "90c07652",
116
+ "metadata": {},
117
+ "source": [
118
+ "## Train\n"
119
+ ]
120
+ },
121
+ {
122
+ "cell_type": "code",
123
+ "execution_count": null,
124
+ "id": "148ae93a",
125
+ "metadata": {},
126
+ "outputs": [],
127
+ "source": [
128
+ "!python scripts/train.py \\\n",
129
+ " --data /kaggle/working/data/doclaynet/doclaynet.yaml \\\n",
130
+ " --epochs 30 \\\n",
131
+ " --batch 8 \\\n",
132
+ " --imgsz 640 \\\n",
133
+ " --device 0 \\\n",
134
+ " --name rtdetr_doclaynet"
135
+ ]
136
+ },
137
+ {
138
+ "cell_type": "markdown",
139
+ "id": "8f5e102a",
140
+ "metadata": {},
141
+ "source": [
142
+ "## Evaluate\n",
143
+ "\n",
144
+ "Per-class AP, per-category mAP, query saturation. Category breakdown\n",
145
+ "matters most - aggregate mAP can't tell you if it learned structure\n",
146
+ "or just financial reports."
147
+ ]
148
+ },
149
+ {
150
+ "cell_type": "code",
151
+ "execution_count": null,
152
+ "id": "bce9eb4b",
153
+ "metadata": {},
154
+ "outputs": [],
155
+ "source": [
156
+ "!python scripts/evaluate.py \\\n",
157
+ " --weights runs/detect/rtdetr_doclaynet/weights/best.pt \\\n",
158
+ " --data /kaggle/working/data/doclaynet/doclaynet.yaml \\\n",
159
+ " --out reports"
160
+ ]
161
+ },
162
+ {
163
+ "cell_type": "markdown",
164
+ "id": "274d9f83",
165
+ "metadata": {},
166
+ "source": [
167
+ "## Mine failure cases\n",
168
+ "\n",
169
+ "Ranks worst predictions, renders them against ground truth. Memo's\n",
170
+ "failure cases come from here, not guessing."
171
+ ]
172
+ },
173
+ {
174
+ "cell_type": "code",
175
+ "execution_count": null,
176
+ "id": "57865d2e",
177
+ "metadata": {},
178
+ "outputs": [],
179
+ "source": [
180
+ "!python scripts/mine_failures.py \\\n",
181
+ " --weights runs/detect/rtdetr_doclaynet/weights/best.pt \\\n",
182
+ " --data /kaggle/working/data/doclaynet \\\n",
183
+ " --out reports/failures \\\n",
184
+ " --top 25"
185
+ ]
186
+ },
187
+ {
188
+ "cell_type": "markdown",
189
+ "id": "d8e7c81c",
190
+ "metadata": {},
191
+ "source": [
192
+ "## Outputs"
193
+ ]
194
+ },
195
+ {
196
+ "cell_type": "code",
197
+ "execution_count": null,
198
+ "metadata": {},
199
+ "outputs": [],
200
+ "source": [
201
+ "import shutil\n",
202
+ "from pathlib import Path\n",
203
+ "\n",
204
+ "run = Path(\"runs/detect/rtdetr_doclaynet\")\n",
205
+ "out = Path(\"/kaggle/working/submission\")\n",
206
+ "out.mkdir(exist_ok=True)\n",
207
+ "\n",
208
+ "shutil.copy(run / \"weights/best.pt\", out / \"best.pt\")\n",
209
+ "shutil.copy(run / \"run_metadata.json\", out / \"run_metadata.json\")\n",
210
+ "if Path(\"reports\").exists():\n",
211
+ " shutil.copytree(\"reports\", out / \"reports\", dirs_exist_ok=True)\n",
212
+ "\n",
213
+ "for path in sorted(out.rglob(\"*\")):\n",
214
+ " if path.is_file():\n",
215
+ " print(f\"{path.stat().st_size / 1e6:8.1f} MB {path.relative_to(out)}\")"
216
+ ]
217
+ }
218
+ ],
219
+ "metadata": {
220
+ "accelerator": "GPU",
221
+ "kernelspec": {
222
+ "display_name": "Python 3",
223
+ "language": "python",
224
+ "name": "python3"
225
+ },
226
+ "language_info": {
227
+ "name": "python",
228
+ "version": "3.11"
229
+ }
230
+ },
231
+ "nbformat": 4,
232
+ "nbformat_minor": 5
233
+ }
reports/metrics.json ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "weights": "runs/detect/rtdetr_doclaynet/weights/best.pt",
3
+ "overall": {
4
+ "mAP50": 0.6216,
5
+ "mAP50_95": 0.4122,
6
+ "precision": 0.7056,
7
+ "recall": 0.6052
8
+ },
9
+ "per_class_mAP50_95": {
10
+ "Caption": 0.4112,
11
+ "Footnote": 0.0963,
12
+ "Formula": 0.4119,
13
+ "List-item": 0.4518,
14
+ "Page-footer": 0.4962,
15
+ "Page-header": 0.3857,
16
+ "Picture": 0.38,
17
+ "Section-header": 0.4851,
18
+ "Table": 0.6333,
19
+ "Text": 0.6579,
20
+ "Title": 0.1246
21
+ },
22
+ "query_saturation": {
23
+ "measured": true,
24
+ "query_budget": 300,
25
+ "max_regions_on_any_page": 95,
26
+ "mean_regions_per_page": 12.7,
27
+ "pages_over_budget": 0,
28
+ "percent_over_budget": 0.0
29
+ },
30
+ "split_leakage": {
31
+ "measured": true,
32
+ "shared_source_pdfs": 0,
33
+ "test_pages_from_shared_pdfs": 0,
34
+ "test_pages_total": 499,
35
+ "percent_affected": 0.0
36
+ },
37
+ "confusion_matrix_plot": "runs/detect/val/confusion_matrix_normalized.png",
38
+ "per_doc_category": {
39
+ "financial_reports": {
40
+ "pages": 172,
41
+ "mAP50": 0.5349,
42
+ "mAP50_95": 0.3383
43
+ },
44
+ "government_tenders": {
45
+ "pages": 29,
46
+ "mAP50": 0.547,
47
+ "mAP50_95": 0.4103
48
+ },
49
+ "laws_and_regulations": {
50
+ "pages": 86,
51
+ "mAP50": 0.4949,
52
+ "mAP50_95": 0.316
53
+ },
54
+ "manuals": {
55
+ "pages": 77,
56
+ "mAP50": 0.6327,
57
+ "mAP50_95": 0.4321
58
+ },
59
+ "patents": {
60
+ "pages": 32,
61
+ "mAP50": 0.7427,
62
+ "mAP50_95": 0.5509
63
+ },
64
+ "scientific_articles": {
65
+ "pages": 103,
66
+ "mAP50": 0.7451,
67
+ "mAP50_95": 0.5254
68
+ }
69
+ }
70
+ }
reports/run_metadata.json ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "git_commit": "00ed77c89f8b469e5dfa32b96cbf9f8e1d79d332",
3
+ "hyperparameters": {
4
+ "data": "/kaggle/working/data/doclaynet/doclaynet.yaml",
5
+ "epochs": 15,
6
+ "imgsz": 640,
7
+ "batch": 8,
8
+ "device": "0",
9
+ "seed": 42,
10
+ "deterministic": true,
11
+ "amp": true,
12
+ "optimizer": "AdamW",
13
+ "lr0": 0.0001,
14
+ "lrf": 0.01,
15
+ "weight_decay": 0.0001,
16
+ "warmup_epochs": 3.0,
17
+ "cos_lr": true,
18
+ "save_period": 1,
19
+ "patience": 10,
20
+ "workers": 2,
21
+ "cache": false,
22
+ "val": true,
23
+ "plots": true,
24
+ "fliplr": 0.0,
25
+ "flipud": 0.0,
26
+ "mosaic": 0.0,
27
+ "degrees": 3.0,
28
+ "perspective": 0.0005,
29
+ "translate": 0.05,
30
+ "scale": 0.2,
31
+ "shear": 0.0,
32
+ "hsv_h": 0.0,
33
+ "hsv_s": 0.2,
34
+ "hsv_v": 0.2,
35
+ "erasing": 0.0,
36
+ "name": "rtdetr_doclaynet"
37
+ },
38
+ "hardware": {
39
+ "gpu": "Tesla T4",
40
+ "gpu_memory_gb": 14.6,
41
+ "torch_version": "2.10.0+cu128",
42
+ "cuda_version": "12.8"
43
+ },
44
+ "ultralytics_version": "8.3.40",
45
+ "wall_clock_seconds": 8178.6,
46
+ "wall_clock_human": "2.27 h"
47
+ }
requirements.txt ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---------------------------------------------------------------------------
2
+ # Hard Constraint 1 of the brief: NO agentic frameworks.
3
+ # LangChain, LangGraph, CrewAI, AutoGen and equivalents are deliberately
4
+ # absent. The reasoning layer in app/reasoning/ is hand-written and talks to
5
+ # the LLM through Groq's plain SDK only.
6
+ # ---------------------------------------------------------------------------
7
+
8
+ # --- Detection (Hard Constraint 2: RT-DETR, own training code) ---
9
+ ultralytics==8.3.40
10
+ torch>=2.2
11
+ torchvision>=0.17
12
+
13
+ # --- Dataset preparation ---
14
+ datasets>=2.19,<4.0.0
15
+ pillow>=10.0
16
+ numpy>=1.26
17
+ tqdm>=4.66
18
+
19
+ # --- Demo UIs (for Hugging Face Spaces) ---
20
+ # gradio: current HF Space creation flow only offers Gradio/Docker/Static
21
+ # as SDKs, no standalone Streamlit option - gradio_app.py is the one that
22
+ # actually gets deployed there. streamlit_app.py stays for local use /
23
+ # Docker, where SDK choice doesn't matter.
24
+ streamlit>=1.38
25
+ gradio>=4.44
26
+ spaces>=0.30 # only does anything on an actual HF ZeroGPU Space
27
+
28
+ # --- API ---
29
+ python-dotenv>=1.0
30
+ fastapi>=0.115
31
+ uvicorn[standard]>=0.30
32
+ pydantic>=2.7
33
+ python-multipart>=0.0.9
34
+
35
+ # --- Reasoning layer (plain SDK, no framework) ---
36
+ # Groq's own SDK. It is a thin HTTP client over their chat-completions
37
+ # endpoint - it does not chain, plan, or orchestrate anything. All of the
38
+ # routing and reasoning logic lives in app/reasoning/ and is mine.
39
+ groq>=0.11
40
+
41
+ # --- Evaluation / reporting ---
42
+ matplotlib>=3.8
43
+ pandas>=2.2
44
+
scripts/__init__.py ADDED
File without changes
scripts/_render_utils.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Shared by prepare_dataset.py's label check and mine_failures.py's
2
+ # failure renders. PIL's bitmap default font (~10px) turns into an
3
+ # illegible smear once resized for a notebook or memo screenshot - which
4
+ # is the only place either render gets looked at.
5
+ from __future__ import annotations
6
+
7
+
8
+ def load_label_font(size: int):
9
+ """Finds a real scalable font. matplotlib ships its own DejaVu copy
10
+ and is already a pinned dependency, so that's guaranteed to exist -
11
+ a bare "DejaVuSans-Bold.ttf" only works if the OS's font search
12
+ happens to resolve it, which isn't something to bet a Kaggle run on."""
13
+ from PIL import ImageFont
14
+
15
+ candidates = []
16
+
17
+ try:
18
+ import matplotlib
19
+
20
+ candidates.append(f"{matplotlib.get_data_path()}/fonts/ttf/DejaVuSans-Bold.ttf")
21
+ except Exception:
22
+ pass
23
+
24
+ candidates += [
25
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # Debian/Ubuntu, Kaggle
26
+ "DejaVuSans-Bold.ttf", # works if OS font search resolves it (e.g. Windows)
27
+ ]
28
+
29
+ for path in candidates:
30
+ try:
31
+ return ImageFont.truetype(path, size)
32
+ except OSError:
33
+ continue
34
+
35
+ return ImageFont.load_default()
scripts/build_report.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Builds a visual PDF report from reports/metrics.json and
2
+ # reports/run_metadata.json - same numbers as the memo, laid out so
3
+ # someone can understand the results without reading raw JSON.
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from pathlib import Path
8
+
9
+ import matplotlib
10
+ import matplotlib.pyplot as plt
11
+ from matplotlib.backends.backend_pdf import PdfPages
12
+
13
+ matplotlib.rcParams.update({
14
+ "font.family": "sans-serif",
15
+ "font.size": 11,
16
+ "text.color": "#0b0b0b",
17
+ "axes.edgecolor": "#0b0b0b",
18
+ "axes.labelcolor": "#0b0b0b",
19
+ "xtick.color": "#0b0b0b",
20
+ "ytick.color": "#0b0b0b",
21
+ })
22
+
23
+ BLUE = "#2a78d6" # validated single hue, see dataviz skill palette
24
+ GRID = "#d9d9d6" # recessive gridline
25
+ MUTED = "#52514e" # secondary text
26
+
27
+ ROOT = Path(__file__).resolve().parent.parent
28
+ metrics = json.loads((ROOT / "reports/metrics.json").read_text())
29
+ run = json.loads((ROOT / "reports/run_metadata.json").read_text())
30
+
31
+ CLASS_INSTANCES = { # from the evaluate.py console output for this run
32
+ "Caption": 149, "Footnote": 47, "Formula": 150, "List-item": 962,
33
+ "Page-footer": 408, "Page-header": 311, "Picture": 143,
34
+ "Section-header": 873, "Table": 252, "Text": 3002, "Title": 52,
35
+ }
36
+
37
+
38
+ def new_page(figsize=(8.5, 11)):
39
+ fig = plt.figure(figsize=figsize, facecolor="white")
40
+ fig.patch.set_facecolor("white")
41
+ return fig
42
+
43
+
44
+ def bare_axes(fig, rect):
45
+ ax = fig.add_axes(rect)
46
+ ax.set_facecolor("white")
47
+ for spine in ax.spines.values():
48
+ spine.set_visible(False)
49
+ ax.set_xticks([])
50
+ ax.set_yticks([])
51
+ return ax
52
+
53
+
54
+ def page_overview():
55
+ fig = new_page()
56
+ ax = bare_axes(fig, [0, 0, 1, 1])
57
+
58
+ ax.text(0.07, 0.94, "Document Layout Detection — Evaluation Report",
59
+ fontsize=20, fontweight="bold", ha="left")
60
+ ax.text(0.07, 0.905, "RT-DETR-L fine-tuned on DocLayNet, evaluated on the held-out test split",
61
+ fontsize=12, color=MUTED, ha="left")
62
+ ax.axhline(0.885, xmin=0.07, xmax=0.93, color="#0b0b0b", linewidth=1)
63
+
64
+ overall = metrics["overall"]
65
+ stats = [
66
+ ("mAP50", f"{overall['mAP50']:.3f}"),
67
+ ("mAP50-95", f"{overall['mAP50_95']:.3f}"),
68
+ ("Precision", f"{overall['precision']:.3f}"),
69
+ ("Recall", f"{overall['recall']:.3f}"),
70
+ ]
71
+ x0 = 0.07
72
+ box_w = 0.20
73
+ for i, (label, value) in enumerate(stats):
74
+ x = x0 + i * (box_w + 0.013)
75
+ ax.add_patch(plt.Rectangle((x, 0.74), box_w, 0.12, fill=False,
76
+ edgecolor="#0b0b0b", linewidth=1))
77
+ ax.text(x + box_w / 2, 0.815, value, fontsize=22, fontweight="bold",
78
+ ha="center", va="center", color=BLUE)
79
+ ax.text(x + box_w / 2, 0.755, label, fontsize=10.5, ha="center",
80
+ va="center", color=MUTED)
81
+
82
+ ax.text(0.07, 0.68, "Test set: 499 pages, 6,349 annotated regions, 11 classes (none in COCO)",
83
+ fontsize=10.5, color=MUTED)
84
+
85
+ # metric bar chart - 4 distinct measures of the same model, so a small
86
+ # categorical set (not a repeated single-series magnitude chart)
87
+ cats = ["Precision", "Recall", "mAP50", "mAP50-95"]
88
+ vals = [overall["precision"], overall["recall"], overall["mAP50"], overall["mAP50_95"]]
89
+ colors = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100"] # fixed categorical order, slots 1-4
90
+ ax2 = fig.add_axes([0.10, 0.48, 0.80, 0.16])
91
+ bars = ax2.bar(cats, vals, color=colors, width=0.55)
92
+ ax2.set_ylim(0, 1.0)
93
+ ax2.grid(axis="y", color=GRID, linewidth=0.8, zorder=0)
94
+ ax2.set_axisbelow(True)
95
+ for spine in ("top", "right", "left"):
96
+ ax2.spines[spine].set_visible(False)
97
+ ax2.spines["bottom"].set_color("#0b0b0b")
98
+ ax2.tick_params(left=False)
99
+ ax2.set_yticklabels([])
100
+ for bar, v in zip(bars, vals):
101
+ ax2.text(bar.get_x() + bar.get_width() / 2, v + 0.02, f"{v:.3f}",
102
+ ha="center", fontsize=10, fontweight="bold")
103
+
104
+ # training config, factual, no names/dates
105
+ hp = run["hyperparameters"]
106
+ hw = run["hardware"]
107
+ config_lines = [
108
+ f"Epochs: {hp['epochs']} Batch: {hp['batch']} Image size: {hp['imgsz']}px",
109
+ f"Optimizer: {hp['optimizer']} LR: {hp['lr0']} Seed: {hp['seed']}",
110
+ f"Hardware: {hw['gpu']} ({hw['gpu_memory_gb']} GB) Wall clock: {run['wall_clock_human']}",
111
+ f"Query-budget saturation: {metrics['query_saturation']['percent_over_budget']}% of test pages "
112
+ f"(max {metrics['query_saturation']['max_regions_on_any_page']} regions, budget "
113
+ f"{metrics['query_saturation']['query_budget']})",
114
+ f"Train/test source-PDF overlap: {metrics['split_leakage']['percent_affected']}% "
115
+ f"({metrics['split_leakage']['test_pages_total']} test pages checked)",
116
+ ]
117
+ ax.text(0.07, 0.40, "Training configuration", fontsize=13, fontweight="bold")
118
+ for i, line in enumerate(config_lines):
119
+ ax.text(0.07, 0.365 - i * 0.032, line, fontsize=10.5, color="#0b0b0b")
120
+
121
+ return fig
122
+
123
+
124
+ def page_per_class():
125
+ fig = new_page()
126
+ ax_title = bare_axes(fig, [0, 0, 1, 1])
127
+ ax_title.text(0.07, 0.955, "Per-class performance (mAP50)", fontsize=16, fontweight="bold")
128
+ ax_title.text(0.07, 0.928, "Sorted by score. Instance count shown per class — rarity, not size alone, "
129
+ "drives the weakest results.", fontsize=10, color=MUTED)
130
+
131
+ per_class = metrics["overall"] # placeholder, real data below
132
+ items = sorted(
133
+ [(name, CLASS_INSTANCES[name]) for name in CLASS_INSTANCES],
134
+ key=lambda kv: PER_CLASS_MAP50[kv[0]],
135
+ )
136
+ names = [n for n, _ in items]
137
+ values = [PER_CLASS_MAP50[n] for n, _ in items]
138
+ counts = [c for _, c in items]
139
+
140
+ ax = fig.add_axes([0.28, 0.08, 0.62, 0.80])
141
+ y = range(len(names))
142
+ ax.barh(y, values, color=BLUE, height=0.6, zorder=3)
143
+ ax.set_yticks(list(y))
144
+ ax.set_yticklabels(names, fontsize=11)
145
+ ax.set_xlim(0, 1.0)
146
+ ax.grid(axis="x", color=GRID, linewidth=0.8, zorder=0)
147
+ ax.set_axisbelow(True)
148
+ for spine in ("top", "right"):
149
+ ax.spines[spine].set_visible(False)
150
+ ax.spines["left"].set_color("#0b0b0b")
151
+ ax.spines["bottom"].set_color("#0b0b0b")
152
+ ax.set_xlabel("mAP50", fontsize=10.5, color=MUTED)
153
+
154
+ for yi, (v, c) in enumerate(zip(values, counts)):
155
+ ax.text(v + 0.015, yi, f"{v:.3f}", va="center", fontsize=9.5, fontweight="bold")
156
+ ax.text(1.02, yi, f"n={c}", va="center", fontsize=8.5, color=MUTED, transform=ax.get_yaxis_transform())
157
+
158
+ return fig
159
+
160
+
161
+ def page_per_category():
162
+ fig = new_page()
163
+ ax_title = bare_axes(fig, [0, 0, 1, 1])
164
+ ax_title.text(0.07, 0.955, "Per-document-category performance (mAP50)", fontsize=16, fontweight="bold")
165
+ ax_title.text(0.07, 0.928, "Tests generalization across document styles, not just aggregate accuracy.",
166
+ fontsize=10, color=MUTED)
167
+
168
+ cat = metrics["per_doc_category"]
169
+ items = sorted(cat.items(), key=lambda kv: kv[1]["mAP50"])
170
+ names = [k.replace("_", " ") for k, _ in items]
171
+ values = [v["mAP50"] for _, v in items]
172
+ pages = [v["pages"] for _, v in items]
173
+
174
+ ax = fig.add_axes([0.30, 0.55, 0.60, 0.34])
175
+ y = range(len(names))
176
+ ax.barh(y, values, color=BLUE, height=0.55, zorder=3)
177
+ ax.set_yticks(list(y))
178
+ ax.set_yticklabels(names, fontsize=11)
179
+ ax.set_xlim(0, 1.0)
180
+ ax.grid(axis="x", color=GRID, linewidth=0.8, zorder=0)
181
+ ax.set_axisbelow(True)
182
+ for spine in ("top", "right"):
183
+ ax.spines[spine].set_visible(False)
184
+ ax.spines["left"].set_color("#0b0b0b")
185
+ ax.spines["bottom"].set_color("#0b0b0b")
186
+ ax.set_xlabel("mAP50", fontsize=10.5, color=MUTED)
187
+ for yi, (v, p) in enumerate(zip(values, pages)):
188
+ ax.text(v + 0.015, yi, f"{v:.3f} ({p} pages)", va="center", fontsize=9.5, fontweight="bold")
189
+
190
+ # failure notes, factual only
191
+ ax_title.text(0.07, 0.42, "Observed failure patterns", fontsize=13, fontweight="bold")
192
+ notes = [
193
+ "Footnote (47 instances): recall 0.048 — rare class, near-total miss rate.",
194
+ "Page-footer (408 instances): mAP50 0.843 — same thin shape as Footnote, but",
195
+ " 9x more training instances and a consistent position. Rarity, not size,",
196
+ " is the dominant factor.",
197
+ "Picture: precision 0.416 despite recall 0.622 — composite regions (diagrams",
198
+ " with embedded text) get split into multiple overlapping boxes instead",
199
+ " of one region.",
200
+ "Dense repeated-entry layouts (directories, org charts) produce duplicate,",
201
+ " overlapping box proposals rather than one box per entry.",
202
+ ]
203
+ for i, line in enumerate(notes):
204
+ ax_title.text(0.07, 0.385 - i * 0.028, line, fontsize=10, color="#0b0b0b")
205
+
206
+ return fig
207
+
208
+
209
+ PER_CLASS_MAP50 = {
210
+ "Caption": 0.622, "Footnote": 0.182, "Formula": 0.788, "List-item": 0.671,
211
+ "Page-footer": 0.843, "Page-header": 0.658, "Picture": 0.495,
212
+ "Section-header": 0.762, "Table": 0.761, "Text": 0.832, "Title": 0.224,
213
+ }
214
+
215
+ if __name__ == "__main__":
216
+ import sys
217
+
218
+ preview = "--preview" in sys.argv
219
+ out_path = ROOT / "reports" / "evaluation_report.pdf"
220
+ pages = [page_overview(), page_per_class(), page_per_category()]
221
+
222
+ with PdfPages(out_path, metadata={
223
+ "Title": "", "Author": "", "Subject": "", "Creator": "", "Producer": "", "CreationDate": None,
224
+ }) as pdf:
225
+ for i, fig in enumerate(pages, start=1):
226
+ pdf.savefig(fig)
227
+ if preview:
228
+ fig.savefig(ROOT / f"reports/_preview_page{i}.png", dpi=110)
229
+ plt.close(fig)
230
+
231
+ print(f"wrote {out_path}")
scripts/evaluate.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Evaluates the trained model on the held-out test split. One aggregate
2
+ # mAP can't answer the questions that actually matter, so this produces:
3
+ # - per-class AP (which classes are actually weak, and was that
4
+ # predictable given the 640px downscale)
5
+ # - per-document-category mAP (did it learn structure, or just what
6
+ # financial reports look like - the biggest slice of the data)
7
+ # - query-budget saturation rate (RT-DETR has a fixed number of
8
+ # predictions per image - dense pages lose recall for a reason that
9
+ # has nothing to do with training quality)
10
+ # - measured train/test source-PDF overlap (used the author's splits for
11
+ # reproducibility, but that means inheriting whatever leakage they have)
12
+ #
13
+ # Usage:
14
+ # python scripts/evaluate.py --weights runs/detect/rtdetr_doclaynet/weights/best.pt \
15
+ # --data data/doclaynet/doclaynet.yaml --out reports
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import sys
21
+ from collections import Counter, defaultdict
22
+ from pathlib import Path
23
+
24
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
25
+
26
+ from app.constants import CLASS_NAMES, DEFAULT_QUERY_BUDGET # noqa: E402
27
+
28
+
29
+ def _load_manifest(data_root: Path, split: str) -> list[dict]:
30
+ path = data_root / f"manifest_{split}.json"
31
+ if not path.exists():
32
+ print(f" (no manifest at {path} - skipping category breakdown)")
33
+ return []
34
+ return json.loads(path.read_text(encoding="utf-8"))
35
+
36
+
37
+ def measure_split_leakage(data_root: Path) -> dict:
38
+ """Checks if any source PDF has pages on both sides of train/test.
39
+ Used the author's published splits rather than re-splitting (keeps
40
+ numbers comparable to published work), but that means inheriting
41
+ whatever overlap they have. Not fixing it, just measuring and
42
+ reporting it."""
43
+ train = {row["source_pdf"] for row in _load_manifest(data_root, "train")}
44
+ test_rows = _load_manifest(data_root, "test")
45
+ if not train or not test_rows:
46
+ return {"measured": False}
47
+
48
+ shared = {row["source_pdf"] for row in test_rows} & train
49
+ affected = sum(1 for row in test_rows if row["source_pdf"] in shared)
50
+
51
+ return {
52
+ "measured": True,
53
+ "shared_source_pdfs": len(shared),
54
+ "test_pages_from_shared_pdfs": affected,
55
+ "test_pages_total": len(test_rows),
56
+ "percent_affected": round(100 * affected / len(test_rows), 1),
57
+ }
58
+
59
+
60
+ def measure_query_saturation(data_root: Path, budget: int = DEFAULT_QUERY_BUDGET) -> dict:
61
+ """RT-DETR emits a fixed number of boxes per image regardless of
62
+ confidence - a page with more regions than that structurally can't be
63
+ fully detected. Measuring this separately so it doesn't get
64
+ misattributed as a training/recall problem in the failure analysis."""
65
+ rows = _load_manifest(data_root, "test")
66
+ if not rows:
67
+ return {"measured": False}
68
+
69
+ counts = [row["num_regions"] for row in rows]
70
+ saturated = [c for c in counts if c > budget]
71
+
72
+ return {
73
+ "measured": True,
74
+ "query_budget": budget,
75
+ "max_regions_on_any_page": max(counts),
76
+ "mean_regions_per_page": round(sum(counts) / len(counts), 1),
77
+ "pages_over_budget": len(saturated),
78
+ "percent_over_budget": round(100 * len(saturated) / len(counts), 2),
79
+ }
80
+
81
+
82
+ def per_category_map(weights: str, data_root: Path, base_yaml: Path, out_dir: Path) -> dict:
83
+ """Runs validation once per document category by writing a filtered
84
+ image-list yaml per category. Six extra small val passes, turns one
85
+ opaque number into a real generalisation check."""
86
+ rows = _load_manifest(data_root, "test")
87
+ if not rows:
88
+ return {}
89
+
90
+ from ultralytics import RTDETR
91
+
92
+ by_category: dict[str, list[str]] = defaultdict(list)
93
+ for row in rows:
94
+ image_path = data_root / "images" / "test" / f"{row['stem']}.png"
95
+ by_category[row["doc_category"]].append(str(image_path.resolve()))
96
+
97
+ split_dir = out_dir / "category_splits"
98
+ split_dir.mkdir(parents=True, exist_ok=True)
99
+
100
+ base = base_yaml.read_text(encoding="utf-8")
101
+ results: dict[str, dict] = {}
102
+
103
+ for category, images in sorted(by_category.items()):
104
+ # too few pages = noise, not a measurement - skip and say so
105
+ if len(images) < 10:
106
+ results[category] = {"pages": len(images), "skipped": "too few pages to be meaningful"}
107
+ continue
108
+
109
+ listing = split_dir / f"{category}.txt"
110
+ listing.write_text("\n".join(images), encoding="utf-8")
111
+
112
+ yaml_path = split_dir / f"{category}.yaml"
113
+ yaml_path.write_text(
114
+ base.replace("val: images/validation", f"val: {listing.resolve().as_posix()}"),
115
+ encoding="utf-8",
116
+ )
117
+
118
+ metrics = RTDETR(weights).val(data=str(yaml_path), split="val", verbose=False)
119
+ results[category] = {
120
+ "pages": len(images),
121
+ "mAP50": round(float(metrics.box.map50), 4),
122
+ "mAP50_95": round(float(metrics.box.map), 4),
123
+ }
124
+ print(f" {category:22s} {len(images):4d} pages mAP50={metrics.box.map50:.3f}")
125
+
126
+ return results
127
+
128
+
129
+ def main() -> None:
130
+ parser = argparse.ArgumentParser(description=__doc__)
131
+ parser.add_argument("--weights", required=True)
132
+ parser.add_argument("--data", required=True, help="path to doclaynet.yaml")
133
+ parser.add_argument("--out", default="reports")
134
+ args = parser.parse_args()
135
+
136
+ from ultralytics import RTDETR
137
+
138
+ base_yaml = Path(args.data)
139
+ data_root = base_yaml.parent
140
+ out_dir = Path(args.out)
141
+ out_dir.mkdir(parents=True, exist_ok=True)
142
+
143
+ print("Evaluating on the held-out test split...")
144
+ metrics = RTDETR(args.weights).val(data=args.data, split="test", plots=True)
145
+
146
+ # zip against my own class list rather than trusting box.maps' order -
147
+ # a mismatch here would attribute every class's score to its neighbour
148
+ # and still look completely plausible
149
+ per_class = {
150
+ name: round(float(ap), 4)
151
+ for name, ap in zip(CLASS_NAMES, list(metrics.box.maps))
152
+ }
153
+
154
+ report = {
155
+ "weights": args.weights,
156
+ "overall": {
157
+ "mAP50": round(float(metrics.box.map50), 4),
158
+ "mAP50_95": round(float(metrics.box.map), 4),
159
+ "precision": round(float(metrics.box.mp), 4),
160
+ "recall": round(float(metrics.box.mr), 4),
161
+ },
162
+ "per_class_mAP50_95": per_class,
163
+ "query_saturation": measure_query_saturation(data_root),
164
+ "split_leakage": measure_split_leakage(data_root),
165
+ "confusion_matrix_plot": str(Path(metrics.save_dir) / "confusion_matrix_normalized.png"),
166
+ }
167
+
168
+ print("\nPer-document-category breakdown:")
169
+ report["per_doc_category"] = per_category_map(args.weights, data_root, base_yaml, out_dir)
170
+
171
+ path = out_dir / "metrics.json"
172
+ path.write_text(json.dumps(report, indent=2), encoding="utf-8")
173
+
174
+ print(f"\nWrote {path}")
175
+ print(f" overall mAP50 {report['overall']['mAP50']:.3f}")
176
+ print(f" overall mAP50-95 {report['overall']['mAP50_95']:.3f}")
177
+ print("\n weakest classes:")
178
+ for name, ap in sorted(per_class.items(), key=lambda kv: kv[1])[:4]:
179
+ print(f" {name:16s} {ap:.3f}")
180
+
181
+
182
+ if __name__ == "__main__":
183
+ main()
scripts/mine_failures.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Finds the test pages the model got most wrong and renders them side by
2
+ # side with ground truth. The 5 failure cases in the memo come from this,
3
+ # not from guessing - the brief flags "no acknowledged failure cases" as a
4
+ # red flag, so these need to be real, pointable-at examples.
5
+ #
6
+ # Scoring is deliberately crude (greedy IoU 0.5 matching) - just needs to
7
+ # rank pages well enough to surface the interesting ones, not be a precise
8
+ # metric (that's evaluate.py's job).
9
+ #
10
+ # Usage:
11
+ # python scripts/mine_failures.py --weights runs/detect/.../best.pt \
12
+ # --data data/doclaynet --out reports/failures --top 25
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
21
+
22
+ from app.constants import ID_TO_CLASS # noqa: E402
23
+ from scripts._render_utils import load_label_font as _label_font # noqa: E402
24
+
25
+ PALETTE = [
26
+ "#e6194b", "#3cb44b", "#ffe119", "#4363d8", "#f58231", "#911eb4",
27
+ "#46f0f0", "#f032e6", "#bcf60c", "#fabebe", "#008080",
28
+ ]
29
+
30
+
31
+ def iou(a: tuple[float, ...], b: tuple[float, ...]) -> float:
32
+ """IoU for two xyxy boxes."""
33
+ ax1, ay1, ax2, ay2 = a
34
+ bx1, by1, bx2, by2 = b
35
+
36
+ ix1, iy1 = max(ax1, bx1), max(ay1, by1)
37
+ ix2, iy2 = min(ax2, bx2), min(ay2, by2)
38
+ if ix2 <= ix1 or iy2 <= iy1:
39
+ return 0.0
40
+
41
+ intersection = (ix2 - ix1) * (iy2 - iy1)
42
+ union = (ax2 - ax1) * (ay2 - ay1) + (bx2 - bx1) * (by2 - by1) - intersection
43
+ return intersection / union if union > 0 else 0.0
44
+
45
+
46
+ def _read_ground_truth(label_path: Path, width: int, height: int) -> list[tuple[int, tuple[float, ...]]]:
47
+ """Reads a YOLO label file back into absolute xyxy boxes."""
48
+ if not label_path.exists():
49
+ return []
50
+
51
+ boxes = []
52
+ for line in label_path.read_text(encoding="utf-8").splitlines():
53
+ if not line.strip():
54
+ continue
55
+ class_id, xc, yc, w, h = line.split()
56
+ xc, yc, w, h = float(xc) * width, float(yc) * height, float(w) * width, float(h) * height
57
+ boxes.append((int(class_id), (xc - w / 2, yc - h / 2, xc + w / 2, yc + h / 2)))
58
+ return boxes
59
+
60
+
61
+ def score_page(
62
+ ground_truth: list[tuple[int, tuple[float, ...]]],
63
+ predictions: list[tuple[int, tuple[float, ...], float]],
64
+ iou_threshold: float = 0.5,
65
+ ) -> dict:
66
+ """Greedy match + counts. Splits misses from misclassifications since
67
+ they have different causes - a miss is usually small/thin/query-
68
+ starved, a misclassification is usually genuine label ambiguity
69
+ (Text vs List-item, Title vs Section-header). That split is what lets
70
+ the memo talk about annotation ambiguity instead of just "missed stuff"."""
71
+ unmatched_gt = list(range(len(ground_truth)))
72
+ matched_predictions: set[int] = set()
73
+
74
+ correct = 0
75
+ misclassified = 0
76
+
77
+ for gt_index in list(unmatched_gt):
78
+ gt_class, gt_box = ground_truth[gt_index]
79
+
80
+ best_index, best_iou = None, iou_threshold
81
+ for pred_index, (_, pred_box, _) in enumerate(predictions):
82
+ if pred_index in matched_predictions:
83
+ continue
84
+ overlap = iou(gt_box, pred_box)
85
+ if overlap >= best_iou:
86
+ best_index, best_iou = pred_index, overlap
87
+
88
+ if best_index is not None:
89
+ matched_predictions.add(best_index)
90
+ unmatched_gt.remove(gt_index)
91
+ if predictions[best_index][0] == gt_class:
92
+ correct += 1
93
+ else:
94
+ misclassified += 1
95
+
96
+ false_positives = len(predictions) - len(matched_predictions)
97
+
98
+ return {
99
+ "ground_truth_regions": len(ground_truth),
100
+ "predicted_regions": len(predictions),
101
+ "correct": correct,
102
+ "misclassified": misclassified,
103
+ "missed": len(unmatched_gt),
104
+ "false_positives": false_positives,
105
+ # misclassifications weighted higher - they're the interesting ones
106
+ "error_score": len(unmatched_gt) + false_positives + 1.5 * misclassified,
107
+ }
108
+
109
+
110
+ def _draw_labelled_box(draw, colour: str, x1, y1, x2, y2, label: str, font) -> None:
111
+ """Box outline plus a solid background behind the label, legible over
112
+ any page content."""
113
+ draw.rectangle([x1, y1, x2, y2], outline=colour, width=4)
114
+ text_box = draw.textbbox((x1, y1), label, font=font)
115
+ draw.rectangle(
116
+ [text_box[0] - 2, text_box[1] - 2, text_box[2] + 2, text_box[3] + 2],
117
+ fill=colour,
118
+ )
119
+ draw.text((x1, y1), label, font=font, fill="white")
120
+
121
+
122
+ def _render_comparison(image, ground_truth, predictions, out_path: Path) -> None:
123
+ """Ground truth on the left, prediction on the right."""
124
+ from PIL import Image, ImageDraw
125
+
126
+ width, height = image.size
127
+ canvas = Image.new("RGB", (width * 2 + 20, height + 40), "white")
128
+ canvas.paste(image, (0, 40))
129
+ canvas.paste(image, (width + 20, 40))
130
+
131
+ draw = ImageDraw.Draw(canvas)
132
+ header_font = _label_font(24)
133
+ label_font = _label_font(22)
134
+ draw.text((10, 8), "GROUND TRUTH", font=header_font, fill="black")
135
+ draw.text((width + 30, 8), "PREDICTION", font=header_font, fill="black")
136
+
137
+ for class_id, (x1, y1, x2, y2) in ground_truth:
138
+ colour = PALETTE[class_id % len(PALETTE)]
139
+ _draw_labelled_box(draw, colour, x1, y1 + 40, x2, y2 + 40,
140
+ ID_TO_CLASS.get(class_id, "?"), label_font)
141
+
142
+ offset = width + 20
143
+ for class_id, (x1, y1, x2, y2), confidence in predictions:
144
+ colour = PALETTE[class_id % len(PALETTE)]
145
+ label = f"{ID_TO_CLASS.get(class_id, '?')} {confidence:.2f}"
146
+ _draw_labelled_box(draw, colour, x1 + offset, y1 + 40, x2 + offset, y2 + 40,
147
+ label, label_font)
148
+
149
+ canvas.save(out_path)
150
+
151
+
152
+ def main() -> None:
153
+ parser = argparse.ArgumentParser(description=__doc__)
154
+ parser.add_argument("--weights", required=True)
155
+ parser.add_argument("--data", required=True, help="dataset root containing images/ and labels/")
156
+ parser.add_argument("--out", default="reports/failures")
157
+ parser.add_argument("--top", type=int, default=25)
158
+ parser.add_argument("--conf", type=float, default=0.25)
159
+ args = parser.parse_args()
160
+
161
+ from PIL import Image
162
+ from ultralytics import RTDETR
163
+
164
+ data_root = Path(args.data)
165
+ image_dir = data_root / "images" / "test"
166
+ label_dir = data_root / "labels" / "test"
167
+ out_dir = Path(args.out)
168
+ out_dir.mkdir(parents=True, exist_ok=True)
169
+
170
+ model = RTDETR(args.weights)
171
+ images = sorted(image_dir.glob("*.png"))
172
+ print(f"Scoring {len(images)} test pages...")
173
+
174
+ manifest_path = data_root / "manifest_test.json"
175
+ categories = {}
176
+ if manifest_path.exists():
177
+ categories = {
178
+ row["stem"]: row["doc_category"]
179
+ for row in json.loads(manifest_path.read_text(encoding="utf-8"))
180
+ }
181
+
182
+ scored = []
183
+ for index, image_path in enumerate(images):
184
+ image = Image.open(image_path).convert("RGB")
185
+ width, height = image.size
186
+
187
+ result = model.predict(image, conf=args.conf, verbose=False)[0]
188
+ predictions = [
189
+ (int(cls), tuple(float(v) for v in box), float(conf))
190
+ for cls, box, conf in zip(
191
+ result.boxes.cls.tolist(),
192
+ result.boxes.xyxy.tolist(),
193
+ result.boxes.conf.tolist(),
194
+ )
195
+ ]
196
+
197
+ ground_truth = _read_ground_truth(label_dir / f"{image_path.stem}.txt", width, height)
198
+ score = score_page(ground_truth, predictions)
199
+ score["stem"] = image_path.stem
200
+ score["doc_category"] = categories.get(image_path.stem, "unknown")
201
+ scored.append((score, image, ground_truth, predictions))
202
+
203
+ if (index + 1) % 100 == 0:
204
+ print(f" {index + 1}/{len(images)}", flush=True)
205
+
206
+ scored.sort(key=lambda item: item[0]["error_score"], reverse=True)
207
+
208
+ summary = []
209
+ for rank, (score, image, ground_truth, predictions) in enumerate(scored[: args.top], start=1):
210
+ out_path = out_dir / f"{rank:02d}_{score['stem']}_err{score['error_score']:.0f}.png"
211
+ _render_comparison(image, ground_truth, predictions, out_path)
212
+ score["render"] = out_path.name
213
+ summary.append(score)
214
+
215
+ (out_dir / "failure_summary.json").write_text(
216
+ json.dumps(summary, indent=2), encoding="utf-8"
217
+ )
218
+
219
+ print(f"\nWrote {len(summary)} comparison renders to {out_dir}")
220
+ print("\nWorst pages:")
221
+ for score in summary[:8]:
222
+ print(
223
+ f" {score['stem']:20s} {score['doc_category']:20s} "
224
+ f"missed={score['missed']:3d} misclassified={score['misclassified']:3d} "
225
+ f"fp={score['false_positives']:3d}"
226
+ )
227
+
228
+
229
+ if __name__ == "__main__":
230
+ main()
scripts/prepare_dataset.py ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Converts the HF DocLayNet-base dataset into the image/label layout
2
+ # Ultralytics expects. Everything that can go wrong here goes wrong
3
+ # silently - bad labels don't raise, training just quietly learns the
4
+ # wrong thing. Three things beyond the obvious conversion:
5
+ # 1. collapses repeated per-line boxes (a 40-region page can show up as
6
+ # ~1,100 duplicate annotations otherwise)
7
+ # 2. drops degenerate boxes and counts them (zero-area -> NaN loss later)
8
+ # 3. can render a sample with decoded labels drawn on, so I can eyeball
9
+ # that the class index mapping is actually right
10
+ #
11
+ # Usage:
12
+ # python scripts/prepare_dataset.py --out data/doclaynet
13
+ # python scripts/prepare_dataset.py --out data/doclaynet --verify 12
14
+ # python scripts/prepare_dataset.py --out data/doclaynet --limit 20 # smoke test
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import random
20
+ from collections import Counter
21
+ from pathlib import Path
22
+
23
+ import sys
24
+
25
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
26
+
27
+ from app.constants import CLASS_NAMES, ID_TO_CLASS # noqa: E402
28
+
29
+ HF_DATASET = "pierreguillou/DocLayNet-base"
30
+
31
+ # HF split names don't match Ultralytics' directory convention
32
+ SPLITS = {"train": "train", "validation": "validation", "test": "test"}
33
+
34
+
35
+ def coco_to_yolo(
36
+ bbox: list[float], img_w: int, img_h: int
37
+ ) -> tuple[float, float, float, float]:
38
+ """DocLayNet gives [x, y, w, h] top-left in pixels. Ultralytics wants
39
+ [x_centre, y_centre, w, h] normalised 0-1. Easy to get the corner-to-
40
+ centre shift wrong without noticing - tested for that reason."""
41
+ x, y, w, h = bbox
42
+ x_centre = (x + w / 2) / img_w
43
+ y_centre = (y + h / 2) / img_h
44
+ return (x_centre, y_centre, w / img_w, h / img_h)
45
+
46
+
47
+ def is_valid_bbox(bbox: list[float], img_w: int, img_h: int) -> bool:
48
+ """Rejects zero-area/out-of-bounds boxes before they turn into NaN
49
+ losses several epochs in. Counts rejections rather than just dropping
50
+ silently."""
51
+ x, y, w, h = bbox
52
+ if w <= 0 or h <= 0:
53
+ return False
54
+ if x < 0 or y < 0:
55
+ return False
56
+ if x + w > img_w or y + h > img_h:
57
+ return False
58
+ return True
59
+
60
+
61
+ def dedupe_annotations(
62
+ bboxes: list[list[float]], categories: list[int]
63
+ ) -> list[tuple[tuple[float, ...], int]]:
64
+ """DocLayNet-base repeats a block's box once per text line inside it -
65
+ a paragraph with 6 lines shows up as 6 identical entries. Dedupe on
66
+ (box, category) rather than box alone, since two classes occasionally
67
+ share the same extent. First-seen order kept so output is reproducible."""
68
+ if len(bboxes) != len(categories):
69
+ raise ValueError(
70
+ f"bboxes and categories must be the same length, "
71
+ f"got {len(bboxes)} and {len(categories)}. "
72
+ "If this fires, the dataset schema is not what this script assumes."
73
+ )
74
+
75
+ seen: set[tuple[tuple[float, ...], int]] = set()
76
+ unique: list[tuple[tuple[float, ...], int]] = []
77
+
78
+ for bbox, category in zip(bboxes, categories):
79
+ key = (tuple(float(v) for v in bbox), int(category))
80
+ if key not in seen:
81
+ seen.add(key)
82
+ unique.append(key)
83
+
84
+ return unique
85
+
86
+
87
+ def _write_split(
88
+ dataset, split_name: str, out_dir: Path, limit: int | None
89
+ ) -> dict:
90
+ """Writes one split to disk, returns the counts (these end up in the
91
+ memo, especially the dedupe ratio - "removed 96% of raw annotations"
92
+ needs a number next to it or it sounds like something broke)."""
93
+ image_dir = out_dir / "images" / split_name
94
+ label_dir = out_dir / "labels" / split_name
95
+ image_dir.mkdir(parents=True, exist_ok=True)
96
+ label_dir.mkdir(parents=True, exist_ok=True)
97
+
98
+ rows = dataset[split_name]
99
+ total = len(rows) if limit is None else min(limit, len(rows))
100
+
101
+ stats = {
102
+ "images": 0,
103
+ "raw_annotations": 0,
104
+ "after_dedupe": 0,
105
+ "dropped_invalid": 0,
106
+ "empty_pages": 0,
107
+ "class_counts": Counter(),
108
+ "doc_category_counts": Counter(),
109
+ }
110
+
111
+ # per-image manifest for evaluate.py's per-category mAP breakdown, and
112
+ # for measuring train/test source-PDF leakage
113
+ manifest: list[dict] = []
114
+
115
+ for index in range(total):
116
+ row = rows[index]
117
+ image = row["image"]
118
+ img_w, img_h = image.size
119
+
120
+ stats["raw_annotations"] += len(row["bboxes_block"])
121
+
122
+ annotations = dedupe_annotations(row["bboxes_block"], row["categories"])
123
+ stats["after_dedupe"] += len(annotations)
124
+
125
+ lines = []
126
+ for bbox, category in annotations:
127
+ if not is_valid_bbox(list(bbox), img_w, img_h):
128
+ stats["dropped_invalid"] += 1
129
+ continue
130
+ xc, yc, w, h = coco_to_yolo(list(bbox), img_w, img_h)
131
+ lines.append(f"{category} {xc:.6f} {yc:.6f} {w:.6f} {h:.6f}")
132
+ stats["class_counts"][ID_TO_CLASS.get(category, f"UNKNOWN_{category}")] += 1
133
+
134
+ # blank page is legit, but also what a conversion bug looks like
135
+ if not lines:
136
+ stats["empty_pages"] += 1
137
+
138
+ stem = f"{split_name}_{index:06d}"
139
+ image.convert("RGB").save(image_dir / f"{stem}.png")
140
+ (label_dir / f"{stem}.txt").write_text("\n".join(lines), encoding="utf-8")
141
+
142
+ doc_category = row.get("doc_category", "unknown")
143
+ stats["doc_category_counts"][doc_category] += 1
144
+ stats["images"] += 1
145
+
146
+ manifest.append({
147
+ "stem": stem,
148
+ "doc_category": doc_category,
149
+ "source_pdf": row.get("original_filename", "unknown"),
150
+ "num_regions": len(lines),
151
+ })
152
+
153
+ if stats["images"] % 500 == 0:
154
+ print(f" {split_name}: {stats['images']}/{total}", flush=True)
155
+
156
+ (out_dir / f"manifest_{split_name}.json").write_text(
157
+ json.dumps(manifest, indent=2), encoding="utf-8"
158
+ )
159
+
160
+ stats["class_counts"] = dict(stats["class_counts"])
161
+ stats["doc_category_counts"] = dict(stats["doc_category_counts"])
162
+ return stats
163
+
164
+
165
+ def _render_label_check(dataset, out_dir: Path, sample_size: int) -> None:
166
+ """Draws decoded class labels on a sample of pages so I can eyeball
167
+ them. My class ordering (0-indexed alphabetical) is inferred, not
168
+ confirmed - if it's off by one, Table becomes Section-header
169
+ everywhere and training won't complain about it. No automated test
170
+ catches that, only looking does."""
171
+ from PIL import ImageDraw
172
+
173
+ from scripts._render_utils import load_label_font
174
+
175
+ label_font = load_label_font(28)
176
+
177
+ check_dir = out_dir / "label_check"
178
+ check_dir.mkdir(parents=True, exist_ok=True)
179
+
180
+ rows = dataset["train"]
181
+ random.seed(42)
182
+ indices = random.sample(range(len(rows)), min(sample_size, len(rows)))
183
+
184
+ palette = [
185
+ "#e6194b", "#3cb44b", "#ffe119", "#4363d8", "#f58231", "#911eb4",
186
+ "#46f0f0", "#f032e6", "#bcf60c", "#fabebe", "#008080",
187
+ ]
188
+
189
+ for index in indices:
190
+ row = rows[index]
191
+ image = row["image"].convert("RGB")
192
+ draw = ImageDraw.Draw(image)
193
+
194
+ for bbox, category in dedupe_annotations(row["bboxes_block"], row["categories"]):
195
+ x, y, w, h = bbox
196
+ colour = palette[category % len(palette)]
197
+ label = ID_TO_CLASS.get(category, "?")
198
+ draw.rectangle([x, y, x + w, y + h], outline=colour, width=4)
199
+
200
+ # solid background behind the label - plain text is invisible
201
+ # on dark scanned content otherwise
202
+ text_box = draw.textbbox((x, y), label, font=label_font)
203
+ draw.rectangle(
204
+ [text_box[0] - 2, text_box[1] - 2, text_box[2] + 2, text_box[3] + 2],
205
+ fill=colour,
206
+ )
207
+ draw.text((x, y), label, font=label_font, fill="white")
208
+
209
+ image.save(check_dir / f"check_{index:06d}.png")
210
+
211
+ print(f"\nWrote {len(indices)} annotated pages to {check_dir}")
212
+ print("LOOK AT THESE before training. If the box labelled 'Table' is not")
213
+ print("drawn around a table, the class index base is wrong and everything")
214
+ print("downstream will be quietly meaningless.\n")
215
+
216
+
217
+ def main() -> None:
218
+ parser = argparse.ArgumentParser(description=__doc__)
219
+ parser.add_argument("--out", default="data/doclaynet", help="output root")
220
+ parser.add_argument(
221
+ "--limit", type=int, default=None,
222
+ help="cap images per split - for smoke tests only, not for a real run",
223
+ )
224
+ parser.add_argument(
225
+ "--verify", type=int, default=0,
226
+ help="render this many annotated pages for visual label checking",
227
+ )
228
+ args = parser.parse_args()
229
+
230
+ out_dir = Path(args.out)
231
+ out_dir.mkdir(parents=True, exist_ok=True)
232
+
233
+ from datasets import ClassLabel, Features, Sequence, Value
234
+ from datasets import load_dataset
235
+ from datasets.features import Image as HFImage
236
+
237
+ # The dataset's own loading script declares these as int64, but the
238
+ # real coordinates are floats (hit one directly: 139.664355). Newer
239
+ # pyarrow refuses that lossy cast where older versions silently
240
+ # floored it. Can't edit someone else's script, so override the
241
+ # schema on load instead - copied field-for-field, only these two
242
+ # fixed to float64.
243
+ doclaynet_features = Features({
244
+ "id": Value("string"),
245
+ "texts": Sequence(Value("string")),
246
+ "bboxes_block": Sequence(Sequence(Value("float64"))),
247
+ "bboxes_line": Sequence(Sequence(Value("float64"))),
248
+ "categories": Sequence(ClassLabel(names=CLASS_NAMES)),
249
+ "image": HFImage(),
250
+ "page_hash": Value("string"),
251
+ "original_filename": Value("string"),
252
+ "page_no": Value("int32"),
253
+ "num_pages": Value("int32"),
254
+ "original_width": Value("int32"),
255
+ "original_height": Value("int32"),
256
+ "coco_width": Value("int32"),
257
+ "coco_height": Value("int32"),
258
+ "collection": Value("string"),
259
+ "doc_category": Value("string"),
260
+ })
261
+
262
+ print(f"Loading {HF_DATASET} (3.8 GB on first run, cached after)...", flush=True)
263
+ try:
264
+ # trust_remote_code=True: this repo ships a loading script rather
265
+ # than static parquet. Without this it prompts for confirmation,
266
+ # which hangs forever under Kaggle's non-interactive commit runs.
267
+ # Checked what the script does first - it's just the author's own
268
+ # DocLayNet -> HF datasets conversion, nothing else.
269
+ dataset = load_dataset(HF_DATASET, trust_remote_code=True, features=doclaynet_features)
270
+ except RuntimeError as error:
271
+ # datasets>=4.0.0 dropped loading-script support entirely -
272
+ # requirements.txt pins <4.0.0 for this reason, but if an
273
+ # environment already has a newer version cached, this is what
274
+ # they'll hit.
275
+ if "no longer supported" in str(error):
276
+ raise RuntimeError(
277
+ f"{error}\n\n"
278
+ "This dataset repo uses the old Hugging Face 'loading script' "
279
+ "format, which datasets>=4.0.0 removed support for entirely. "
280
+ "Fix: pip install \"datasets<4.0.0\" (already pinned in "
281
+ "requirements.txt - your environment likely has a newer "
282
+ "version cached from something else)."
283
+ ) from error
284
+ raise
285
+
286
+ if args.verify:
287
+ _render_label_check(dataset, out_dir, args.verify)
288
+
289
+ report = {"dataset": HF_DATASET, "splits": {}}
290
+ for hf_split, dir_name in SPLITS.items():
291
+ print(f"Converting split '{hf_split}'...", flush=True)
292
+ report["splits"][dir_name] = _write_split(dataset, hf_split, out_dir, args.limit)
293
+
294
+ _write_data_yaml(out_dir)
295
+
296
+ report_path = out_dir / "prep_report.json"
297
+ report_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
298
+
299
+ print(f"\nWrote {report_path}")
300
+ for split, stats in report["splits"].items():
301
+ raw, kept = stats["raw_annotations"], stats["after_dedupe"]
302
+ shrink = (1 - kept / raw) * 100 if raw else 0
303
+ print(
304
+ f" {split:11s} {stats['images']:5d} images | "
305
+ f"{raw:7d} raw -> {kept:6d} annotations ({shrink:.1f}% were repeats) | "
306
+ f"{stats['dropped_invalid']} invalid dropped"
307
+ )
308
+
309
+
310
+ def _write_data_yaml(out_dir: Path) -> None:
311
+ """Generates the Ultralytics config instead of hand-writing it, so
312
+ class names can't drift from app/constants.py."""
313
+ names = "\n".join(f" {idx}: {name}" for idx, name in enumerate(CLASS_NAMES))
314
+ yaml = (
315
+ "# Generated by scripts/prepare_dataset.py - do not edit by hand.\n"
316
+ "# Class names come from app/constants.py so the two cannot drift.\n"
317
+ f"path: {out_dir.resolve().as_posix()}\n"
318
+ "train: images/train\n"
319
+ "val: images/validation\n"
320
+ "test: images/test\n"
321
+ "\n"
322
+ "names:\n"
323
+ f"{names}\n"
324
+ )
325
+ (out_dir / "doclaynet.yaml").write_text(yaml, encoding="utf-8")
326
+
327
+
328
+ if __name__ == "__main__":
329
+ main()
scripts/train.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Fine-tunes RT-DETR on the prepared DocLayNet data. Every hyperparameter
2
+ # is set explicitly (nothing left to Ultralytics defaults) and the run
3
+ # writes a metadata file with GPU, wall-clock time and library versions,
4
+ # since the brief caps Part A at 50% if the run isn't reproducible.
5
+ #
6
+ # Usage:
7
+ # python scripts/train.py --data data/doclaynet/doclaynet.yaml
8
+ # python scripts/train.py --data ... --epochs 1 --smoke # CPU smoke test
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import subprocess
14
+ import time
15
+ from pathlib import Path
16
+
17
+ RANDOM_SEED = 42
18
+
19
+
20
+ def _build_train_args(data: str, epochs: int, batch: int, imgsz: int, device: str) -> dict:
21
+ """All the hyperparameters. The augmentation block is the part worth
22
+ reading - three of Ultralytics' defaults are actively wrong for
23
+ documents:
24
+ - fliplr 0.5 mirrors pages, which don't have a valid mirrored layout
25
+ (text direction, page numbers in fixed corners)
26
+ - flipud same problem, more obviously (upside-down page)
27
+ - mosaic composites 4 images into one, destroying whole-page
28
+ structure (header at top, footer at bottom) that the model needs
29
+ Kept small rotation + mild perspective - those model real scan/camera
30
+ skew and are the only hedge against the hidden eval set containing
31
+ photographed rather than rendered pages.
32
+
33
+ lr0=1e-4 not the usual YOLO 1e-2 - DETR-family transformer components
34
+ are unstable at high LR, and I'm fine-tuning from COCO weights, not
35
+ training from scratch."""
36
+ return {
37
+ "data": data,
38
+ "epochs": epochs,
39
+ "imgsz": imgsz,
40
+ "batch": batch,
41
+ "device": device,
42
+ "seed": RANDOM_SEED,
43
+ "deterministic": True,
44
+
45
+ "amp": True, # needed to fit RT-DETR-L at batch 8 in 16GB on a T4
46
+
47
+ "optimizer": "AdamW",
48
+ "lr0": 1e-4,
49
+ "lrf": 0.01,
50
+ "weight_decay": 1e-4,
51
+ "warmup_epochs": 3.0,
52
+ "cos_lr": True,
53
+
54
+ "save_period": 1, # checkpoint every epoch - Kaggle sessions can drop
55
+ "patience": 10,
56
+
57
+ "workers": 2, # Kaggle's few CPU cores - more workers stall
58
+ "cache": False, # 8k pages at 1025px won't fit in RAM
59
+ "val": True,
60
+ "plots": True,
61
+
62
+ # --- augmentation, see docstring above ---
63
+ "fliplr": 0.0,
64
+ "flipud": 0.0,
65
+ "mosaic": 0.0,
66
+ "degrees": 3.0,
67
+ "perspective": 0.0005,
68
+ "translate": 0.05,
69
+ "scale": 0.2,
70
+ "shear": 0.0,
71
+ "hsv_h": 0.0, # document colour isn't a useful signal
72
+ "hsv_s": 0.2,
73
+ "hsv_v": 0.2, # scan brightness genuinely varies
74
+ "erasing": 0.0, # occluding part of a region changes its class
75
+ }
76
+
77
+
78
+ def _git_commit() -> str:
79
+ try:
80
+ return subprocess.check_output(
81
+ ["git", "rev-parse", "HEAD"], text=True, stderr=subprocess.DEVNULL
82
+ ).strip()
83
+ except Exception:
84
+ return "unknown"
85
+
86
+
87
+ def _write_run_metadata(out_dir: Path, train_args: dict, seconds: float) -> None:
88
+ """Everything needed to reproduce this run. GPU name matters most -
89
+ "took 3 hours" means nothing without knowing what it ran on, and
90
+ Kaggle doesn't always give you the accelerator you asked for."""
91
+ import torch
92
+ import ultralytics
93
+
94
+ gpu_name, gpu_memory_gb = "cpu", None
95
+ if torch.cuda.is_available():
96
+ gpu_name = torch.cuda.get_device_name(0)
97
+ gpu_memory_gb = round(
98
+ torch.cuda.get_device_properties(0).total_memory / 1024**3, 1
99
+ )
100
+
101
+ metadata = {
102
+ "git_commit": _git_commit(),
103
+ "hyperparameters": train_args,
104
+ "hardware": {
105
+ "gpu": gpu_name,
106
+ "gpu_memory_gb": gpu_memory_gb,
107
+ "torch_version": torch.__version__,
108
+ "cuda_version": torch.version.cuda,
109
+ },
110
+ "ultralytics_version": ultralytics.__version__,
111
+ "wall_clock_seconds": round(seconds, 1),
112
+ "wall_clock_human": f"{seconds / 3600:.2f} h",
113
+ }
114
+
115
+ path = out_dir / "run_metadata.json"
116
+ path.write_text(json.dumps(metadata, indent=2), encoding="utf-8")
117
+ print(f"\nRun metadata written to {path}")
118
+ print(f" GPU: {gpu_name} ({gpu_memory_gb} GB)")
119
+ print(f" Time: {seconds / 3600:.2f} h")
120
+
121
+
122
+ def main() -> None:
123
+ parser = argparse.ArgumentParser(description=__doc__)
124
+ parser.add_argument("--data", default="data/doclaynet/doclaynet.yaml")
125
+ parser.add_argument("--epochs", type=int, default=30)
126
+ parser.add_argument("--batch", type=int, default=8)
127
+ parser.add_argument("--imgsz", type=int, default=640)
128
+ parser.add_argument("--device", default="0")
129
+ parser.add_argument("--name", default="rtdetr_doclaynet")
130
+ parser.add_argument(
131
+ "--smoke", action="store_true",
132
+ help="tiny CPU run to prove the script works before spending GPU quota",
133
+ )
134
+ args = parser.parse_args()
135
+
136
+ if args.smoke:
137
+ args.epochs, args.batch, args.device, args.imgsz = 1, 2, "cpu", 320
138
+ args.name = "smoke"
139
+
140
+ from ultralytics import RTDETR
141
+ from ultralytics.utils import SETTINGS
142
+
143
+ # Ultralytics auto-registers a Ray Tune callback whenever `ray` is
144
+ # importable, no check for whether Tune is actually running. Kaggle
145
+ # ships ray pre-installed for other stuff, so this callback silently
146
+ # activates and crashes end-of-epoch calling a Ray internal API that
147
+ # doesn't exist in the installed version. Not using Ray Tune anywhere
148
+ # here, so just turn the integration off before .train() runs.
149
+ SETTINGS["raytune"] = False
150
+
151
+ # Starting from COCO weights, not scratch. None of my 11 classes exist
152
+ # in COCO so the detection head gets zero class knowledge for free -
153
+ # the pretrained backbone just gives generic visual features. Training
154
+ # from random init wouldn't converge in the epoch budget I have.
155
+ model = RTDETR("rtdetr-l.pt")
156
+
157
+ train_args = _build_train_args(args.data, args.epochs, args.batch, args.imgsz, args.device)
158
+ train_args["name"] = args.name
159
+
160
+ print(f"Training RT-DETR-L | {args.epochs} epochs | batch {args.batch} | {args.imgsz}px")
161
+ started = time.time()
162
+ results = model.train(**train_args)
163
+ elapsed = time.time() - started
164
+
165
+ out_dir = Path(results.save_dir)
166
+ _write_run_metadata(out_dir, train_args, elapsed)
167
+ print(f"\nBest weights: {out_dir / 'weights' / 'best.pt'}")
168
+
169
+
170
+ if __name__ == "__main__":
171
+ main()
streamlit_app.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Streamlit demo UI for Hugging Face Spaces. Reuses the same Detector and
2
+ # reasoning pipeline as the FastAPI app - no duplicated logic, just a
3
+ # visual layer for showing the thing actually working.
4
+ from __future__ import annotations
5
+
6
+ import os
7
+
8
+ from dotenv import load_dotenv
9
+
10
+ load_dotenv() # loads .env locally, no-op on HF Spaces (uses Space secrets instead)
11
+
12
+ import streamlit as st
13
+ from PIL import Image, ImageDraw
14
+
15
+ from app.constants import CLASS_NAMES, MODEL_VERSION
16
+ from app.detector import Detector
17
+ from app.reasoning.pipeline import answer_question
18
+ from scripts._render_utils import load_label_font
19
+
20
+ st.set_page_config(page_title="Document Layout Detection", page_icon="\U0001F4C4", layout="wide")
21
+
22
+ PALETTE = [
23
+ "#e6194b", "#3cb44b", "#ffe119", "#4363d8", "#f58231", "#911eb4",
24
+ "#46f0f0", "#f032e6", "#bcf60c", "#fabebe", "#008080",
25
+ ]
26
+
27
+
28
+ @st.cache_resource
29
+ def get_detector() -> Detector:
30
+ # cache_resource so weights load once per container, not per request
31
+ detector = Detector()
32
+ try:
33
+ detector.load()
34
+ except FileNotFoundError:
35
+ pass # surfaced in the UI below instead of crashing the app
36
+ return detector
37
+
38
+
39
+ def draw_detections(image: Image.Image, detections) -> Image.Image:
40
+ annotated = image.copy()
41
+ draw = ImageDraw.Draw(annotated)
42
+ font = load_label_font(18)
43
+
44
+ for det in detections:
45
+ colour = PALETTE[det.class_id % len(PALETTE)]
46
+ box = det.bbox
47
+ draw.rectangle([box.x1, box.y1, box.x2, box.y2], outline=colour, width=3)
48
+ label = f"{det.class_name} {det.confidence:.2f}"
49
+ text_box = draw.textbbox((box.x1, box.y1), label, font=font)
50
+ draw.rectangle(
51
+ [text_box[0] - 2, text_box[1] - 2, text_box[2] + 2, text_box[3] + 2],
52
+ fill=colour,
53
+ )
54
+ draw.text((box.x1, box.y1), label, font=font, fill="white")
55
+
56
+ return annotated
57
+
58
+
59
+ detector = get_detector()
60
+
61
+ st.title("Constrained Document Layout Detection")
62
+ st.caption(f"RT-DETR fine-tuned on DocLayNet - {MODEL_VERSION}")
63
+
64
+ if not detector.is_loaded:
65
+ st.warning(
66
+ f"Model weights not found at `{os.environ.get('MODEL_PATH', './weights/best.pt')}`. "
67
+ "Detection and Q&A won't work until weights are available - see the README "
68
+ "for the download link, or set the MODEL_PATH secret on this Space."
69
+ )
70
+
71
+ with st.sidebar:
72
+ st.subheader("Classes")
73
+ st.write(", ".join(CLASS_NAMES))
74
+ st.subheader("Model")
75
+ st.write("loaded" if detector.is_loaded else "not loaded")
76
+ if not os.environ.get("GROQ_API_KEY"):
77
+ st.info("GROQ_API_KEY not set - the Ask tab needs it for the reasoning layer.")
78
+
79
+ tab_detect, tab_ask = st.tabs(["Detect", "Ask"])
80
+
81
+ with tab_detect:
82
+ st.write("Upload a document page to see the detected layout regions.")
83
+ uploaded = st.file_uploader("Image", type=["png", "jpg", "jpeg"], key="detect_upload")
84
+
85
+ if uploaded and st.button("Run detection", disabled=not detector.is_loaded):
86
+ image = Image.open(uploaded).convert("RGB")
87
+ with st.spinner("Running RT-DETR..."):
88
+ detections, inference_ms = detector.predict(image)
89
+
90
+ col1, col2 = st.columns(2)
91
+ col1.image(image, caption="Original", use_container_width=True)
92
+ col2.image(draw_detections(image, detections), caption="Detections", use_container_width=True)
93
+
94
+ st.caption(f"{len(detections)} detections in {inference_ms:.1f} ms")
95
+ if detections:
96
+ st.table([
97
+ {"class": d.class_name, "confidence": round(d.confidence, 3)}
98
+ for d in sorted(detections, key=lambda d: -d.confidence)
99
+ ])
100
+
101
+ with tab_ask:
102
+ st.write("Ask a question about the document's layout - not its text content.")
103
+ uploaded_q = st.file_uploader("Image", type=["png", "jpg", "jpeg"], key="ask_upload")
104
+ question = st.text_input("Question", placeholder="How many tables are on this page?")
105
+
106
+ ask_disabled = not detector.is_loaded or not os.environ.get("GROQ_API_KEY")
107
+ if uploaded_q and question and st.button("Ask", disabled=ask_disabled):
108
+ image = Image.open(uploaded_q).convert("RGB")
109
+ with st.spinner("Thinking..."):
110
+ response = answer_question(image=image, question=question, detector=detector)
111
+
112
+ if response.insufficient_information:
113
+ st.warning(response.answer)
114
+ else:
115
+ st.success(response.answer)
116
+
117
+ with st.expander("Reasoning trace"):
118
+ st.json(response.reasoning_trace)