Spaces:
Running on Zero
Running on Zero
File size: 7,229 Bytes
087643a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | # 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()
|