Spaces:
Sleeping
Sleeping
Commit ·
087643a
0
Parent(s):
space deploy: fix zerogpu pickling
Browse files- .dockerignore +17 -0
- .env.example +25 -0
- .gitignore +24 -0
- Dockerfile +38 -0
- README.md +25 -0
- app/__init__.py +0 -0
- app/constants.py +45 -0
- app/detector.py +83 -0
- app/logging_config.py +66 -0
- app/main.py +143 -0
- app/reasoning/__init__.py +0 -0
- app/reasoning/evidence.py +105 -0
- app/reasoning/guardrail.py +100 -0
- app/reasoning/pipeline.py +76 -0
- app/reasoning/router.py +147 -0
- app/reasoning/synthesize.py +95 -0
- app/schemas.py +105 -0
- deploy/huggingface_space_README.md +25 -0
- gradio_app.py +158 -0
- memo/BUILD_LOG.md +156 -0
- memo/MEMO.md +124 -0
- notebooks/kaggle_train.ipynb +233 -0
- reports/metrics.json +70 -0
- reports/run_metadata.json +47 -0
- requirements.txt +44 -0
- scripts/__init__.py +0 -0
- scripts/_render_utils.py +35 -0
- scripts/build_report.py +231 -0
- scripts/evaluate.py +183 -0
- scripts/mine_failures.py +230 -0
- scripts/prepare_dataset.py +329 -0
- scripts/train.py +171 -0
- streamlit_app.py +118 -0
.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)
|