Spaces:
Sleeping
Sleeping
Amol Kaushik commited on
Commit ·
13091ee
1
Parent(s): 28f7e81
feat(a16): add batch evaluator over labeled clips; allow .avi uploads via gr.File; gitignore A16/all_videos
Browse files- .gitignore +3 -0
- A16/service/ui.py +28 -5
- A16/tests/batch_eval.py +338 -0
.gitignore
CHANGED
|
@@ -25,3 +25,6 @@ venv/pose_outputs/
|
|
| 25 |
*.mp4
|
| 26 |
*.mov
|
| 27 |
*.avi
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
*.mp4
|
| 26 |
*.mov
|
| 27 |
*.avi
|
| 28 |
+
|
| 29 |
+
# A16 — local-only labeled clips used by the batch evaluator. Never push.
|
| 30 |
+
A16/all_videos/
|
A16/service/ui.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
-
from typing import Any, Dict, Tuple
|
| 6 |
|
| 7 |
from A16.service.endpoint import (
|
| 8 |
STATUS_OK,
|
|
@@ -77,14 +77,29 @@ def _status_badge(resp: Dict[str, Any]) -> str:
|
|
| 77 |
|
| 78 |
|
| 79 |
def run_a16_tab(
|
| 80 |
-
video_path: str,
|
|
|
|
| 81 |
quality_threshold: float,
|
| 82 |
) -> Tuple[str, str, Any, Dict[str, Any]]:
|
| 83 |
"""Gradio callback for the A16 tab.
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
Returns ``(status_text, summary_markdown, skeleton_video, full_json)``.
|
| 86 |
"""
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
skeleton_video = resp["artefacts"].get("skeleton_mp4")
|
| 89 |
return _status_badge(resp), _format_summary(resp), skeleton_video, resp
|
| 90 |
|
|
@@ -115,9 +130,17 @@ def build_a16_tab(gr):
|
|
| 115 |
with gr.Row():
|
| 116 |
with gr.Column():
|
| 117 |
a16_video = gr.Video(
|
| 118 |
-
label="Record or upload
|
| 119 |
sources=["webcam", "upload"],
|
| 120 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
a16_threshold = gr.Slider(
|
| 122 |
minimum=0.1, maximum=0.9, value=0.6, step=0.05,
|
| 123 |
label="Recording quality threshold "
|
|
@@ -140,6 +163,6 @@ def build_a16_tab(gr):
|
|
| 140 |
|
| 141 |
a16_run.click(
|
| 142 |
fn=run_a16_tab,
|
| 143 |
-
inputs=[a16_video, a16_threshold],
|
| 144 |
outputs=[a16_status, a16_summary, a16_video_out, a16_json],
|
| 145 |
)
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
from typing import Any, Dict, Optional, Tuple
|
| 6 |
|
| 7 |
from A16.service.endpoint import (
|
| 8 |
STATUS_OK,
|
|
|
|
| 77 |
|
| 78 |
|
| 79 |
def run_a16_tab(
|
| 80 |
+
video_path: Optional[str],
|
| 81 |
+
file_path: Optional[str],
|
| 82 |
quality_threshold: float,
|
| 83 |
) -> Tuple[str, str, Any, Dict[str, Any]]:
|
| 84 |
"""Gradio callback for the A16 tab.
|
| 85 |
|
| 86 |
+
Accepts a webcam-recorded clip (``video_path``) OR a generic file upload
|
| 87 |
+
(``file_path``) — useful for formats the ``gr.Video`` widget filters out
|
| 88 |
+
in the browser file picker, such as ``.avi``. The first non-empty input
|
| 89 |
+
wins.
|
| 90 |
+
|
| 91 |
Returns ``(status_text, summary_markdown, skeleton_video, full_json)``.
|
| 92 |
"""
|
| 93 |
+
chosen = video_path or file_path
|
| 94 |
+
if not chosen:
|
| 95 |
+
return (
|
| 96 |
+
"ERROR — no video provided",
|
| 97 |
+
"Please record a clip or upload a video file before running.",
|
| 98 |
+
None,
|
| 99 |
+
{"status": "ERROR_NO_VIDEO",
|
| 100 |
+
"message": "No video input provided."},
|
| 101 |
+
)
|
| 102 |
+
resp = run_pipeline_3d(chosen, quality_threshold=quality_threshold)
|
| 103 |
skeleton_video = resp["artefacts"].get("skeleton_mp4")
|
| 104 |
return _status_badge(resp), _format_summary(resp), skeleton_video, resp
|
| 105 |
|
|
|
|
| 130 |
with gr.Row():
|
| 131 |
with gr.Column():
|
| 132 |
a16_video = gr.Video(
|
| 133 |
+
label="Record or upload (mp4/mov/webm)",
|
| 134 |
sources=["webcam", "upload"],
|
| 135 |
)
|
| 136 |
+
a16_file = gr.File(
|
| 137 |
+
label="… or upload any video file (incl. .avi)",
|
| 138 |
+
file_types=[
|
| 139 |
+
".avi", ".mp4", ".mov", ".webm",
|
| 140 |
+
".mkv", ".m4v", "video",
|
| 141 |
+
],
|
| 142 |
+
type="filepath",
|
| 143 |
+
)
|
| 144 |
a16_threshold = gr.Slider(
|
| 145 |
minimum=0.1, maximum=0.9, value=0.6, step=0.05,
|
| 146 |
label="Recording quality threshold "
|
|
|
|
| 163 |
|
| 164 |
a16_run.click(
|
| 165 |
fn=run_a16_tab,
|
| 166 |
+
inputs=[a16_video, a16_file, a16_threshold],
|
| 167 |
outputs=[a16_status, a16_summary, a16_video_out, a16_json],
|
| 168 |
)
|
A16/tests/batch_eval.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Batch evaluator for the A16 endpoint over locally-stored labeled clips.
|
| 2 |
+
|
| 3 |
+
Walks every video in ``A16/all_videos`` (gitignored), looks up the ground-truth
|
| 4 |
+
label from the A15 lists, runs the live ``run_pipeline_3d`` endpoint on each
|
| 5 |
+
clip, and prints a live per-clip report plus an aggregate confusion matrix at
|
| 6 |
+
the end. Designed to surface real misbehaviours fast without manual webcam
|
| 7 |
+
testing.
|
| 8 |
+
|
| 9 |
+
Usage::
|
| 10 |
+
|
| 11 |
+
python -m A16.tests.batch_eval # all clips
|
| 12 |
+
python -m A16.tests.batch_eval --limit 20 # first 20 clips
|
| 13 |
+
python -m A16.tests.batch_eval --pattern A1 # only clips matching glob A1*
|
| 14 |
+
python -m A16.tests.batch_eval --csv out.csv # also dump per-clip results
|
| 15 |
+
|
| 16 |
+
Ground truth sources:
|
| 17 |
+
- ``A15_Data/a15_good_list.csv`` → GOOD clips (with reference score)
|
| 18 |
+
- ``A15_Data/a15_ugly_list.csv`` → UGLY clips
|
| 19 |
+
- ``A15_Data/scores.csv`` → fallback reference score for any clip
|
| 20 |
+
|
| 21 |
+
Clip filename ``A123.avi`` maps to ground-truth key ``A123_kinect``.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import argparse
|
| 27 |
+
import csv
|
| 28 |
+
import fnmatch
|
| 29 |
+
import os
|
| 30 |
+
import sys
|
| 31 |
+
import time
|
| 32 |
+
import traceback
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 35 |
+
|
| 36 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 37 |
+
VIDEOS_DIR = ROOT / "A16" / "all_videos"
|
| 38 |
+
A15_DIR = ROOT / "A15_Data"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
# Ground-truth loading
|
| 43 |
+
# ---------------------------------------------------------------------------
|
| 44 |
+
|
| 45 |
+
def _load_label_csv(path: Path, label: str) -> Dict[str, Dict[str, Any]]:
|
| 46 |
+
"""Read a15_good_list.csv / a15_ugly_list.csv into ``{clip_key: row}``."""
|
| 47 |
+
out: Dict[str, Dict[str, Any]] = {}
|
| 48 |
+
if not path.exists():
|
| 49 |
+
return out
|
| 50 |
+
with path.open() as f:
|
| 51 |
+
reader = csv.DictReader(f)
|
| 52 |
+
for row in reader:
|
| 53 |
+
key = row.get("clip", "").strip()
|
| 54 |
+
if not key:
|
| 55 |
+
continue
|
| 56 |
+
out[key] = {
|
| 57 |
+
"label": label,
|
| 58 |
+
"ref_score": float(row["score"]) if row.get("score") else None,
|
| 59 |
+
"ref_good_prob": (
|
| 60 |
+
float(row["good_probability"])
|
| 61 |
+
if row.get("good_probability") else None
|
| 62 |
+
),
|
| 63 |
+
}
|
| 64 |
+
return out
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _load_scores_csv(path: Path) -> Dict[str, float]:
|
| 68 |
+
out: Dict[str, float] = {}
|
| 69 |
+
if not path.exists():
|
| 70 |
+
return out
|
| 71 |
+
with path.open() as f:
|
| 72 |
+
reader = csv.reader(f)
|
| 73 |
+
header = next(reader, None) # noqa: F841 — header skipped
|
| 74 |
+
for row in reader:
|
| 75 |
+
if len(row) < 2:
|
| 76 |
+
continue
|
| 77 |
+
try:
|
| 78 |
+
out[row[0].strip()] = float(row[1])
|
| 79 |
+
except ValueError:
|
| 80 |
+
continue
|
| 81 |
+
return out
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def load_ground_truth() -> Dict[str, Dict[str, Any]]:
|
| 85 |
+
gt = _load_label_csv(A15_DIR / "a15_good_list.csv", "GOOD")
|
| 86 |
+
gt.update(_load_label_csv(A15_DIR / "a15_ugly_list.csv", "UGLY"))
|
| 87 |
+
score_lookup = _load_scores_csv(A15_DIR / "scores.csv")
|
| 88 |
+
for key, ref_score in score_lookup.items():
|
| 89 |
+
gt.setdefault(key, {"label": None, "ref_score": ref_score,
|
| 90 |
+
"ref_good_prob": None})
|
| 91 |
+
gt[key].setdefault("ref_score", ref_score)
|
| 92 |
+
return gt
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _video_key(video_path: Path) -> str:
|
| 96 |
+
"""``A123.avi`` → ``A123_kinect`` (the key used by the A15 lists)."""
|
| 97 |
+
return f"{video_path.stem}_kinect"
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ---------------------------------------------------------------------------
|
| 101 |
+
# Per-clip evaluation
|
| 102 |
+
# ---------------------------------------------------------------------------
|
| 103 |
+
|
| 104 |
+
def evaluate_clip(
|
| 105 |
+
video_path: Path,
|
| 106 |
+
threshold: float,
|
| 107 |
+
) -> Dict[str, Any]:
|
| 108 |
+
"""Run ``run_pipeline_3d`` on a single clip and return a flat result row."""
|
| 109 |
+
# Local import so the script can be imported without TF/MediaPipe at
|
| 110 |
+
# module-collection time (e.g. by pytest).
|
| 111 |
+
from A16.service.endpoint import run_pipeline_3d
|
| 112 |
+
|
| 113 |
+
t0 = time.monotonic()
|
| 114 |
+
err: Optional[str] = None
|
| 115 |
+
resp: Dict[str, Any] = {}
|
| 116 |
+
try:
|
| 117 |
+
resp = run_pipeline_3d(str(video_path), quality_threshold=threshold)
|
| 118 |
+
except Exception as e: # pragma: no cover — surfacing is the point
|
| 119 |
+
err = f"{type(e).__name__}: {e}"
|
| 120 |
+
resp = {"status": "EXCEPTION", "message": err}
|
| 121 |
+
|
| 122 |
+
wall_ms = (time.monotonic() - t0) * 1000.0
|
| 123 |
+
|
| 124 |
+
rec = resp.get("recording", {}) or {}
|
| 125 |
+
cls = resp.get("classification", {}) or {}
|
| 126 |
+
sc = resp.get("score", {}) or {}
|
| 127 |
+
seg = resp.get("segment", {}) or {}
|
| 128 |
+
|
| 129 |
+
return {
|
| 130 |
+
"clip": video_path.stem,
|
| 131 |
+
"status": resp.get("status"),
|
| 132 |
+
"message": resp.get("message", ""),
|
| 133 |
+
"rec_label": rec.get("quality_label"),
|
| 134 |
+
"rec_conf": rec.get("quality_confidence"),
|
| 135 |
+
"class_label": cls.get("label"),
|
| 136 |
+
"class_conf": cls.get("confidence"),
|
| 137 |
+
"score": sc.get("value"),
|
| 138 |
+
"band": sc.get("band"),
|
| 139 |
+
"start": seg.get("start_frame"),
|
| 140 |
+
"stop": seg.get("stop_frame"),
|
| 141 |
+
"wall_ms": round(wall_ms, 1),
|
| 142 |
+
"error": err,
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# ---------------------------------------------------------------------------
|
| 147 |
+
# Reporting
|
| 148 |
+
# ---------------------------------------------------------------------------
|
| 149 |
+
|
| 150 |
+
def _fmt_cell(v: Any, n: int = 6) -> str:
|
| 151 |
+
if v is None:
|
| 152 |
+
return "-".rjust(n)
|
| 153 |
+
if isinstance(v, float):
|
| 154 |
+
return f"{v:.3f}".rjust(n)
|
| 155 |
+
return str(v).rjust(n)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def print_row(idx: int, total: int, row: Dict[str, Any],
|
| 159 |
+
gt: Dict[str, Any]) -> None:
|
| 160 |
+
truth = gt.get("label") or "?"
|
| 161 |
+
truth_score = gt.get("ref_score")
|
| 162 |
+
ok_marker = " "
|
| 163 |
+
if row["status"] == "EXCEPTION":
|
| 164 |
+
ok_marker = "X"
|
| 165 |
+
elif truth == "UGLY" and row["rec_label"] == "UGLY":
|
| 166 |
+
ok_marker = "."
|
| 167 |
+
elif truth == "UGLY" and row["rec_label"] != "UGLY":
|
| 168 |
+
ok_marker = "!" # false-positive-good (let an UGLY clip through)
|
| 169 |
+
elif truth == "GOOD" and row["rec_label"] == "UGLY":
|
| 170 |
+
ok_marker = "!" # false-positive-ugly (rejected a GOOD clip)
|
| 171 |
+
elif truth == "GOOD":
|
| 172 |
+
ok_marker = "."
|
| 173 |
+
|
| 174 |
+
truth_s = f"truth={truth}"
|
| 175 |
+
if truth_score is not None:
|
| 176 |
+
truth_s += f"(ref {truth_score:.2f})"
|
| 177 |
+
|
| 178 |
+
print(
|
| 179 |
+
f"[{idx:>3}/{total}] {ok_marker} {row['clip']:<6} "
|
| 180 |
+
f"{truth_s:<22} status={row['status']:<22} "
|
| 181 |
+
f"rec={row['rec_label']}/{_fmt_cell(row['rec_conf'])} "
|
| 182 |
+
f"cls={row['class_label']}/{_fmt_cell(row['class_conf'])} "
|
| 183 |
+
f"score={_fmt_cell(row['score'])} band={row['band']} "
|
| 184 |
+
f"seg={row['start']}->{row['stop']} "
|
| 185 |
+
f"({row['wall_ms']:.0f} ms)"
|
| 186 |
+
)
|
| 187 |
+
if row["error"]:
|
| 188 |
+
print(f" ERROR: {row['error']}")
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def summarise(rows: List[Dict[str, Any]],
|
| 192 |
+
gt: Dict[str, Dict[str, Any]]) -> None:
|
| 193 |
+
n = len(rows)
|
| 194 |
+
exceptions = [r for r in rows if r["status"] == "EXCEPTION"]
|
| 195 |
+
rejected = [r for r in rows if r["rec_label"] == "UGLY"]
|
| 196 |
+
ok = [r for r in rows if r["status"] == "OK"]
|
| 197 |
+
|
| 198 |
+
# Confusion vs ground-truth (only clips we have a truth label for).
|
| 199 |
+
tp = fp = tn = fn = 0
|
| 200 |
+
unknown = 0
|
| 201 |
+
for r in rows:
|
| 202 |
+
truth = (gt.get(_video_key(VIDEOS_DIR / f"{r['clip']}.avi"), {}) or {}).get("label")
|
| 203 |
+
if truth is None:
|
| 204 |
+
unknown += 1
|
| 205 |
+
continue
|
| 206 |
+
pred_ugly = r["rec_label"] == "UGLY"
|
| 207 |
+
if truth == "UGLY" and pred_ugly:
|
| 208 |
+
tp += 1
|
| 209 |
+
elif truth == "UGLY" and not pred_ugly:
|
| 210 |
+
fn += 1
|
| 211 |
+
elif truth == "GOOD" and pred_ugly:
|
| 212 |
+
fp += 1
|
| 213 |
+
elif truth == "GOOD" and not pred_ugly:
|
| 214 |
+
tn += 1
|
| 215 |
+
|
| 216 |
+
wall = [r["wall_ms"] for r in rows if r["wall_ms"] is not None]
|
| 217 |
+
wall_total_s = sum(wall) / 1000.0 if wall else 0.0
|
| 218 |
+
wall_avg_s = (sum(wall) / len(wall) / 1000.0) if wall else 0.0
|
| 219 |
+
|
| 220 |
+
print("")
|
| 221 |
+
print("=" * 70)
|
| 222 |
+
print(f"Processed {n} clips in {wall_total_s:.1f}s "
|
| 223 |
+
f"(avg {wall_avg_s:.2f}s/clip)")
|
| 224 |
+
print(f" OK : {len(ok)}")
|
| 225 |
+
print(f" Rejected (UGLY) : {len(rejected)}")
|
| 226 |
+
print(f" Exceptions : {len(exceptions)}")
|
| 227 |
+
print(f" No ground truth : {unknown}")
|
| 228 |
+
print("")
|
| 229 |
+
print("UGLY-gate confusion (truth UGLY = positive):")
|
| 230 |
+
print(f" TP={tp} FN={fn} FP={fp} TN={tn}")
|
| 231 |
+
if tp + fn > 0:
|
| 232 |
+
print(f" Recall (catch-UGLY) : {tp/(tp+fn):.2%}")
|
| 233 |
+
if tn + fp > 0:
|
| 234 |
+
print(f" Specificity (pass-GOOD): {tn/(tn+fp):.2%}")
|
| 235 |
+
|
| 236 |
+
if exceptions:
|
| 237 |
+
print("")
|
| 238 |
+
print(f"Exceptions ({len(exceptions)}):")
|
| 239 |
+
for r in exceptions[:20]:
|
| 240 |
+
print(f" {r['clip']:<6} {r['error']}")
|
| 241 |
+
if len(exceptions) > 20:
|
| 242 |
+
print(f" ... and {len(exceptions) - 20} more")
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def dump_csv(rows: List[Dict[str, Any]], path: Path) -> None:
|
| 246 |
+
if not rows:
|
| 247 |
+
return
|
| 248 |
+
cols = list(rows[0].keys())
|
| 249 |
+
with path.open("w", newline="") as f:
|
| 250 |
+
w = csv.DictWriter(f, fieldnames=cols)
|
| 251 |
+
w.writeheader()
|
| 252 |
+
w.writerows(rows)
|
| 253 |
+
print(f"Per-clip CSV written to {path}")
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
# ---------------------------------------------------------------------------
|
| 257 |
+
# CLI
|
| 258 |
+
# ---------------------------------------------------------------------------
|
| 259 |
+
|
| 260 |
+
def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
|
| 261 |
+
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
| 262 |
+
p.add_argument("--videos-dir", default=str(VIDEOS_DIR),
|
| 263 |
+
help="Directory of .avi/.mp4 clips (default: A16/all_videos)")
|
| 264 |
+
p.add_argument("--pattern", default="*",
|
| 265 |
+
help="Glob applied to filenames (e.g. 'A1*').")
|
| 266 |
+
p.add_argument("--limit", type=int, default=0,
|
| 267 |
+
help="Process at most N clips (0 = all).")
|
| 268 |
+
p.add_argument("--threshold", type=float, default=0.6,
|
| 269 |
+
help="Recording-quality threshold (matches UI default).")
|
| 270 |
+
p.add_argument("--csv", default="",
|
| 271 |
+
help="Optional path to dump per-clip results as CSV.")
|
| 272 |
+
p.add_argument("--ext", default=".avi,.mp4,.mov,.webm",
|
| 273 |
+
help="Comma-separated extensions to include.")
|
| 274 |
+
return p.parse_args(argv)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def main(argv: Optional[List[str]] = None) -> int:
|
| 278 |
+
args = parse_args(argv)
|
| 279 |
+
videos_dir = Path(args.videos_dir)
|
| 280 |
+
if not videos_dir.exists():
|
| 281 |
+
print(f"ERROR: videos dir does not exist: {videos_dir}", file=sys.stderr)
|
| 282 |
+
return 2
|
| 283 |
+
|
| 284 |
+
allowed_exts = {e.strip().lower() for e in args.ext.split(",") if e.strip()}
|
| 285 |
+
clips = sorted(
|
| 286 |
+
p for p in videos_dir.iterdir()
|
| 287 |
+
if p.is_file()
|
| 288 |
+
and p.suffix.lower() in allowed_exts
|
| 289 |
+
and fnmatch.fnmatch(p.name, args.pattern)
|
| 290 |
+
# Also accept patterns without extension, e.g. --pattern A1
|
| 291 |
+
or (p.is_file() and p.suffix.lower() in allowed_exts
|
| 292 |
+
and fnmatch.fnmatch(p.stem, args.pattern))
|
| 293 |
+
)
|
| 294 |
+
# De-dupe while preserving order
|
| 295 |
+
seen = set()
|
| 296 |
+
clips = [c for c in clips if not (c in seen or seen.add(c))]
|
| 297 |
+
if args.limit > 0:
|
| 298 |
+
clips = clips[: args.limit]
|
| 299 |
+
|
| 300 |
+
if not clips:
|
| 301 |
+
print(f"No clips matched in {videos_dir} (pattern={args.pattern!r}).")
|
| 302 |
+
return 1
|
| 303 |
+
|
| 304 |
+
gt_all = load_ground_truth()
|
| 305 |
+
print(f"Loaded {len(gt_all)} ground-truth entries from A15_Data/")
|
| 306 |
+
print(f"Evaluating {len(clips)} clip(s) from {videos_dir}\n")
|
| 307 |
+
|
| 308 |
+
rows: List[Dict[str, Any]] = []
|
| 309 |
+
for i, clip in enumerate(clips, 1):
|
| 310 |
+
gt = gt_all.get(_video_key(clip), {"label": None, "ref_score": None})
|
| 311 |
+
try:
|
| 312 |
+
row = evaluate_clip(clip, threshold=args.threshold)
|
| 313 |
+
except KeyboardInterrupt:
|
| 314 |
+
print("\nInterrupted — summarising results so far...")
|
| 315 |
+
break
|
| 316 |
+
except Exception as e: # pragma: no cover
|
| 317 |
+
row = {
|
| 318 |
+
"clip": clip.stem, "status": "EXCEPTION",
|
| 319 |
+
"message": "outer-loop crash",
|
| 320 |
+
"rec_label": None, "rec_conf": None,
|
| 321 |
+
"class_label": None, "class_conf": None,
|
| 322 |
+
"score": None, "band": None,
|
| 323 |
+
"start": None, "stop": None,
|
| 324 |
+
"wall_ms": 0.0,
|
| 325 |
+
"error": f"{type(e).__name__}: {e}\n{traceback.format_exc()}",
|
| 326 |
+
}
|
| 327 |
+
rows.append(row)
|
| 328 |
+
print_row(i, len(clips), row, gt)
|
| 329 |
+
sys.stdout.flush()
|
| 330 |
+
|
| 331 |
+
summarise(rows, gt_all)
|
| 332 |
+
if args.csv:
|
| 333 |
+
dump_csv(rows, Path(args.csv))
|
| 334 |
+
return 0
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
if __name__ == "__main__":
|
| 338 |
+
raise SystemExit(main())
|