Skip to content

feat!: multi-ROI motion energy from binary masks, one NPZ per video - #46

Merged
1mattbecker merged 12 commits into
devfrom
feat/multi-roi-masks
Oct 21, 2026
Merged

1mattbecker merged 12 commits into
devfrom
feat/multi-roi-masks

Conversation

@1mattbecker

@1mattbecker 1mattbecker commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Motion energy for any number of ROIs of any shape, computed in one decode pass per video.

  • ROIs are binary masks. The ROI annotator's *_masks.npz export (masks (H, W, K), roi_ids) is the input. Boxes, polygons and compound shapes all arrive as masks. --roi-masks DIR matches one file per camera by name, ignoring case and separators, so the annotator's …_BottomCamera_masks.npz matches bottom_camera.mp4. --roi X Y W H still works as a single rectangular mask. In Python, ROIs are a plain dict of name → boolean mask.
  • One pass per frame. Each frame is decoded and differenced once. The whole frame and every ROI are summed from that diff in one vectorized call, so cost grows with the total masked area, not the ROI count.
  • One NPZ per video replaces the four .npy files. It holds me and me_clean (frames × (1+K), column 0 = full_frame), keyframe_mask, first_frame, roi_ids, pixel_counts and avg_map. It is written atomically, and the input masks are copied next to it for provenance.
  • Frame-aligned outputs. Saved traces, and the traces compute_motion_energy returns, have one value per frame (row i = frame first_frame + i), with NaN at row 0, which has no previous frame.
  • QC plots. The trace PNG shows the full-frame trace above an ROI × time z-scored heatmap. The map PNG and the MP4 show ROI outlines, and --viz-rois chooses which traces scroll in the MP4.
  • Smaller CLI changes. --csv replaces --format. An invalid --start-frame/--end-frame range is refused up front, as are masks that are empty or don't match the video's size.

Breaking changes

  • The .npy outputs are replaced by {stem}_motion_energy.npz. The old motion_energy_clean.npy is now npz["me_clean"][:, 0].
  • Readers must stop adding their own leading NaN. The library now pads frame 0. The metadata key n_me_frames is removed, so existing padding logic fails loudly instead of shifting traces by one frame.
  • compute_motion_energy(rois={name: mask}) returns a frame-aligned MotionEnergyResult (it was roi= returning a 4-tuple of N−1 arrays).
  • --format {npy,csv,both} is replaced by --csv.

Downstream updates (kinematics_analysis, the ME capsule, the batch launcher, the ROI annotator) are listed in docs/plans/downstream-migration.md. They are not part of this PR. The version bump to 0.4.0 will follow as a separate chore: PR.

Testing

  • 101 tests, 100% coverage; ruff, ruff format and mypy --strict are clean.
  • Real session: behavior_800886_2025-09-11_13-52-51 (flat layout, 1000 fps, 2.6M frames per camera), with 10 ROIs drawn in the annotator. Every ROI trace matches a brute-force per-mask reference (max error 1e-7), and the outlines sit on the intended anatomy. 6 ROIs cost about 8% throughput (1040 → 955 fps).
  • Sample clip: whole-frame-only throughput matches 0.3.0. 100 ROIs of ~3,000 px run at about 55% of it; kernel measurements are in docs/design-notes.md.
  • The IBL comparison notebook runs and reaches the same conclusions.

A simplification pass before merge removed ~250 lines: the RoiSet class, bounding boxes, size guards and the separate frame-alignment step. The real-session check was re-run afterwards with identical results.

Plan and as-built notes: docs/plans/multi-roi-motion-energy.md.

🤖 Generated with Claude Code

1mattbecker and others added 12 commits October 8, 2026 12:08
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saved traces, keyframe mask and CSV now have length N (frame 0 = 0) instead
of N-1 per-diff values, so index i lines up with video frame start + i.
compute_motion_energy still returns raw per-diff arrays; the new
align_to_frames helper pads at the CLI edge. Also splits cli.main() into
_build_parser() and _process_video().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ROIs of any shape and number are consumed as binary masks (the ROI
annotator's *_masks.npz export). Each frame is decoded and differenced
once; the whole-frame trace and every ROI are reduced from that same diff
with one fancy-index + np.add.reduceat call (exact uint32/uint64 sums).

- rois.py: RoiSet (compact flat-index storage, validation, size guards on
  total mask area), RoiSet.from_box for --roi, load_roi_masks.
- compute: compute_motion_energy(rois=...) returns MotionEnergyResult with
  me (T, 1+K), column 0 = full_frame; full-frame avg map accumulated in
  uint32. clean_trace handles (T, K).
- cli: --roi-masks (dir matched by camera key, or one file for one video),
  validated before any processing; one {stem}_motion_energy.npz per video
  (atomic write), wide CSV, masks copy + sha256 in metadata, --viz-rois.
- viz: QC trace PNG (full-frame trace + ROI x time z-score heatmap), ROI
  outlines on the map and in the MP4, multi-trace scrolling plot.

BREAKING CHANGE: the four per-video .npy outputs are replaced by
{stem}_motion_energy.npz; compute_motion_energy's roi= tuple is replaced by
rois=RoiSet and it returns a MotionEnergyResult; --format npy is now npz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ample notebook

README flags/outputs/ROIs sections, design notes (mask pathway, kernel
measurements, NPZ rationale and limits), optimization backlog, the example
notebook's compute_motion_energy call, and the plan's as-built notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Frame 0 has no preceding frame, so its motion energy is undefined, not
zero. A 0.0 pad looked like a real still frame, biased means, and put an
outlier at t=0 under z-scoring. Saved me/me_clean (NPZ and CSV) now start
with NaN; the keyframe mask still starts with False.

- align_to_frames: default fill is NaN for float arrays, False/0 otherwise
  (np.full(nan, dtype=bool) would silently give True).
- viz: NaN-aware binned z-score for the ROI heatmap and NaN-safe y-limits.
- cli: NaN-aware summary line; metadata first_frame_fill is null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…races

Found by running a real session (behavior_800886, flat layout, 1000 fps,
2.6M frames/camera) with ROIs drawn in the annotator:

- Mask files are matched to videos ignoring case and separators, on whole
  '_'-separated name parts, so the annotator's *_BottomCamera_masks.npz
  matches bottom_camera.mp4. Mask files matching no video are listed.
- Plots drop the camera prefix shared by all ROI ids (legend, outlines,
  heatmap labels); saved ids are unchanged.
- compute_motion_energy writes per-frame sums into a preallocated buffer
  (grown if the probed frame count is short) instead of a list of arrays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on list

Saved traces are now frame-aligned (length N, NaN row 0), but 0.3.0 readers
pad when n_me_frames == n_frames_decoded - 1. Renaming the key makes that
code fail with a KeyError instead of silently padding twice.

docs/plans/downstream-migration.md lists the changes needed in
kinematics_analysis, the ME capsule, the batch launcher and the ROI
annotator before 0.4.0 outputs are used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…frame_index array

Row i is video frame first_frame + i. The per-row int64 array cost 21 MB per
camera on a 2.6M-frame session and only repeated the row number in
whole-video runs. The CSV keeps its frame_index column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The NPZ is always written, so --format only ever switched the CSV on, and
'csv' and 'both' did the same thing. --csv says what it does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A negative start, or an end not after the start, used to give silently
empty outputs. check_frame_range raises in compute_motion_energy and the
CLI reports it before any video is processed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gned traces

Removes ~250 lines of library code without losing ROI features:

- ROIs are a plain dict of name -> (H, W) bool mask. The RoiSet class
  (compact index storage, bounding boxes, mask reconstruction) is gone;
  load_roi_masks returns the dict, box_roi builds --roi's one-entry dict,
  check_rois validates. The summing setup is a few lines in compute.
- Size guards (area warn/refuse, ROI-count warn) and the uint32/uint64
  switch are removed; frames over 16.8 megapixels are refused instead.
- compute_motion_energy returns frame-aligned traces (row 0 NaN) plus
  first_frame, so Python results match the saved files. align_to_frames,
  n_diffs, n_output_frames and first_frame_fill are gone; n_me_frames
  stays removed so old padding code still fails loudly.
- Outlines are contoured from the full-size masks (no crop/pad math).
- roi_bboxes and the masks SHA-256 are dropped; the copied masks file is
  the provenance.

Re-checked on behavior_800886_2025-09-11_13-52-51 (both cameras, 10 ROIs):
traces match a brute-force reference (max error 1.1e-7), summary numbers
match the previous run, outlines land on the drawn anatomy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@1mattbecker 1mattbecker left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reviewed

@1mattbecker
1mattbecker merged commit bb319e4 into dev Oct 21, 2026
6 checks passed
@1mattbecker
1mattbecker deleted the feat/multi-roi-masks branch October 22, 2026 18:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant