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
Files changed (3) hide show
  1. .gitignore +3 -0
  2. A16/service/ui.py +28 -5
  3. 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
- resp = run_pipeline_3d(video_path, quality_threshold=quality_threshold)
 
 
 
 
 
 
 
 
 
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 exercise video",
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())