Skip to content

Add option to infer frame index from filename#1742

Open
romleiaj wants to merge 15 commits into
dev/sealtkfrom
dev/frame-alignment
Open

Add option to infer frame index from filename#1742
romleiaj wants to merge 15 commits into
dev/sealtkfrom
dev/frame-alignment

Conversation

@romleiaj

@romleiaj romleiaj commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Frame Alignment Feature (dev/frame-alignment)

What it does

Multi-camera datasets used to require every camera to have the same number of
frames
, aligned by position (frame 5 = frame 5). That breaks for capture systems
like KAMERA that drop frames independently, so frame 5 of one camera isn't the
same instant as frame 5 of another.

This branch aligns cameras by when each frame was captured instead of by
position:

  1. Reads a capture timestamp from each image's filename (e.g. KAMERA's
    ..._20240407_130757.206341_ir.tif).
  2. Builds one shared timeline, grouping frames with the same capture
    timestamp into the same slot.
  3. Allows cameras to have different frame counts; a camera with no frame at a slot
    shows "No frame at this instant" rather than a stale image.

If timestamps aren't available (or it's a single-camera dataset), it falls back to
the old positional behavior — existing datasets are unaffected.

What a user sees

Element Where Purpose
"Infer frame index from filename" checkbox Import dialog Opt-in; allows unequal frame counts. Off by default (old equal-length check still applies).
"No frame at this instant" overlay Camera pane Shown when a camera has no frame at the current slot.
Gap indicator bar Under the scrubber Marks timeline slots where a camera is missing a frame.

How it works

  • Timestamp parsing — one shared module (client/dive-common/frameTimestamp.ts)
    used by both platforms: desktop parses local media on load, web parses after
    fetching from girder. The server does not parse timestamps.
  • Timelineclient/dive-common/alignedTimeline.ts (pure, unit-tested) turns
    per-camera timestamped frames into global slots, and computes the gap slots.
  • Playback — the viewer hands the media controllers an AlignedFrameResolver
    that maps a global slot to each camera's local frame (or "no frame"), keeping all
    panes in sync during seek/scrub.
  • Import — both the client validation and the server (create_multicam) no
    longer reject cameras with differing frame counts.

Note

The KAMERA YYYYMMDD_HHMMSS.ffffff convention is confirmed against real sample data
(data/test_data) and locked by a test. Cameras must currently share an exact
timestamp to align (tolerance 0); this can be widened later if real captures show
sub-second jitter. The epoch-based fallback patterns are likewise best-effort for
other capture systems.

romleiaj and others added 14 commits July 7, 2026 11:44
  Multicam datasets assume cameras are frame-aligned by position, but
  cameras in a rig can independently drop frames, so index i isn't
  necessarily the same real-world moment across cameras. Phase I of
  SEAL feature 5: derive a best-effort timestamp per frame from its
  filename and expose it on FrameImage/MediaResource, so later phases
  can build a real aligned timeline instead of assuming index parity.

  Parsing is provisional (no real MML sample filenames yet) and fully
  optional -- undefined/None is the expected common case and must not
  affect existing datasets. No meta.json schema change: the timestamp
  is computed at read time from the filename, mirroring how url/
  filename are already derived, not persisted.
  Multicam playback assumed cameras were frame-aligned by position, but
  cameras in a rig can independently drop frames, so index i wasn't
  necessarily the same real-world moment across cameras. Phase II of
  SEAL feature 5: when every camera has a timestamp on every frame,
  build a global aligned timeline and resolve each camera's own local
  frame per slot, blanking a camera's pane when it has none at that
  instant. Falls back byte-identically to today's positional broadcast
  whenever any camera lacks full timestamp coverage -- zero risk to
  existing datasets.

  Also fixes a latent gap this surfaced: continuous playback bypassed
  the aggregate controller entirely (each camera free-ran its own
  frame-advance loop), so aligned mode would have applied to scrubbing
  but silently reverted to raw per-camera advancement during playback.
  Centralizes a single tick in the aggregate controller when aligned.
…panes

      Phase III of SEAL feature 5. Phase II blanked a camera's image when the
      global aligned timeline has no frame for it at a slot, but left two
      gaps: LayerManager kept drawing annotations for whatever local frame
      the camera was on before blanking, since seek(undefined) deliberately
      leaves data.frame untouched and LayerManager's watchers are keyed on
      frame number -- so stale boxes floated over an otherwise-blank pane.
      The pane itself also gave no visual indication it was blanked rather
      than just failing to load.

      Expose the existing per-camera hasFrame flag on MediaController, and
      have LayerManager watch it: every prior updateLayers() call site now
      goes through a new refreshLayers(), which disables all annotation/edit
      layers instead whenever hasFrame is false. ImageAnnotator/VideoAnnotator
      now render a 'eNo frame at this instant' overlay over a blanked pane.
…ndicator

Track create/edit/delete and seek operations were keying off the global
aligned slot index instead of the selected camera's local frame, writing
detections to wrong frame numbers when alignment is active. Adds
selectedCameraFrame() in useModeManager, seekCameraFrame() with an
inverse aligned index, per-selected-camera time updates in Viewer, and
an aligned-gap indicator bar under the frame slider in Controls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
imageData was initialized as { singleCam: [] } and multicam loading only
added per-camera keys on top, so the leftover empty singleCam entry made
canAlign() disqualify every multicam dataset. Rebuild imageData with
exactly the dataset's cameras before loading (replacing the whole object,
since adding keys via bracket assignment is non-reactive under Vue 2.7)
and restrict the alignedTimeline computed to the multicam camera list so
stale keys can never disqualify alignment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
loadData calls changeCamera(defaultCamera) before progress.loaded, so no
annotator (and thus no camera controller) exists yet and the aggregate is
the empty stub whose getController() throws. That produced an unhandled
promise rejection on every multicam load and skipped selectCamera's
edit-mode restore and change-camera emit. selectedCameraFrame() and
isCreatingNewDetection() shared the same throw exposure whenever a
camera's annotator never mounted. Wrap all three lookups with a graceful
fallback (skip the time resync / fall back to the aggregate frame).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Viewer.vue's seekToFrame (track-seek from TrackItem/TrackList/
TrackDetailsPanel/AttributesSubsection keyframe navigation, BottomPanel)
and ControlsContainer's Timeline chart click-seek were calling the
aggregate seek(), which operates in global slot space under an aligned
timeline, with frames produced in the selected camera's local frame
space. Translate both through seekCameraFrame(selectedCamera, frame)
(a passthrough when alignment is inactive), and drive the Timeline
playhead from time.frame -- which tracks the selected camera's local
frame -- so charts, playhead, and click-seek all agree on frame space.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gap gradient painted slot s over [s/maxFrame, (s+1)/maxFrame], a full
cell to the right of the v-slider thumb (which sits at s/maxFrame), and a
trailing gap at s === maxFrame painted [100%, 100+%] -- invisible, since
the widening branch only ran for sub-0.25% cells. Extract the computation
into a pure computeGapGradient() in alignedTimeline.ts that centers each
band on the thumb position ([(s-0.5)/maxFrame, (s+0.5)/maxFrame], clamped
to [0, 100]) and unit-test it, including edge slots and min-width
widening.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
setAlignedFrameResolver() reset alignedCurrentFrame to 0 without seeking,
so a camera that should be blank at slot 0 kept showing its local frame 0
until the first user seek. Perform an aligned seek to slot 0 on resolver
install, and re-apply the current slot (flush: 'post') whenever a camera
registers while a resolver is active -- annotators self-seek to their own
local frame 0 during init, which covers the common ordering where the
resolver is installed before the annotators mount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…reinstalls

Sorting triples camera-name-first swept all of one camera's same-time
frames into consecutive single-camera slots, so a calibration dataset
where every frame shares one collection timestamp produced a timeline of
mostly blank panes instead of positional pairing; tie-break equal
timestamps by local index first. Also key the Viewer's resolver install
on the slot structure so display-URL swaps (percentile stretch) that
leave timestamps unchanged no longer reinstall the resolver and visibly
re-seek every camera.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add an "Infer frame index from filename" toggle to the multicam import
dialog. When enabled, the equal-frame-count validation is skipped so
datasets with dropped frames (e.g. KAMERA, which encodes a capture
timestamp per filename) can be imported and aligned downstream by their
filename timestamps rather than by exact positional index.

Also drop the matching server-side equal-frame-count rejection in
create_multicam, keeping the frame-count call only for its validation
side effect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the hand-synced Python + TypeScript filename timestamp parsers with
one shared TS module (dive-common/frameTimestamp.ts) used by both platforms.

The girder server previously parsed each frame's timestamp in get_media and
embedded it in the /media response, but nothing server-side ever read it -- it
existed only to reach the client. Move parsing to the client: the web-girder
API layer now attaches timestamps from filenames after fetching media (both the
single-cam /media response and multicam per-camera imageData from /dive_dataset),
mirroring how the desktop backend already parses on media enumeration.

- Add dive-common/frameTimestamp.ts (parseFrameTimestamp + attachFrameTimestamps)
  and move its tests out of sharedUtils.spec.ts.
- Point the desktop backend importers at the new shared module.
- Delete server dive_utils/timestamp_parser.py and its test; drop the now-dead
  MediaResource.timestamp field and the get_media parsing call; update the
  multicam dataset tests that asserted server-side timestamps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@romleiaj romleiaj requested a review from BryonLewis July 7, 2026 16:38
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