diff --git a/CHANGELOG.md b/CHANGELOG.md index 38f6fe0..facbfa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **`_tracks.read_columns` failed on any recording long enough to need a pyramid level.** + `extract_tracks` and `extract_tracks_parallel` write the base videograms only, and + `read_columns` memory-mapped `videogram_v.L.u1` without checking it existed, so the first + read of a real concert raised `FileNotFoundError` unless the caller knew to run + `build_pyramid` first. It now builds the levels on first use. + ### Added +- `tracks.json` (and the dict both extractors return) carries `analysis_dir`, so a caller can + go from `extract_tracks_parallel(...)` to `read_columns`/`check_tracks` without + reconstructing the `analysis/` path convention. +- `extract_wav` is exported from the package root. - **Events against events** — new `_events` module. `event_alignment` measures how far each event of one stream (strokes, footfalls, looks) falls from the nearest event of another (note onsets, beats, cues) against uniformly placed surrogate references, and says whether diff --git a/musicalgestures/__init__.py b/musicalgestures/__init__.py index 9f42b4d..8e24ce1 100644 --- a/musicalgestures/__init__.py +++ b/musicalgestures/__init__.py @@ -24,6 +24,7 @@ cuda_build_available, cuda_unavailable_reason, show_progress, + extract_wav, ) from musicalgestures._mglist import MgList diff --git a/musicalgestures/_tracks.py b/musicalgestures/_tracks.py index fb7b993..2975ba8 100644 --- a/musicalgestures/_tracks.py +++ b/musicalgestures/_tracks.py @@ -282,6 +282,7 @@ def extract_tracks(video, out_dir=None, filtertype="Regular", threshold=0.05, "different places keeps a faint ghost of each of them " "everywhere they stood; a median removes them, because at " "any pixel they are a minority of the samples.") + meta["analysis_dir"] = str(d) (d / "tracks.json").write_text(json.dumps(meta, indent=1) + "\n") return meta @@ -330,7 +331,8 @@ def read_columns(analysis_dir, start_s=0.0, end_s=None, max_columns=2000, the display can use and read a slice of it, rather than reading everything and throwing most of it away. - Returns (columns, seconds_per_column). + Levels are built on first use (`build_pyramid`), so a fresh extraction can be read + straight away. Returns (columns, seconds_per_column). """ d = Path(analysis_dir) meta = json.loads((d / "tracks.json").read_text()) @@ -350,6 +352,14 @@ def read_columns(analysis_dir, start_s=0.0, end_s=None, max_columns=2000, arr = np.memmap(d / meta[which], dtype=np.uint8, mode="r", shape=(n, span)) else: name = f"{which}.L{level}.u1" + if not (d / name).exists(): + #: The extractors write the base only; the levels are cheap and derived, so the + #: first reader that needs them builds them rather than failing on a missing file. + build_pyramid(d, which=which) + if not (d / name).exists(): + raise FileNotFoundError( + f"{name} not in {d}: the pyramid stops above {MIN_LEVEL_COLUMNS} columns, " + f"so ask for max_columns >= {MIN_LEVEL_COLUMNS} or read level 0") rows = n // stride arr = np.memmap(d / name, dtype=np.uint8, mode="r", shape=(rows, span)) @@ -434,6 +444,7 @@ def extract_tracks_parallel(video, out_dir=None, workers=None, chunk_s=120.0, "performer everywhere they stood.") for f in plate_files: f.unlink() + meta["analysis_dir"] = str(d) (d / "tracks.json").write_text(json.dumps(meta, indent=1) + "\n") return meta diff --git a/tests/test_pyramid_on_demand.py b/tests/test_pyramid_on_demand.py new file mode 100644 index 0000000..1144e85 --- /dev/null +++ b/tests/test_pyramid_on_demand.py @@ -0,0 +1,48 @@ +"""read_columns must work on a fresh extraction, and callers must be able to find it. + +The bug: the extractors write the videogram base only, and `read_columns` mapped +`videogram_v.L.u1` without checking, so the first read of any recording long enough to +need a coarser level raised FileNotFoundError until someone ran `build_pyramid` by hand. +""" +import json +import subprocess + +import numpy as np + +from musicalgestures._tracks import MIN_LEVEL_COLUMNS, extract_tracks, read_columns + + +def _synth(path, seconds=12, fps=25, size="160x120"): + subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "lavfi", + "-i", f"testsrc=size={size}:rate={fps}:duration={seconds}", + "-pix_fmt", "yuv420p", str(path)], check=True, capture_output=True) + return str(path) + + +def test_read_columns_builds_the_pyramid_it_needs(tmp_path): + v = _synth(tmp_path / "v.mp4") + meta = extract_tracks(v, out_dir=tmp_path / "out", progress=False) + d = tmp_path / "out" / "v" + assert meta["frames"] > 2 * MIN_LEVEL_COLUMNS, "clip too short to need a level" + assert not list(d.glob("videogram_v.L*.u1")) # nothing built yet + cols, spc = read_columns(d, max_columns=MIN_LEVEL_COLUMNS + 10) + assert cols.shape[0] < meta["frames"] and cols.shape[1] == meta["height"] + assert spc > 1.0 / meta["fps"] # a coarser level was used + assert list(d.glob("videogram_v.L*.u1")) # and it is now on disk + assert "pyramid" in json.loads((d / "tracks.json").read_text()) + # the level keeps extremes: no coarse column exceeds the base maximum + base = np.memmap(d / meta["videogram_v"], dtype=np.uint8, mode="r", shape=(meta["frames"], meta["height"])) + assert cols.max() <= np.asarray(base).max() + + +def test_meta_says_where_it_lives(tmp_path): + v = _synth(tmp_path / "v.mp4", seconds=2) + meta = extract_tracks(v, out_dir=tmp_path / "out", progress=False) + assert (tmp_path / "out" / "v" / "tracks.json").exists() + assert meta["analysis_dir"] == str(tmp_path / "out" / "v") + assert json.loads((tmp_path / "out" / "v" / "tracks.json").read_text())["analysis_dir"] == meta["analysis_dir"] + + +def test_extract_wav_is_exported(): + import musicalgestures as mg + assert callable(mg.extract_wav)