(video_path: Path, gt: dict, pred: dict)
| 91 | |
| 92 | |
| 93 | def build_report(video_path: Path, gt: dict, pred: dict) -> str: |
| 94 | correct, misclass, miss_strokes, fp_strokes = match_strokes(gt["strokes"], pred["strokes"]) |
| 95 | bounce_matches, miss_bounces, fp_bounces = match_bounces(gt["bounces"], pred["bounces"]) |
| 96 | |
| 97 | n_gt = len(gt["strokes"]) |
| 98 | n_pred = len(pred["strokes"]) |
| 99 | n_matched = len(correct) + len(misclass) |
| 100 | det_recall = n_matched / n_gt if n_gt else 0 |
| 101 | det_precision = n_matched / n_pred if n_pred else 0 |
| 102 | det_f1 = f1(det_precision, det_recall) |
| 103 | cls_acc = len(correct) / n_matched if n_matched else 0 |
| 104 | |
| 105 | classes = sorted({s["type"] for s in gt["strokes"]} | {normalize_pred_label(s["label"]) for s in pred["strokes"]}) |
| 106 | confusion = {c: {c2: 0 for c2 in classes} for c in classes} |
| 107 | for record in correct + misclass: |
| 108 | gt_c = record["gt"]["type"] |
| 109 | pred_c = normalize_pred_label(record["pred"]["label"]) |
| 110 | confusion[gt_c][pred_c] = confusion[gt_c].get(pred_c, 0) + 1 |
| 111 | |
| 112 | per_class = {} |
| 113 | for c in classes: |
| 114 | tp = confusion[c].get(c, 0) |
| 115 | fn = sum(v for k, v in confusion[c].items() if k != c) |
| 116 | fp = sum(confusion[c2].get(c, 0) for c2 in classes if c2 != c) |
| 117 | rec = tp / (tp + fn) if (tp + fn) else 0 |
| 118 | prec = tp / (tp + fp) if (tp + fp) else 0 |
| 119 | per_class[c] = {"tp": tp, "fn": fn, "fp": fp, "precision": prec, "recall": rec} |
| 120 | |
| 121 | bn_gt = len(gt["bounces"]) |
| 122 | bn_pred = len(pred["bounces"]) |
| 123 | bn_matched = len(bounce_matches) |
| 124 | b_recall = bn_matched / bn_gt if bn_gt else 0 |
| 125 | b_precision = bn_matched / bn_pred if bn_pred else 0 |
| 126 | b_f1 = f1(b_precision, b_recall) |
| 127 | |
| 128 | in_correct = sum(1 for m in bounce_matches if m["gt"]["in_bounds"] == m["pred"]["in_bounds"]) |
| 129 | in_acc = in_correct / bn_matched if bn_matched else 0 |
| 130 | |
| 131 | fps = gt.get("fps", pred.get("fps", 30.0)) |
| 132 | |
| 133 | def t(frame: int) -> str: |
| 134 | return f"{frame / fps:.2f}s" |
| 135 | |
| 136 | L = [] |
| 137 | L.append(f"# Eval Report") |
| 138 | L.append("") |
| 139 | L.append(f"**Video:** `{video_path}`") |
| 140 | L.append(f"**FPS:** {fps} **Frames:** {gt.get('total_frames')}") |
| 141 | L.append(f"**Tolerances:** stroke = ±{STROKE_TOLERANCE_FRAMES} frames, bounce = ±{BOUNCE_TOLERANCE_FRAMES} frames") |
| 142 | L.append("") |
| 143 | L.append("## Headline") |
| 144 | L.append("") |
| 145 | L.append(f"- Strokes: P={det_precision:.2f} R={det_recall:.2f} F1={det_f1:.2f} Class-acc={cls_acc:.2f} ({len(correct)}/{n_matched} matched events classified correctly)") |
| 146 | L.append(f"- Bounces: P={b_precision:.2f} R={b_recall:.2f} F1={b_f1:.2f} In/Out-acc={in_acc:.2f}") |
| 147 | L.append("") |
| 148 | L.append("## Strokes") |
| 149 | L.append("") |
| 150 | L.append(f"- GT events: {n_gt} Pred events: {n_pred} Matched: {n_matched}") |
no test coverage detected