Add frame extraction bridging recording output to the labeling tool
Slice a recorded session's lossless video into individual frame PNGs (ffmpeg -vsync 0, no drop/dup) via a new `extract-frames` subcommand, with frame count cross-checked against the manifest and input log so any capture-rate drift surfaces immediately instead of silently misaligning frames and logged input later. Includes a real end-to-end test against an ffmpeg-generated synthetic video (skipped when ffmpeg isn't installed, e.g. bare WSL). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
a7c370b3d4
commit
2989a0814c
@ -26,6 +26,13 @@ below).
|
|||||||
resolution/frame count. Because both are ticked from the same start time at the same
|
resolution/frame count. Because both are ticked from the same start time at the same
|
||||||
fixed rate, frame `i` in the log lines up with frame `i` of the video without
|
fixed rate, frame `i` in the log lines up with frame `i` of the video without
|
||||||
post-hoc alignment.
|
post-hoc alignment.
|
||||||
|
- **Frame extraction** (`frames.py`): slices a session's video into individual
|
||||||
|
`frame_%06d.png` images (via `ffmpeg -vsync 0`, so no frames are silently dropped or
|
||||||
|
duplicated during extraction) — the bridge to the Labeling Tool, which works on
|
||||||
|
frame images rather than video. Cross-checks the extracted frame count against the
|
||||||
|
manifest's `frame_count` and the input log's line count, and warns if they disagree
|
||||||
|
— the one place actual capture drift (if the recording PC couldn't sustain 30fps)
|
||||||
|
would surface, since it's not otherwise possible to detect from the video alone.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@ -52,6 +59,9 @@ spelunkai-record record --input-device /dev/input/event3 --output-dir recordings
|
|||||||
|
|
||||||
# fixed-length session
|
# fixed-length session
|
||||||
spelunkai-record record --input-device /dev/input/event3 --duration 120 --name run01
|
spelunkai-record record --input-device /dev/input/event3 --duration 120 --name run01
|
||||||
|
|
||||||
|
# slice a recorded session into individual frame images for labeling
|
||||||
|
spelunkai-record extract-frames --output-dir recordings --name run01
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
@ -64,5 +74,10 @@ ffmpeg binary required, so they run anywhere (including WSL):
|
|||||||
pytest
|
pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`test_frames.py` additionally runs a real end-to-end extraction against an
|
||||||
|
`ffmpeg`-generated synthetic test video (no display or game needed) when `ffmpeg` is
|
||||||
|
on `PATH`; it's skipped automatically otherwise (e.g. a bare WSL install without
|
||||||
|
`ffmpeg`).
|
||||||
|
|
||||||
Actual capture (`VideoCapture`/`KeyboardState` against real hardware) still needs to be
|
Actual capture (`VideoCapture`/`KeyboardState` against real hardware) still needs to be
|
||||||
validated end-to-end on the real recording PC with Spelunky Classic HD running.
|
validated end-to-end on the real recording PC with Spelunky Classic HD running.
|
||||||
|
|||||||
@ -7,6 +7,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from .capture import CaptureConfig, VideoCapture
|
from .capture import CaptureConfig, VideoCapture
|
||||||
from .devices import list_keyboard_devices
|
from .devices import list_keyboard_devices
|
||||||
|
from .frames import extract_frames
|
||||||
from .input_logger import KeyboardState
|
from .input_logger import KeyboardState
|
||||||
from .session import RecordingSession, session_paths
|
from .session import RecordingSession, session_paths
|
||||||
|
|
||||||
@ -41,13 +42,36 @@ def main() -> None:
|
|||||||
|
|
||||||
subparsers.add_parser("list-devices", help="List input devices that look like keyboards")
|
subparsers.add_parser("list-devices", help="List input devices that look like keyboards")
|
||||||
|
|
||||||
|
extract = subparsers.add_parser(
|
||||||
|
"extract-frames",
|
||||||
|
help="Slice a recorded session's video into individual frame images",
|
||||||
|
)
|
||||||
|
extract.add_argument(
|
||||||
|
"--output-dir", type=Path, default=Path("recordings"),
|
||||||
|
help="Directory containing the session's video/log/manifest",
|
||||||
|
)
|
||||||
|
extract.add_argument("--name", required=True, help="Session name (file prefix used during recording)")
|
||||||
|
extract.add_argument(
|
||||||
|
"--frames-dir", type=Path, default=None,
|
||||||
|
help="Where to write frame images (default: <output-dir>/<name>_frames)",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.command == "list-devices":
|
if args.command == "list-devices":
|
||||||
for path, name in list_keyboard_devices():
|
_run_list_devices()
|
||||||
print(f"{path}\t{name}")
|
elif args.command == "record":
|
||||||
return
|
_run_record(args)
|
||||||
|
elif args.command == "extract-frames":
|
||||||
|
_run_extract_frames(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_list_devices() -> None:
|
||||||
|
for path, name in list_keyboard_devices():
|
||||||
|
print(f"{path}\t{name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_record(args: argparse.Namespace) -> None:
|
||||||
session_name = args.name or datetime.now().strftime("%Y%m%d_%H%M%S")
|
session_name = args.name or datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
paths = session_paths(args.output_dir, session_name)
|
paths = session_paths(args.output_dir, session_name)
|
||||||
config = CaptureConfig(
|
config = CaptureConfig(
|
||||||
@ -72,5 +96,18 @@ def main() -> None:
|
|||||||
print(f"Stopped. Frames logged: {frame_count if frame_count is not None else 'interrupted'}")
|
print(f"Stopped. Frames logged: {frame_count if frame_count is not None else 'interrupted'}")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_extract_frames(args: argparse.Namespace) -> None:
|
||||||
|
paths = session_paths(args.output_dir, args.name)
|
||||||
|
frames_dir = args.frames_dir or (args.output_dir / f"{args.name}_frames")
|
||||||
|
result = extract_frames(paths, frames_dir)
|
||||||
|
print(f"Extracted {result.extracted_frame_count} frames to {result.frames_dir}")
|
||||||
|
if not result.is_in_sync:
|
||||||
|
print(
|
||||||
|
f"WARNING: frame count mismatch (video={result.extracted_frame_count}, "
|
||||||
|
f"manifest={result.manifest_frame_count}, input_log={result.input_log_line_count}). "
|
||||||
|
"Video capture may have dropped or duplicated frames relative to the input log."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
64
recording/src/spelunkai_recording/frames.py
Normal file
64
recording/src/spelunkai_recording/frames.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
"""Extract individual frames from a recorded session's lossless video.
|
||||||
|
|
||||||
|
Bridges the Recording Tool's video output to the Labeling Tool, which
|
||||||
|
operates on individual frame images rather than video. Frame filenames are
|
||||||
|
zero-padded by frame index so they line up 1:1 with the `frame` field in
|
||||||
|
the session's input log.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .session import RecordingPaths
|
||||||
|
|
||||||
|
FRAME_FILENAME_PATTERN = "frame_%06d.png"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FrameExtractionResult:
|
||||||
|
frames_dir: Path
|
||||||
|
extracted_frame_count: int
|
||||||
|
manifest_frame_count: int
|
||||||
|
input_log_line_count: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_in_sync(self) -> bool:
|
||||||
|
return self.extracted_frame_count == self.manifest_frame_count == self.input_log_line_count
|
||||||
|
|
||||||
|
|
||||||
|
def extract_frames(paths: RecordingPaths, frames_dir: Path) -> FrameExtractionResult:
|
||||||
|
if shutil.which("ffmpeg") is None:
|
||||||
|
raise RuntimeError("ffmpeg not found on PATH")
|
||||||
|
if not paths.video.exists():
|
||||||
|
raise FileNotFoundError(f"video not found: {paths.video}")
|
||||||
|
|
||||||
|
frames_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-i", str(paths.video),
|
||||||
|
"-vsync", "0",
|
||||||
|
"-start_number", "0",
|
||||||
|
str(frames_dir / FRAME_FILENAME_PATTERN),
|
||||||
|
]
|
||||||
|
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
|
||||||
|
return FrameExtractionResult(
|
||||||
|
frames_dir=frames_dir,
|
||||||
|
extracted_frame_count=len(list(frames_dir.glob("frame_*.png"))),
|
||||||
|
manifest_frame_count=_read_manifest_frame_count(paths.manifest),
|
||||||
|
input_log_line_count=_count_lines(paths.input_log),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_manifest_frame_count(manifest_path: Path) -> int:
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
return manifest["frame_count"]
|
||||||
|
|
||||||
|
|
||||||
|
def _count_lines(path: Path) -> int:
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
return sum(1 for _ in f)
|
||||||
67
recording/tests/test_frames.py
Normal file
67
recording/tests/test_frames.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from spelunkai_recording.frames import FrameExtractionResult, extract_frames
|
||||||
|
from spelunkai_recording.session import session_paths
|
||||||
|
|
||||||
|
FFMPEG_AVAILABLE = shutil.which("ffmpeg") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_in_sync_true_when_counts_match():
|
||||||
|
result = FrameExtractionResult(
|
||||||
|
frames_dir=Path("."), extracted_frame_count=3, manifest_frame_count=3, input_log_line_count=3,
|
||||||
|
)
|
||||||
|
assert result.is_in_sync
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_in_sync_false_when_counts_differ():
|
||||||
|
result = FrameExtractionResult(
|
||||||
|
frames_dir=Path("."), extracted_frame_count=3, manifest_frame_count=3, input_log_line_count=2,
|
||||||
|
)
|
||||||
|
assert not result.is_in_sync
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not FFMPEG_AVAILABLE, reason="ffmpeg not installed")
|
||||||
|
def test_extract_frames_matches_synthetic_video(tmp_path: Path):
|
||||||
|
paths = session_paths(tmp_path, "synthetic")
|
||||||
|
frame_count = 5
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-f", "lavfi", "-i", "testsrc=size=64x64:rate=10",
|
||||||
|
"-frames:v", str(frame_count),
|
||||||
|
"-c:v", "libx264rgb", "-qp", "0",
|
||||||
|
str(paths.video),
|
||||||
|
],
|
||||||
|
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
paths.manifest.write_text(
|
||||||
|
json.dumps({
|
||||||
|
"started_at": "2026-01-01T00:00:00+00:00",
|
||||||
|
"video_file": paths.video.name,
|
||||||
|
"input_log_file": paths.input_log.name,
|
||||||
|
"fps": 10,
|
||||||
|
"width": 64,
|
||||||
|
"height": 64,
|
||||||
|
"frame_count": frame_count,
|
||||||
|
}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with paths.input_log.open("w", encoding="utf-8") as f:
|
||||||
|
for i in range(frame_count):
|
||||||
|
f.write(json.dumps({"frame": i, "t": i / 10, "keys": []}) + "\n")
|
||||||
|
|
||||||
|
frames_dir = tmp_path / "synthetic_frames"
|
||||||
|
result = extract_frames(paths, frames_dir)
|
||||||
|
|
||||||
|
assert result.is_in_sync
|
||||||
|
assert result.extracted_frame_count == frame_count
|
||||||
|
assert (frames_dir / "frame_000000.png").exists()
|
||||||
|
assert (frames_dir / f"frame_{frame_count - 1:06d}.png").exists()
|
||||||
Loading…
x
Reference in New Issue
Block a user