Spaces:
Running on Zero
Running on Zero
| # Evaluates the trained model on the held-out test split. One aggregate | |
| # mAP can't answer the questions that actually matter, so this produces: | |
| # - per-class AP (which classes are actually weak, and was that | |
| # predictable given the 640px downscale) | |
| # - per-document-category mAP (did it learn structure, or just what | |
| # financial reports look like - the biggest slice of the data) | |
| # - query-budget saturation rate (RT-DETR has a fixed number of | |
| # predictions per image - dense pages lose recall for a reason that | |
| # has nothing to do with training quality) | |
| # - measured train/test source-PDF overlap (used the author's splits for | |
| # reproducibility, but that means inheriting whatever leakage they have) | |
| # | |
| # Usage: | |
| # python scripts/evaluate.py --weights runs/detect/rtdetr_doclaynet/weights/best.pt \ | |
| # --data data/doclaynet/doclaynet.yaml --out reports | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import sys | |
| from collections import Counter, defaultdict | |
| from pathlib import Path | |
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) | |
| from app.constants import CLASS_NAMES, DEFAULT_QUERY_BUDGET # noqa: E402 | |
| def _load_manifest(data_root: Path, split: str) -> list[dict]: | |
| path = data_root / f"manifest_{split}.json" | |
| if not path.exists(): | |
| print(f" (no manifest at {path} - skipping category breakdown)") | |
| return [] | |
| return json.loads(path.read_text(encoding="utf-8")) | |
| def measure_split_leakage(data_root: Path) -> dict: | |
| """Checks if any source PDF has pages on both sides of train/test. | |
| Used the author's published splits rather than re-splitting (keeps | |
| numbers comparable to published work), but that means inheriting | |
| whatever overlap they have. Not fixing it, just measuring and | |
| reporting it.""" | |
| train = {row["source_pdf"] for row in _load_manifest(data_root, "train")} | |
| test_rows = _load_manifest(data_root, "test") | |
| if not train or not test_rows: | |
| return {"measured": False} | |
| shared = {row["source_pdf"] for row in test_rows} & train | |
| affected = sum(1 for row in test_rows if row["source_pdf"] in shared) | |
| return { | |
| "measured": True, | |
| "shared_source_pdfs": len(shared), | |
| "test_pages_from_shared_pdfs": affected, | |
| "test_pages_total": len(test_rows), | |
| "percent_affected": round(100 * affected / len(test_rows), 1), | |
| } | |
| def measure_query_saturation(data_root: Path, budget: int = DEFAULT_QUERY_BUDGET) -> dict: | |
| """RT-DETR emits a fixed number of boxes per image regardless of | |
| confidence - a page with more regions than that structurally can't be | |
| fully detected. Measuring this separately so it doesn't get | |
| misattributed as a training/recall problem in the failure analysis.""" | |
| rows = _load_manifest(data_root, "test") | |
| if not rows: | |
| return {"measured": False} | |
| counts = [row["num_regions"] for row in rows] | |
| saturated = [c for c in counts if c > budget] | |
| return { | |
| "measured": True, | |
| "query_budget": budget, | |
| "max_regions_on_any_page": max(counts), | |
| "mean_regions_per_page": round(sum(counts) / len(counts), 1), | |
| "pages_over_budget": len(saturated), | |
| "percent_over_budget": round(100 * len(saturated) / len(counts), 2), | |
| } | |
| def per_category_map(weights: str, data_root: Path, base_yaml: Path, out_dir: Path) -> dict: | |
| """Runs validation once per document category by writing a filtered | |
| image-list yaml per category. Six extra small val passes, turns one | |
| opaque number into a real generalisation check.""" | |
| rows = _load_manifest(data_root, "test") | |
| if not rows: | |
| return {} | |
| from ultralytics import RTDETR | |
| by_category: dict[str, list[str]] = defaultdict(list) | |
| for row in rows: | |
| image_path = data_root / "images" / "test" / f"{row['stem']}.png" | |
| by_category[row["doc_category"]].append(str(image_path.resolve())) | |
| split_dir = out_dir / "category_splits" | |
| split_dir.mkdir(parents=True, exist_ok=True) | |
| base = base_yaml.read_text(encoding="utf-8") | |
| results: dict[str, dict] = {} | |
| for category, images in sorted(by_category.items()): | |
| # too few pages = noise, not a measurement - skip and say so | |
| if len(images) < 10: | |
| results[category] = {"pages": len(images), "skipped": "too few pages to be meaningful"} | |
| continue | |
| listing = split_dir / f"{category}.txt" | |
| listing.write_text("\n".join(images), encoding="utf-8") | |
| yaml_path = split_dir / f"{category}.yaml" | |
| yaml_path.write_text( | |
| base.replace("val: images/validation", f"val: {listing.resolve().as_posix()}"), | |
| encoding="utf-8", | |
| ) | |
| metrics = RTDETR(weights).val(data=str(yaml_path), split="val", verbose=False) | |
| results[category] = { | |
| "pages": len(images), | |
| "mAP50": round(float(metrics.box.map50), 4), | |
| "mAP50_95": round(float(metrics.box.map), 4), | |
| } | |
| print(f" {category:22s} {len(images):4d} pages mAP50={metrics.box.map50:.3f}") | |
| return results | |
| def main() -> None: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("--weights", required=True) | |
| parser.add_argument("--data", required=True, help="path to doclaynet.yaml") | |
| parser.add_argument("--out", default="reports") | |
| args = parser.parse_args() | |
| from ultralytics import RTDETR | |
| base_yaml = Path(args.data) | |
| data_root = base_yaml.parent | |
| out_dir = Path(args.out) | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| print("Evaluating on the held-out test split...") | |
| metrics = RTDETR(args.weights).val(data=args.data, split="test", plots=True) | |
| # zip against my own class list rather than trusting box.maps' order - | |
| # a mismatch here would attribute every class's score to its neighbour | |
| # and still look completely plausible | |
| per_class = { | |
| name: round(float(ap), 4) | |
| for name, ap in zip(CLASS_NAMES, list(metrics.box.maps)) | |
| } | |
| report = { | |
| "weights": args.weights, | |
| "overall": { | |
| "mAP50": round(float(metrics.box.map50), 4), | |
| "mAP50_95": round(float(metrics.box.map), 4), | |
| "precision": round(float(metrics.box.mp), 4), | |
| "recall": round(float(metrics.box.mr), 4), | |
| }, | |
| "per_class_mAP50_95": per_class, | |
| "query_saturation": measure_query_saturation(data_root), | |
| "split_leakage": measure_split_leakage(data_root), | |
| "confusion_matrix_plot": str(Path(metrics.save_dir) / "confusion_matrix_normalized.png"), | |
| } | |
| print("\nPer-document-category breakdown:") | |
| report["per_doc_category"] = per_category_map(args.weights, data_root, base_yaml, out_dir) | |
| path = out_dir / "metrics.json" | |
| path.write_text(json.dumps(report, indent=2), encoding="utf-8") | |
| print(f"\nWrote {path}") | |
| print(f" overall mAP50 {report['overall']['mAP50']:.3f}") | |
| print(f" overall mAP50-95 {report['overall']['mAP50_95']:.3f}") | |
| print("\n weakest classes:") | |
| for name, ap in sorted(per_class.items(), key=lambda kv: kv[1])[:4]: | |
| print(f" {name:16s} {ap:.3f}") | |
| if __name__ == "__main__": | |
| main() | |