Buckets:
| """FS-Spot: untrimmed figure skating data with frame-precise jump/spin boundaries. | |
| Source: Hong et al., "Spotting Temporally Precise, Fine-Grained Events in Video" | |
| (ECCV 2022) -- https://github.com/jhong93/spot. The repo ships ONLY annotations | |
| (train/val/test.json per split, committed to git); the broadcast video itself is | |
| ISU/Olympic copyright and is not redistributed, so videos.csv gives YouTube ids | |
| and you source the pixels yourself. Everything is indexed in frames at 25fps, so | |
| the download MUST be the 25fps rendition (format 248) or every label shifts. | |
| This module turns that into skeleton clips our pipeline can train on: | |
| 11 broadcasts (4-6h each, 29-40 skaters back to back) | |
| -> 371 per-skater clips (frame windows carved out by the clip name) | |
| -> YOLO pose per frame, skater picked by a continuity tracker | |
| -> (T, 17, 3) COCO-17 normalized skeletons + dense per-frame labels | |
| Why this dataset: it is the only public figure skating source with untrimmed | |
| multi-action sequences AND ground-truth temporal boundaries. 1,464 jumps, each | |
| with a takeoff frame, a landing frame, and a jump type; 373 spins; 338 real jump | |
| combinations. The 6 jump types are exactly FS-Jump3D's taxonomy. | |
| Clip naming encodes the window: <broadcast>_<skater##>_<startframe>_<endframe>, | |
| where endframe - startframe == num_frames (verified across all 371). | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import re | |
| import subprocess | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| import numpy as np | |
| PROJECT_ROOT = Path(__file__).resolve().parents[2] | |
| SPOT_ANNO_DIR = PROJECT_ROOT / "data" / "fs_spot" / "spot_annotations" | |
| VIDEO_DIR = PROJECT_ROOT / "data" / "fs_spot" / "videos" | |
| # Most broadcasts download as VP9 (format 248, .webm). Two have no VP9 rendition | |
| # and fall back to format 137 (h264, .mp4) -- same 1920x1080 @ 25fps, so frame | |
| # indices are unaffected. Look up by stem, not by a hardcoded extension. | |
| VIDEO_EXTS = (".webm", ".mp4", ".mkv") | |
| OUT_DIR = PROJECT_ROOT / "data" / "fs_spot" / "processed" | |
| FPS = 25.0 | |
| CLIP_RE = re.compile(r"^(?P<broadcast>.+)_(?P<skater>\d+)_(?P<start>\d{8})_(?P<end>\d{8})$") | |
| # spot's `comment` field carries the jump type. One record is typo'd 'tow_loop'. | |
| JUMP_TYPE_FIX = {"tow_loop": "toe_loop"} | |
| # Dense per-frame taxonomy. Jump types match FS_JUMP3D_TAXONOMY names so a model | |
| # trained here transfers to the FS-Jump3D mocap clips without a label remap. | |
| SPOT_TAXONOMY = { | |
| 0: "NONE", | |
| 1: "Axel", | |
| 2: "Flip", | |
| 3: "Loop", | |
| 4: "Lutz", | |
| 5: "Salchow", | |
| 6: "Toeloop", | |
| 7: "Spin", | |
| } | |
| SPOT_LABEL_TO_IDX = {name: idx for idx, name in SPOT_TAXONOMY.items()} | |
| _SPOT_JUMP_TO_TAXONOMY = { | |
| "axel": "Axel", | |
| "flip": "Flip", | |
| "loop": "Loop", | |
| "lutz": "Lutz", | |
| "salchow": "Salchow", | |
| "toe_loop": "Toeloop", | |
| } | |
| class Element: | |
| """One jump or spin: a [takeoff, landing] frame window, relative to the clip.""" | |
| kind: str # "jump" | "spin" | |
| label: str # SPOT_TAXONOMY name | |
| takeoff: int | |
| landing: int | |
| def duration(self) -> int: | |
| return self.landing - self.takeoff | |
| class Clip: | |
| """One skater's performance: a frame window inside a broadcast.""" | |
| name: str | |
| broadcast: str | |
| start: int # absolute frame in the broadcast | |
| end: int | |
| num_frames: int | |
| elements: list[Element] = field(default_factory=list) | |
| def transcript(self) -> list[str]: | |
| """Ordered element labels -- the CTC target, if you want one.""" | |
| return [e.label for e in self.elements] | |
| def frame_labels(self) -> np.ndarray: | |
| """Dense (T,) int labels: every frame in [takeoff, landing] gets the element | |
| label, everything else is NONE. This is the supervision CTC exists to avoid | |
| needing -- since we have it, prefer a dense loss and skip CTC entirely.""" | |
| y = np.zeros(self.num_frames, dtype=np.int64) | |
| for e in self.elements: | |
| y[e.takeoff : e.landing + 1] = SPOT_LABEL_TO_IDX[e.label] | |
| return y | |
| def _pair_events(events: list[dict]) -> list[Element]: | |
| """Pair takeoff/landing events into elements. | |
| spot stores events as a flat frame-ordered list of {frame, label, comment}. | |
| A takeoff is always followed by its landing, so pair them in order per kind. | |
| An unmatched takeoff at the end of a clip is dropped rather than guessed. | |
| Event frames are already CLIP-relative (0..num_frames), not absolute into the | |
| broadcast -- the clip name's start/end give the absolute window separately. | |
| """ | |
| out: list[Element] = [] | |
| pending: dict[str, tuple[int, str | None]] = {} | |
| for e in sorted(events, key=lambda x: x["frame"]): | |
| label = e["label"] | |
| kind, _, phase = label.partition("_") | |
| if phase == "takeoff": | |
| pending[kind] = (e["frame"], e.get("comment")) | |
| elif phase == "landing": | |
| if kind not in pending: | |
| continue # landing with no takeoff -- skip rather than invent one | |
| takeoff, comment = pending.pop(kind) | |
| if kind == "jump": | |
| raw = JUMP_TYPE_FIX.get(comment or "", comment or "") | |
| name = _SPOT_JUMP_TO_TAXONOMY.get(raw) | |
| if name is None: | |
| continue # unknown jump type -- don't silently mislabel it | |
| else: | |
| name = "Spin" | |
| out.append(Element(kind=kind, label=name, takeoff=takeoff, landing=e["frame"])) | |
| return out | |
| def build_index(anno_dir: Path = SPOT_ANNO_DIR, split_set: str = "fs_comp") -> dict[str, Clip]: | |
| """Parse spot's json into Clip records keyed by clip name. | |
| fs_comp and fs_perf are two different train/val/test *partitions of the same | |
| 371 clips*, not different data -- so indexing either gives the full set. We | |
| use fs_comp's partition by default. | |
| """ | |
| clips: dict[str, Clip] = {} | |
| for split in ("train", "val", "test"): | |
| path = anno_dir / split_set / f"{split}.json" | |
| for rec in json.loads(path.read_text()): | |
| m = CLIP_RE.match(rec["video"]) | |
| if m is None: | |
| raise ValueError(f"unparseable clip name: {rec['video']!r}") | |
| start, end = int(m["start"]), int(m["end"]) | |
| if end - start != rec["num_frames"]: | |
| raise ValueError( | |
| f"{rec['video']}: name span {end - start} != num_frames {rec['num_frames']}" | |
| ) | |
| clips[rec["video"]] = Clip( | |
| name=rec["video"], | |
| broadcast=m["broadcast"], | |
| start=start, | |
| end=end, | |
| num_frames=rec["num_frames"], | |
| elements=_pair_events(rec["events"]), | |
| ) | |
| return clips | |
| def find_video(broadcast: str, video_dir: Path = VIDEO_DIR) -> Path | None: | |
| """Locate a broadcast's file regardless of container.""" | |
| for ext in VIDEO_EXTS: | |
| p = video_dir / f"{broadcast}{ext}" | |
| if p.exists(): | |
| return p | |
| return None | |
| def available_broadcasts(video_dir: Path = VIDEO_DIR) -> set[str]: | |
| """Broadcast stems that have a complete (non-.part) file on disk.""" | |
| return {p.stem for p in video_dir.iterdir() if p.suffix in VIDEO_EXTS} | |
| def _ffmpeg_window_cmd( | |
| video: Path, start_frame: int, num_frames: int, scale: tuple[int, int] | None = None | |
| ) -> list[str]: | |
| """Decode exactly [start_frame, start_frame+num_frames) to rawvideo on stdout. | |
| Input-side -ss makes ffmpeg seek to the preceding keyframe and then decode | |
| forward to the exact frame (accurate seek). That precision is the whole ball | |
| game here: every label is a frame index, so a keyframe-snapped cut would shift | |
| every annotation in the clip. Validated against a known Axel -- the skater | |
| leaves the ice within 2 frames of the annotated takeoff. | |
| `scale` downsizes in ffmpeg. This is free accuracy-wise: YOLO letterboxes to | |
| its imgsz regardless, so 1920x1080 and 640x360 both arrive at the model as | |
| 640x360 -- but pre-scaling cuts ~9x off the decode/transfer bytes and moves | |
| the letterbox off ultralytics' single-threaded CPU path (78 -> ~300 fps). | |
| Keypoints are normalized to [0,1] by frame size, so coordinates are unchanged. | |
| """ | |
| cmd = [ | |
| "ffmpeg", "-v", "error", "-nostdin", | |
| "-ss", f"{start_frame / FPS:.6f}", | |
| "-i", str(video), | |
| "-frames:v", str(num_frames), | |
| ] | |
| if scale is not None: | |
| cmd += ["-vf", f"scale={scale[0]}:{scale[1]}"] | |
| return cmd + ["-f", "rawvideo", "-pix_fmt", "rgb24", "-"] | |
| def decode_window(video: Path, start_frame: int, num_frames: int, width: int, height: int) -> np.ndarray: | |
| """Decode a window fully into memory as (N, H, W, 3) uint8. | |
| Only safe for short windows: 1080p costs ~6.2MB/frame, so a full 4,266-frame | |
| clip would be ~26GB. Use stream_window() for anything clip-length. | |
| """ | |
| proc = subprocess.run(_ffmpeg_window_cmd(video, start_frame, num_frames), capture_output=True) | |
| if proc.returncode != 0: | |
| raise RuntimeError(f"ffmpeg failed on {video.name}@{start_frame}: {proc.stderr.decode()[:400]}") | |
| frame_bytes = width * height * 3 | |
| n = len(proc.stdout) // frame_bytes | |
| if n == 0: | |
| raise RuntimeError(f"ffmpeg returned no frames for {video.name}@{start_frame}") | |
| return np.frombuffer(proc.stdout[: n * frame_bytes], dtype=np.uint8).reshape(n, height, width, 3) | |
| def stream_window( | |
| video: Path, | |
| start_frame: int, | |
| num_frames: int, | |
| width: int, | |
| height: int, | |
| chunk: int = 128, | |
| scale: tuple[int, int] | None = None, | |
| ): | |
| """Yield (N, H, W, 3) uint8 chunks over the window, never holding it all at once. | |
| A clip is ~4,266 frames of 1080p (~26GB decoded), so the extractor consumes it | |
| in chunks and keeps only the (T, 17, 3) skeletons (~1.7MB) -- four orders of | |
| magnitude smaller than the pixels they came from. | |
| `width`/`height` must match what ffmpeg emits, i.e. `scale` when it is set. | |
| """ | |
| frame_bytes = width * height * 3 | |
| proc = subprocess.Popen( | |
| _ffmpeg_window_cmd(video, start_frame, num_frames, scale=scale), | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| bufsize=frame_bytes * 4, | |
| ) | |
| try: | |
| remaining = num_frames | |
| while remaining > 0: | |
| want = min(chunk, remaining) | |
| buf = proc.stdout.read(frame_bytes * want) | |
| if not buf: | |
| break | |
| got = len(buf) // frame_bytes | |
| if got == 0: | |
| break | |
| yield np.frombuffer(buf[: got * frame_bytes], dtype=np.uint8).reshape(got, height, width, 3) | |
| remaining -= got | |
| finally: | |
| if proc.stdout: | |
| proc.stdout.close() | |
| proc.wait() | |
| if proc.returncode not in (0, None): | |
| err = proc.stderr.read().decode()[:400] if proc.stderr else "" | |
| raise RuntimeError(f"ffmpeg failed on {video.name}@{start_frame}: {err}") | |
| if proc.stderr: | |
| proc.stderr.close() | |
| def pick_skater(boxes_xyxy: np.ndarray, confs: np.ndarray, prev_center: np.ndarray | None) -> int: | |
| """Choose which detected person is the skater. | |
| gpu_pose._results_to_skeleton takes argmax(conf), which is fine for trimmed | |
| single-skater clips but wrong here: broadcast footage is full of crowd, | |
| coaches and judges, and a sharp face in the stands routinely outscores a | |
| motion-blurred skater. Score on confidence AND size (the skater is the camera | |
| subject, so they're the large one), then bias toward the previous frame's pick | |
| so the choice stays on one person through a crowd pan. | |
| """ | |
| areas = (boxes_xyxy[:, 2] - boxes_xyxy[:, 0]) * (boxes_xyxy[:, 3] - boxes_xyxy[:, 1]) | |
| score = confs * np.sqrt(np.maximum(areas, 1.0)) | |
| if prev_center is not None: | |
| centers = np.stack( | |
| [(boxes_xyxy[:, 0] + boxes_xyxy[:, 2]) / 2, (boxes_xyxy[:, 1] + boxes_xyxy[:, 3]) / 2], axis=1 | |
| ) | |
| dist = np.linalg.norm(centers - prev_center[None, :], axis=1) | |
| # Halve the score for every ~25% of frame width the candidate has jumped. | |
| score = score / (1.0 + (dist / 250.0) ** 2) | |
| return int(np.argmax(score)) | |
Xet Storage Details
- Size:
- 12.2 kB
- Xet hash:
- 8c7316883975f462ce39644b6c8b192ac2b8a5e608044632985c9c5dd1cfb1b3
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.