Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<k>.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/<stem>` 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
Expand Down
1 change: 1 addition & 0 deletions musicalgestures/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
cuda_build_available,
cuda_unavailable_reason,
show_progress,
extract_wav,
)
from musicalgestures._mglist import MgList

Expand Down
13 changes: 12 additions & 1 deletion musicalgestures/_tracks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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())
Expand All @@ -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))

Expand Down Expand Up @@ -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

Expand Down
48 changes: 48 additions & 0 deletions tests/test_pyramid_on_demand.py
Original file line number Diff line number Diff line change
@@ -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<k>.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)
Loading