Spaces:
Sleeping
Sleeping
| # Finds the test pages the model got most wrong and renders them side by | |
| # side with ground truth. The 5 failure cases in the memo come from this, | |
| # not from guessing - the brief flags "no acknowledged failure cases" as a | |
| # red flag, so these need to be real, pointable-at examples. | |
| # | |
| # Scoring is deliberately crude (greedy IoU 0.5 matching) - just needs to | |
| # rank pages well enough to surface the interesting ones, not be a precise | |
| # metric (that's evaluate.py's job). | |
| # | |
| # Usage: | |
| # python scripts/mine_failures.py --weights runs/detect/.../best.pt \ | |
| # --data data/doclaynet --out reports/failures --top 25 | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import sys | |
| from pathlib import Path | |
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) | |
| from app.constants import ID_TO_CLASS # noqa: E402 | |
| from scripts._render_utils import load_label_font as _label_font # noqa: E402 | |
| PALETTE = [ | |
| "#e6194b", "#3cb44b", "#ffe119", "#4363d8", "#f58231", "#911eb4", | |
| "#46f0f0", "#f032e6", "#bcf60c", "#fabebe", "#008080", | |
| ] | |
| def iou(a: tuple[float, ...], b: tuple[float, ...]) -> float: | |
| """IoU for two xyxy boxes.""" | |
| ax1, ay1, ax2, ay2 = a | |
| bx1, by1, bx2, by2 = b | |
| ix1, iy1 = max(ax1, bx1), max(ay1, by1) | |
| ix2, iy2 = min(ax2, bx2), min(ay2, by2) | |
| if ix2 <= ix1 or iy2 <= iy1: | |
| return 0.0 | |
| intersection = (ix2 - ix1) * (iy2 - iy1) | |
| union = (ax2 - ax1) * (ay2 - ay1) + (bx2 - bx1) * (by2 - by1) - intersection | |
| return intersection / union if union > 0 else 0.0 | |
| def _read_ground_truth(label_path: Path, width: int, height: int) -> list[tuple[int, tuple[float, ...]]]: | |
| """Reads a YOLO label file back into absolute xyxy boxes.""" | |
| if not label_path.exists(): | |
| return [] | |
| boxes = [] | |
| for line in label_path.read_text(encoding="utf-8").splitlines(): | |
| if not line.strip(): | |
| continue | |
| class_id, xc, yc, w, h = line.split() | |
| xc, yc, w, h = float(xc) * width, float(yc) * height, float(w) * width, float(h) * height | |
| boxes.append((int(class_id), (xc - w / 2, yc - h / 2, xc + w / 2, yc + h / 2))) | |
| return boxes | |
| def score_page( | |
| ground_truth: list[tuple[int, tuple[float, ...]]], | |
| predictions: list[tuple[int, tuple[float, ...], float]], | |
| iou_threshold: float = 0.5, | |
| ) -> dict: | |
| """Greedy match + counts. Splits misses from misclassifications since | |
| they have different causes - a miss is usually small/thin/query- | |
| starved, a misclassification is usually genuine label ambiguity | |
| (Text vs List-item, Title vs Section-header). That split is what lets | |
| the memo talk about annotation ambiguity instead of just "missed stuff".""" | |
| unmatched_gt = list(range(len(ground_truth))) | |
| matched_predictions: set[int] = set() | |
| correct = 0 | |
| misclassified = 0 | |
| for gt_index in list(unmatched_gt): | |
| gt_class, gt_box = ground_truth[gt_index] | |
| best_index, best_iou = None, iou_threshold | |
| for pred_index, (_, pred_box, _) in enumerate(predictions): | |
| if pred_index in matched_predictions: | |
| continue | |
| overlap = iou(gt_box, pred_box) | |
| if overlap >= best_iou: | |
| best_index, best_iou = pred_index, overlap | |
| if best_index is not None: | |
| matched_predictions.add(best_index) | |
| unmatched_gt.remove(gt_index) | |
| if predictions[best_index][0] == gt_class: | |
| correct += 1 | |
| else: | |
| misclassified += 1 | |
| false_positives = len(predictions) - len(matched_predictions) | |
| return { | |
| "ground_truth_regions": len(ground_truth), | |
| "predicted_regions": len(predictions), | |
| "correct": correct, | |
| "misclassified": misclassified, | |
| "missed": len(unmatched_gt), | |
| "false_positives": false_positives, | |
| # misclassifications weighted higher - they're the interesting ones | |
| "error_score": len(unmatched_gt) + false_positives + 1.5 * misclassified, | |
| } | |
| def _draw_labelled_box(draw, colour: str, x1, y1, x2, y2, label: str, font) -> None: | |
| """Box outline plus a solid background behind the label, legible over | |
| any page content.""" | |
| draw.rectangle([x1, y1, x2, y2], outline=colour, width=4) | |
| text_box = draw.textbbox((x1, y1), label, font=font) | |
| draw.rectangle( | |
| [text_box[0] - 2, text_box[1] - 2, text_box[2] + 2, text_box[3] + 2], | |
| fill=colour, | |
| ) | |
| draw.text((x1, y1), label, font=font, fill="white") | |
| def _render_comparison(image, ground_truth, predictions, out_path: Path) -> None: | |
| """Ground truth on the left, prediction on the right.""" | |
| from PIL import Image, ImageDraw | |
| width, height = image.size | |
| canvas = Image.new("RGB", (width * 2 + 20, height + 40), "white") | |
| canvas.paste(image, (0, 40)) | |
| canvas.paste(image, (width + 20, 40)) | |
| draw = ImageDraw.Draw(canvas) | |
| header_font = _label_font(24) | |
| label_font = _label_font(22) | |
| draw.text((10, 8), "GROUND TRUTH", font=header_font, fill="black") | |
| draw.text((width + 30, 8), "PREDICTION", font=header_font, fill="black") | |
| for class_id, (x1, y1, x2, y2) in ground_truth: | |
| colour = PALETTE[class_id % len(PALETTE)] | |
| _draw_labelled_box(draw, colour, x1, y1 + 40, x2, y2 + 40, | |
| ID_TO_CLASS.get(class_id, "?"), label_font) | |
| offset = width + 20 | |
| for class_id, (x1, y1, x2, y2), confidence in predictions: | |
| colour = PALETTE[class_id % len(PALETTE)] | |
| label = f"{ID_TO_CLASS.get(class_id, '?')} {confidence:.2f}" | |
| _draw_labelled_box(draw, colour, x1 + offset, y1 + 40, x2 + offset, y2 + 40, | |
| label, label_font) | |
| canvas.save(out_path) | |
| def main() -> None: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("--weights", required=True) | |
| parser.add_argument("--data", required=True, help="dataset root containing images/ and labels/") | |
| parser.add_argument("--out", default="reports/failures") | |
| parser.add_argument("--top", type=int, default=25) | |
| parser.add_argument("--conf", type=float, default=0.25) | |
| args = parser.parse_args() | |
| from PIL import Image | |
| from ultralytics import RTDETR | |
| data_root = Path(args.data) | |
| image_dir = data_root / "images" / "test" | |
| label_dir = data_root / "labels" / "test" | |
| out_dir = Path(args.out) | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| model = RTDETR(args.weights) | |
| images = sorted(image_dir.glob("*.png")) | |
| print(f"Scoring {len(images)} test pages...") | |
| manifest_path = data_root / "manifest_test.json" | |
| categories = {} | |
| if manifest_path.exists(): | |
| categories = { | |
| row["stem"]: row["doc_category"] | |
| for row in json.loads(manifest_path.read_text(encoding="utf-8")) | |
| } | |
| scored = [] | |
| for index, image_path in enumerate(images): | |
| image = Image.open(image_path).convert("RGB") | |
| width, height = image.size | |
| result = model.predict(image, conf=args.conf, verbose=False)[0] | |
| predictions = [ | |
| (int(cls), tuple(float(v) for v in box), float(conf)) | |
| for cls, box, conf in zip( | |
| result.boxes.cls.tolist(), | |
| result.boxes.xyxy.tolist(), | |
| result.boxes.conf.tolist(), | |
| ) | |
| ] | |
| ground_truth = _read_ground_truth(label_dir / f"{image_path.stem}.txt", width, height) | |
| score = score_page(ground_truth, predictions) | |
| score["stem"] = image_path.stem | |
| score["doc_category"] = categories.get(image_path.stem, "unknown") | |
| scored.append((score, image, ground_truth, predictions)) | |
| if (index + 1) % 100 == 0: | |
| print(f" {index + 1}/{len(images)}", flush=True) | |
| scored.sort(key=lambda item: item[0]["error_score"], reverse=True) | |
| summary = [] | |
| for rank, (score, image, ground_truth, predictions) in enumerate(scored[: args.top], start=1): | |
| out_path = out_dir / f"{rank:02d}_{score['stem']}_err{score['error_score']:.0f}.png" | |
| _render_comparison(image, ground_truth, predictions, out_path) | |
| score["render"] = out_path.name | |
| summary.append(score) | |
| (out_dir / "failure_summary.json").write_text( | |
| json.dumps(summary, indent=2), encoding="utf-8" | |
| ) | |
| print(f"\nWrote {len(summary)} comparison renders to {out_dir}") | |
| print("\nWorst pages:") | |
| for score in summary[:8]: | |
| print( | |
| f" {score['stem']:20s} {score['doc_category']:20s} " | |
| f"missed={score['missed']:3d} misclassified={score['misclassified']:3d} " | |
| f"fp={score['false_positives']:3d}" | |
| ) | |
| if __name__ == "__main__": | |
| main() | |