Skip to content

Add keypoint GUI for manual calibration - #1749

Merged
BryonLewis merged 13 commits into
dev/sealtkfrom
sealtk/keypoint-gui
Jul 15, 2026
Merged

Add keypoint GUI for manual calibration#1749
BryonLewis merged 13 commits into
dev/sealtkfrom
sealtk/keypoint-gui

Conversation

@romleiaj

@romleiaj romleiaj commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

@romleiaj
romleiaj marked this pull request as draft July 9, 2026 21:17
@romleiaj romleiaj mentioned this pull request Jul 9, 2026
@romleiaj
romleiaj changed the base branch from sealtk/calibration to sealtk/image-registration July 10, 2026 16:53
@romleiaj
romleiaj force-pushed the sealtk/image-registration branch from 30ebbee to a6d545d Compare July 10, 2026 18:38
@romleiaj
romleiaj force-pushed the sealtk/keypoint-gui branch 2 times, most recently from f25924b to 0de24f9 Compare July 12, 2026 15:12
@romleiaj
romleiaj force-pushed the sealtk/image-registration branch from b60430b to 1d9a2eb Compare July 12, 2026 15:54
@romleiaj
romleiaj force-pushed the sealtk/keypoint-gui branch from eca04d4 to 55f0eaa Compare July 12, 2026 15:55
@romleiaj
romleiaj marked this pull request as ready for review July 12, 2026 15:56
@romleiaj
romleiaj requested a review from BryonLewis July 12, 2026 15:56
Base automatically changed from sealtk/image-registration to dev/sealtk July 13, 2026 20:12
romleiaj and others added 6 commits July 14, 2026 08:42
Extend CameraCalibrationStore with the Manual Alignment creation state
and operations: active pair, point picking (add/pick/move/drag-refine,
select/delete, clear last/pair), transform fitting (fitTransform /
maybeFitPair via the transform estimators), the ghost-overlay alignment
mode/opacity, linked-nav toggle, and cursor/recenter plumbing.
CalibrationKeypointLayer renders the picked correspondences (locked
blue / unlocked yellow, exact-pixel markers, click-select and drag) and
the cross-pane ghost warp preview; CameraImage moves here with it.
useCalibrationNavigation links the active pair's panes through the
fitted homography while picking (standing down under the Align View).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CalibrationTools.vue (modeled on VIAME's keypointgui): pick point pairs
across a camera pair, fit the chosen transform model with a
fit-readiness countdown, preview the fit as a ghost overlay with warp
opacity, review rig alignment status (N/M cameras calibrated), and
save/load the calibration -- to dataset meta and the portable
calibration.json. Wired into the Viewer as a context panel: workspace
chrome minimizes while calibrating, only the active pair's panes show
on 3+ camera rigs, the general Align warp suspends while picking so
picks are always native-space, and LayerManager mounts the keypoint
marker/ghost layer per pane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rename the feature's own files and identifiers to the registration
vocabulary the rest of the branch adopted: CalibrationTools ->
RegistrationTools (CameraRegistration/), CalibrationKeypointLayer ->
RegistrationKeypointLayer, useCalibrationNavigation ->
useRegistrationNavigation, and the calibration-named locals, props, and
UI strings that referenced them. Stereo calibration (a genuinely
different artifact) keeps its name.

Reuse shared helpers where the feature had its own copies: the panel's
unknown-camera notice now comes from unknownCameraWarning, and the
geojs warp-quad mapping duplicated between AlignedImageLayer and the
keypoint ghost is extracted to geojsWarpQuads in alignedView/homography.
The linkedViewers README's "planned" registration-pair-link section now
describes the shipped composable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same replacement as the Aligned View warp: the ghost polled with a
bounded requestAnimationFrame loop to catch the source pane swapping
its <img> element after a seek. The source annotator now bumps
imageRevision on that swap, so LayerManager watches the ghost source
camera's revision (alongside its frame number) and re-renders the
ghost from the element actually on screen -- with no ~1s give-up
window on slow-loading frames.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rename the Manual Alignment panel to Camera Registration and scope it to
its actual job -- picking points, fitting, and saving:

- Drop the panel's "Load registration" button: the Import menu's
  per-camera registration targets are the one import path.
- Drop the panel's export button too: the Export menu's per-camera
  registration downloads (web and desktop) are the one export path. The
  refined-from-source notice now points there (save first -- the menu
  reads saved meta).
- Remove CameraRegistrationStore.toRegistrationJson, the last whole-rig
  (all pairs in one file) serializer; per-camera
  <camera>_to_<reference>_registration.json files are the only
  registration file format. loadRegistrationText stays as the shared
  parser, and its round-trip specs now serialize through the production
  buildPerCameraRegistrationFiles.
- Protect unsaved panel edits on exit: the browser beforeunload warning,
  the navigate-away prompt, and the desktop close dialog now fire on
  registration dirt too (hasUnsavedChanges = pending annotation saves OR
  a dirty registration), and the desktop dialog's Save choice persists
  the registration via the new Viewer.saveRegistration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A registration imported from a producer (e.g. KAMERA) carries matrices but
no picked points, and the panel treated it as incomplete: "Fit pan/zoom"
stayed greyed out (it gated on point count, though the link only needs a
homography), the status line claimed points were still needed, and picking
was force-enabled so stray clicks placed points on a registration that
needed none.

- Add a three-state transform status (loaded from file / fit from N point
  pairs / none) and gate the pan/zoom link on having a transform at all,
  renaming it "Link pan/zoom"; enabling it fits the pair on demand.
- Make picking an explicit "Pick points" toggle scoping the authoring UI
  (cursor readout, correspondences, transform type), defaulting per pair:
  off in review of a file-loaded transform, on when points are needed.
- Decouple linked navigation from picking mode -- it now runs whenever the
  panel has a pair and the link is on; the panel clears the pair (and with
  it the warp ghost) on close to keep that scoped.
- After a registration import, summarize what the loaded transform drives
  and, when the panel is open, re-select the imported pair in review
  posture (hydrate cleared it and left the panel inert).
- Confirm before "Save registration" overwrites existing per-camera
  registration files, naming exactly the files whose content changes --
  untouched pairs rewrite byte-identical and don't warn -- along with the
  provenance stamp being replaced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@romleiaj
romleiaj force-pushed the sealtk/keypoint-gui branch from 2151cd0 to 128d794 Compare July 14, 2026 12:54
Overview canvases are smaller than native frame size; crop rects must
follow the texture or ghosts/Align View sample the wrong texels.
… ghosts

Tiled panes have no media quad; a capped overview canvas lets warps and
ghosts draw while tiles remain the interactive native view.
Tiled maps keep clamp and a native tile fit while Align is off; without
an Align-on snap and shared bounds linking, panes look stranded or ignore
snaps.
Calling them from async loadData left pan/zoom listeners without
onBeforeUnmount and triggered Vue instance warnings.
…loss

Re-creating maps without exit() exhausts WebGL contexts; layers then
throw on draw against a dead renderer.
@BryonLewis

BryonLewis commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Most of the changes are related to getting a large-image camera to align with images. This is because large-image is used for the images that have higher bit-depth and need to be rescaled like IR images. So a lot of my changes were fixing/adjusting and adding functions to handle this

  • Large-image overview frameTexture — Tiled large-image panes have no img/video quad for Align View / registration ghosts. LargeImageAnnotator now composites a capped overview canvas and publishes it as frameTexture so warps/ghosts have a texture source.
  • geojsWarpQuadsForImage crop remapping — Overview canvases are smaller than native frame size. Crop rects now scale into texture pixels while corners stay in native/map space, fixing massively wrong Align / ghost scaling.
  • CameraImage shared type (incl. canvas) — Texture type moved to cameraImage.ts and allows HTMLCanvasElement, so image/video/large-image share one warp input model.
  • Align View navigation for large-image — On Align-on: unlock GeoJS clamp, fit reference native frame, mirror that FOV via zoomAndCenterFromBounds (not zoom-0 unitsPerPixel). Without this, tiled maps stay on a native tile fit and look stranded / clamped after warps switch to reference coords.
  • Multicam clamp unlock for tiled panes — resetMapDimensions used to skip clamp unlock when isMap=true, so Align snaps were silently rejected on large-image. Multicam now unlocks clamp for all panes.
  • Viewer registers linked navigation in setup() — useAlignedNavigation / useRegistrationNavigation moved out of async loadData() so onBeforeUnmount attaches correctly and pan/zoom listeners tear down (fixes Vue instance warnings / leaks).
  • GeoJS map / WebGL lifecycle hardening — Exit previous maps on re-init/clear; guard annotation redraw/disable after context loss (“Too many active WebGL contexts”) so orphaned maps don’t kill redraws.
  • Large-image seek blanks missing frames — seek(undefined) clears tiles/frameTexture like image/video annotators, for aligned-timeline slots with no frame on that camera.

@BryonLewis BryonLewis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

went through and tested, then did several updates for web/girder support mostly related to large-image being part of the transformation/alignment process when it wasn't before.

Also slightly modified the alignment toggle button to provide a button link to Camera Registration so user's don't ahve to find it in the dropdown box

@BryonLewis
BryonLewis merged commit 2cd2b61 into dev/sealtk Jul 15, 2026
3 checks passed
@BryonLewis
BryonLewis deleted the sealtk/keypoint-gui branch July 15, 2026 12:54
BryonLewis added a commit that referenced this pull request Jul 15, 2026
* Percentile stretch Image Enhancement option (#1690)

* Fix spelling error, move duplicate filter IDs to a single variable

* Add percentile stretch option to 'Image Enhancements' for thermal imagery.

* Fix per-camera image enhancement state and persistence

  Each camera now maintains independent enhancement values. Viewer.vue
  loads enhancements from each camera's sub-meta instead of root meta,
  saves to the correct camera-scoped path via a per-camera debounce map,
  and flushes pending saves before switching cameras.

* Fix Vue 2 reactivity, spurious save, and percentileStretch aliasing in per-camera enhancements

* Sync image enhancement sliders when a new camera is selected

* client/platform/desktop/backend/media/

* Fix seven correctness and quality issues in display processing backend

Float32/Float64 TIFFs now use sort-based percentile instead of the
integer histogram, which collapsed sub-integer values to a single bin
and produced solid-color output silently. Uint8/Uint16 bin counts are
now type-matched (256 vs 65536).

Zero-range frames (flat field, dead sensor) now render as mid-gray;
the previous check tested only raw[0] against min, so every uniform
frame filled with black.

Cache keys now include mtimeMs so entries are invalidated when a source
file is replaced at the same path, matching the tile cache pattern.

fs.statSync replaced with await fs.stat to avoid blocking the event
loop on a per-frame hot path. The two try-catch blocks collapsed into
one with ENOENT distinguished for a 404.

Array.isArray guards added for low and high query params; duplicate
params previously passed validation and silently discarded extra values.
A low >= high check catches inverted percentile inputs at the boundary.

encodePngRgba deduplicated — exported from displayProcessing.ts and
imported by geotiffTiles.ts, removing the unused pngjs import.

* Wire percentile stretch UI: toggle/sliders in ImageEnhancements, display URL remapping in Viewer

* fix: percentile slider bounds, signed-16 histogram, per-camera URL debounce, stale isDefaultImage, TIFF lazy load, and in-flight decode dedup

* fix: derive display URL origin from rawUrl to bypass Vite proxy in Electron

* fix: stretch original TIFF via dataset id+frame, pass through non-TIFF originals

* Reuse the existing viewer when imageData URLs are swapped in place

The imageData watcher rebuilt the whole viewer via initializeViewer,
which replaces the geojs map and orphans every feature layer and event
handler other components attached to it (LayerManager annotation layers
crash with 'null.api' on redraw; calibration click handlers go dead).
An imageData change on a mounted annotator is a display-URL swap for
the same camera (percentile stretch), so rebuild only the image cache
and redraw the current frame on the existing map; drawImage already
handles dimension changes via resetMapDimensions.

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

* Linting

* Change stretch to 0-1 on the low end, 99-100 on the high end, more realistic for IR data.

* disable percentile stretch on unsupported imagery

* add histogram viewer to percentile stretch

* web support for percentile stretching

* updating docs about image enhancements

* support for multicamera IR/EO importing from parent folder for electron and web

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Bryon Lewis <Bryon.Lewis@kitware.com>

* Add a batch import for multicam datasets (#1745)

* Add batch multicam collect-folder scanner

Scans a root folder of collect folders (collect -> camera -> images) and
produces per-collect beginMultiCamImport arguments for MML seal surveys,
without copying imagery. Camera subfolders are matched case-insensitively
across collects; the canonical camera set is the union of image-bearing
subfolder names, limited to 2-3 alphanumeric cameras to match the existing
multicam subfolder import. Missing/empty/imageless camera folders are
reported as per-collect blocking problems; frame-count mismatches are
non-blocking warnings (seal collects legitimately drop frames). Default
display camera follows pickDefaultMulticamCamera (center/middle alias,
else the middle camera of display order).

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

* Add batch multicam import UI and desktop wiring

New 'MultiCam Batch' entry in the Open Image Sequence dropdown (desktop
only, behind a new batch-multi-cam-import prop on ImportButton) opens
ImportMultiCamBatchDialog: pick a root folder, review a per-collect
validation summary table (cameras, frame counts, problems/warnings),
then import all valid collects sequentially via the existing
import-multicam-media / finalize-import machinery, continuing past
per-collect failures and reporting a final summary. Adds the
scan-multicam-batch IPC handler and scanMultiCamBatch frontend API.

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

* make the dropdown area larger to display full multicam batch import option

* editable batch import datasets

* add support for web to batch upload multicam datasets

* update documentation for batch multi-camera import functionality

* linting

* ordering multicam imports

* prevent stereoscopic loading and calculations when in multicamera mode

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Bryon Lewis <Bryon.Lewis@kitware.com>

* Add option to infer frame index from filename (#1742)

* Parse per-frame capture timestamps from filenames

  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.

* Align multicam playback to a global timestamp-derived timeline

  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.

* Fix stale annotation overlay and add UI for blanked aligned-timeline 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.

* Fix local-vs-global frame bugs in editing/seek and add timeline gap indicator

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>

* Fix aligned timeline never activating on real multicam datasets

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>

* Make per-camera controller lookups null-safe during load

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>

* Route local-frame seek producers through seekCameraFrame

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>

* Center timeline gap-indicator bands on the slider thumb position

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>

* Apply the aligned seek when a resolver is installed or a camera mounts

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>

* Pair identical-timestamp frames positionally and skip no-op timeline 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>

* Allow unequal multicam frame counts via filename frame-index inference

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>

* Remove provisional language

* Unify frame-timestamp parsing on a single client implementation

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>

* Shrink alignment to 0s, from 0.5s tolerance

* Update tests - matching intended metadata behavior

* documentation update

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Bryon Lewis <Bryon.Lewis@kitware.com>

* fix current frame drift with multiframe datga

* fix camera/frame mistmatch for multicam mis-aligned frames

* fix frame alignment for creating new detections

* Add multicam image registration backend (#1750)

* Add camera-calibration math and persistence foundation

homography.ts: 3x3 matrix primitives (multiply, invert, apply, linear
and DLT solvers) plus warp-grid subdivision helpers. transform.ts:
keypointgui-style transform models (translation / rigid / similarity /
affine / homography) that all estimate to a plain Matrix3, with
Similarity as the single shared default. CameraCalibrationStore: the
persistence core for camera-rig calibration -- per-pair homographies,
correspondences, transform types, and producer provenance, with
hydrate() from dataset meta, dirty tracking, and the portable
calibration.json round trip (toCalibrationJson / loadCalibrationText).

Interactive creation (point picking, fitting, overlay preview) is not
part of this branch; it layers on top in the Manual Alignment GUI.

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

* Load external camera calibrations into datasets

Persist cameraHomographies / cameraCorrespondences /
cameraTransformTypes / cameraCalibrationSource in dataset meta on both
platforms (server allowlist + desktop meta merge), and load
calibrations produced externally (e.g. by kamera) at import time: the
multicam import dialog gains a per-camera transform-file picker, the
desktop backend parses DIVE calibration .json pairs
(fromCalibrationPairs / toCalibrationPairs) and auto-discovers a
calibration.json in the import parent folder, and saveMetadata writes
the portable calibration.json alongside the project so the calibration
travels with the dataset.

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

* Add the Align View: warped display, linked navigation, and mirroring

When every camera resolves a transform into the reference camera's
space (first camera in display order, composed through the calibration
pair graph), an Align button warps each pane's imagery and annotations
into that shared space at draw time -- stored geometry always stays
native per camera. Includes: AlignedViewStore + resolution wiring in
the Viewer; AlignedImageLayer (sub-quad warped imagery with seam
overlap); display-transform routing through every annotation layer;
transform-aware linked pan/zoom (useAlignedNavigation/useLinkedViewers,
with the raw screen-delta camera sync retired once transforms exist);
drawing/editing on any camera with edits mapped back through the
inverse; and a continuous cross-camera mirror that re-projects every
geometry edit onto all other calibrated cameras.

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

* Warp imported annotations onto every calibrated camera on request

Importing single-camera annotations into a calibrated multicam dataset
now offers an "Import to all cameras" checkbox: after the import lands
on the active camera, every track is copied onto each other camera
through the camera-to-camera homographies (bounds, rotated bounds, and
GeoJSON geometry), then saved. Unlike the Align View continuous mirror
this is a data import, so confidence pairs and track/feature attributes
are copied too. Gated on the whole rig resolving to the reference
(alignedView.available), independent of the display toggle.

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

* Store the calibration as one file per camera

The dataset's saved calibration moves from a single all-pairs
calibration.json to one calibration_<camera>.json per non-reference
camera (a pair not touching the reference files under its right
camera), matching the per-camera files users already handle from
kamera. File names are discovery/provenance only: the pair's left/right
names inside each file body stay authoritative, so a misnamed or copied
file can never rebind a transform to the wrong camera. The legacy
single file is still read and is migrated (rewritten per-camera, then
removed) on the next save; stale per-camera files are cleaned up so the
on-disk set always mirrors the saved calibration.

Per-file producer stamps are compared when the set is merged (project
load and import seeding): agreement keeps the stamp, disagreement
becomes a { mixed: true, files: {...} } composite that the Align View
tooltip surfaces as a mixed-generation warning instead of composing
silently. A save never writes the composite into per-camera files --
that would read back as a unanimous rig.

Parent-folder transform discovery now returns every self-identified
calibration file (calibration.json first, then per-camera files), and
the multicam import dialog auto-attaches calibration_<camera>.json to
its matching camera slot. Files written by DIVE now also self-identify
with type: dive-camera-calibration so discovery recognizes them.

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

* Export the camera calibration and drop the all-pairs calibration.json

The per-camera calibration_<camera>.json files are now the only on-disk
form: the single all-pairs calibration.json is no longer read, migrated,
or given priority during import discovery. The Export menu gains explicit
calibration export -- one file per calibrated camera, or all of them
zipped -- built from the same per-camera grouping the save path uses, and
the multicam Everything zip now carries the files too. Exports resolve
the calibration the same way loading does (standalone files first, the
import-time meta seed otherwise) and never stamp a file with a mixed
composite source.

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

* Make calibration export per-camera on desktop and web, and add import

Calibration export is now strictly per-camera -- one
calibration_<camera>.json at a time, no all-cameras zip -- and works on
both platforms: desktop writes through the backend as before, web builds
the file client-side from the dataset meta and downloads it. The Import
menu gains a matching per-dataset calibration import (same workflow as
annotation import): pairs the file names replace the current ones, other
cameras' pairs are kept, so per-camera files can be imported one at a
time to assemble a rig; the viewer store rehydrates immediately so the
Align View picks up the new transforms without a reload. Grouping and
merge semantics live in a new shared vue-media-annotator module used by
the desktop backend, both Export menus, and both import paths, so listed
cameras, written files, and merge behavior cannot drift between
platforms. Web needs no server changes: the calibration meta fields were
already allowlisted.

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

* Polish the import/export menus around calibration

The Import and Export buttons now advertise "Supplementary Data" in
their hover tooltips instead of "Annotation Data", since both menus
carry calibrations as well. The Import Calibration blurb shrinks to one
line. The warp-on-import checkbox moves out of the active-camera alert
to sit beside Overwrite as "Warp to All", always visible on multicam
datasets and greyed out until the whole rig resolves, with an
"N/M cameras calibrated" hint. The viewer publishes that count into
AlignedViewStore from the same computation as the Align View tooltip,
so the two indicators can never disagree.

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

* Import the calibration per camera pair, confirming replacement

The generic Import Calibration button becomes one button per
non-reference camera pair ("Import eo -> ir"), each scoped to its
camera: only the file's pairs naming that camera are taken, they
replace that camera's current pairs, and other cameras' pairs are
kept -- so per-camera files assemble a rig one at a time and a
multi-pair file pointed at one camera doesn't drag the rest along.
Scoping to a camera the file doesn't name refuses with a clear error.
Buttons are green when the camera already has a calibration and yellow
when it doesn't, and importing onto an existing one asks for
confirmation before replacing it, so no separate overwrite toggle is
needed. The camera filtering and pair/camera summary live in the
shared calibration-file module so desktop and web behave identically.

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

* Adopt image-registration naming across UI, API, files, and the wire format

"Calibration" collided with the pre-existing stereo camera file (an
intrinsic/extrinsic camera model for the measurement pipeline), and in
computer vision this process is image registration, so the feature says
registration everywhere: "Import Registration" / "Registration: <camera>"
menu sections, importCameraRegistration and exportCameraRegistration API
members and IPC channels, registrationProgress ("N/M cameras registered")
published on the aligned-view store, matching error messages, the
internals (CameraRegistrationStore, cameraRegistrationFiles, and the
load/merge/build helpers), and the wire format: the dataset meta field is
cameraRegistrationSource, renamed through the server models, allowlist,
and tests alongside the RegistrationSource type and stamp helpers. Files
are named <camera>_to_<reference>_registration.json and self-identify as
dive-camera-registration: each file states the destination of the mapping
it carries (ir_to_eo_registration.json registers ir onto eo), making the
reference-camera rule -- first camera in display order -- legible from
the artifacts themselves. The name scheme contains neither "calibration"
nor "cal", so these files can no longer be mis-suggested as stereo camera
files by the stereo parent-folder discovery heuristic. The Align View
keeps its name (registration is the data and process; the Align View is
the display mode that renders registered imagery), and the stereo
camera-file surfaces are untouched.

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

* Let the Reference Camera choice drive image registration

The registration reference was implicitly the first camera in display
order, which merely coincided with the camera users pick at import in the
common EO-first case. The import dialog's picker is now labeled
"Reference Camera" ("Choose which camera to use as the reference camera
for image registration") and its choice -- stored as the dataset's
defaultDisplay -- actually drives the reference, through a shared
referenceCameraName() resolver used by the viewer's transform resolution
and N/M-registered status, both Export menus, and the desktop per-camera
file persistence, falling back to display order when the choice is
missing. The viewer now publishes the reference on the aligned-view store
even before the rig resolves, so the per-pair import buttons name it
("Import ir → eo") instead of assuming camera-map order.

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

* lazy loading camera alignment tools only when multicamera

* refactor common to simplify into separate files

* refactor the linked viewer and aligned navigation into their own folder

* LayerManager.vue simplification

* folder for aligned camera utilites/tools

* more type specification for camera alignment models in the server

* client linting

* minor fix

* Accept untyped per-camera registration files in parent-folder discovery

A file named *_registration.json with a pairs list now qualifies without
the self-identifying type marker -- the conventional name is the
producer's declaration of intent, matching every other load path where
type is optional. Non-conventionally-named JSONs still require the exact
type string so stray JSON in the collect root is never grabbed.

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

* Warn at desktop multicam import when registration names unknown cameras

Pair bodies are authoritative on load, so a pair naming a camera the
dataset doesn't have imports fine but never resolves in the Aligned
View. beginMultiCamImport now collects a warning per transform file
whose pairs name cameras outside the dataset, carried on the import
response as importWarnings and shown in the import confirmation dialog
so the mismatch is visible before Finish Import.

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

* Bring transform import at multicam creation to the web platform

Web parity for the desktop-only transform seeding: the import dialog's
per-camera transform pickers and parent-folder auto-discovery now work
on web (Files stashed in the multicam registry, discovery scanning the
selected folder's root with the same qualification rules as the desktop
backend), and the upload flow parses the attached registration files up
front, seeds the new dataset's saved camera registration through the
meta PATCH, and prompts the same unknown-camera warning as desktop.
mergeRegistrationSources moves to the shared cameraRegistrationFiles so
both platforms compose producer stamps identically.

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

* Discover per-collect registration files in batch multicam import

Each collect's root is scanned for DIVE registration .json files (the
same qualification rules as the single import's parent-folder
discovery) on both platforms, and the shared batch scan assigns them to
camera slots on the collect's importArgs via the slot-matching rule now
extracted from the import dialog. The batch table shows what was
attached, warns when a file has no free slot, and surfaces the
unknown-camera seeding warnings per collect row -- batch imports skip
the confirmation dialog that normally shows them.

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

* Keep the Aligned View warp in step with image-enhancement changes

The warp renders a snapshot of the camera's displayed image element and
only re-rendered on transform or frame changes, so a same-frame
presentation change -- the percentile-stretch URL remap swapping the
quad's <img>, or the first enhancement toggling the CSS filter -- left
the warped pane stale until the Aligned View was exited. The media
controller now exposes an imageRevision counter that the annotators
bump on those redraws, and LayerManager re-renders the warp when it
moves.

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

* Deduplicate registration discovery and warning logic across platforms

The web and desktop discovery paths each carried their own copy of the
registration-file qualification rule, candidate ordering, and file-name
pattern; share them from dive-common/registrationParentFolder. The
unknown-camera import warning was likewise duplicated between the desktop
and web seeding paths; share it as unknownCameraWarning next to
mergeRegistrationSources, with desktop deriving named cameras from the
pairs' own left/right fields instead of re-splitting transformTypes keys.
Drop the mergeRegistrationSources re-export shim in favor of importing it
from its home in cameraRegistrationFiles.

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

* Drive the Aligned View warp from imageRevision instead of polling

AlignedImageLayer polled with a bounded requestAnimationFrame loop to
catch the annotator swapping its <img> element asynchronously, because
no event reached the layer. imageRevision is now that event: drawImage
bumps it on every media-quad redraw (including the post-seek swap), and
the LayerManager watch re-renders the warp from it. VideoAnnotator now
bumps it on the initial video quad draw, the one element swap a video
pane ever makes.

This also removes the poll's failure mode: it gave up after ~60 frames,
so an image that loaded slower than a second left a stale warp until
the next trigger; the revision bump fires whenever the image actually
lands.

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

* Fix the imageRevision warp watch, which referenced a layer out of scope

The LayerManager watch that re-renders the Aligned View warp on
imageRevision bumps called alignedImageLayer.update(), but
alignedImageLayer was never destructured from useLayerManagerAlignedView
-- it sat inside the ...alignedViewHelpers rest object, so the callback
threw on every bump and Vue swallowed the error. The warp therefore
never followed image-enhancement changes (percentile-stretch toggles
left it stale until the Aligned View was re-toggled); the removed rAF
poll had been masking the dead watch for post-seek swaps.

Move the watch into setupDisplayTransformWatches, where the layer is
genuinely in scope and the transform/frame/revision triggers live
together, and pin the contract with a composable-level spec (bump
imageRevision with a swapped element -> the warp re-renders from it).

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

* Center the Align View reset on the selected camera's warped bounds

The center-view button (and the r hotkey) reset every pane to its own
native image bounds, but while the Align View renders panes in the shared
reference space that parks the view on an empty corner -- and each pane's
reset events ran through the identity link, so whichever pane landed last
dragged every other pane along with it.

The aggregate reset now consults an override installed by
useAlignedNavigation: while the aligned view is active, fit the SELECTED
camera's content (its native bounds mapped through its native->reference
homography) in its own pane, and mirror that view to every pane through
the existing link. Falls back to the native per-pane reset when the
aligned view is off.

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

* restruture aligned view tooltip into it's own component

* linting

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Bryon Lewis <Bryon.Lewis@kitware.com>

* Add keypoint GUI for manual calibration (#1749)

* Add interactive calibration state, keypoint markers, and pair link

Extend CameraCalibrationStore with the Manual Alignment creation state
and operations: active pair, point picking (add/pick/move/drag-refine,
select/delete, clear last/pair), transform fitting (fitTransform /
maybeFitPair via the transform estimators), the ghost-overlay alignment
mode/opacity, linked-nav toggle, and cursor/recenter plumbing.
CalibrationKeypointLayer renders the picked correspondences (locked
blue / unlocked yellow, exact-pixel markers, click-select and drag) and
the cross-pane ghost warp preview; CameraImage moves here with it.
useCalibrationNavigation links the active pair's panes through the
fitted homography while picking (standing down under the Align View).

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

* Add the Manual Alignment panel for in-app calibration creation

CalibrationTools.vue (modeled on VIAME's keypointgui): pick point pairs
across a camera pair, fit the chosen transform model with a
fit-readiness countdown, preview the fit as a ghost overlay with warp
opacity, review rig alignment status (N/M cameras calibrated), and
save/load the calibration -- to dataset meta and the portable
calibration.json. Wired into the Viewer as a context panel: workspace
chrome minimizes while calibrating, only the active pair's panes show
on 3+ camera rigs, the general Align warp suspends while picking so
picks are always native-space, and LayerManager mounts the keypoint
marker/ghost layer per pane.

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

* Adopt image-registration naming in the Manual Alignment feature

Rename the feature's own files and identifiers to the registration
vocabulary the rest of the branch adopted: CalibrationTools ->
RegistrationTools (CameraRegistration/), CalibrationKeypointLayer ->
RegistrationKeypointLayer, useCalibrationNavigation ->
useRegistrationNavigation, and the calibration-named locals, props, and
UI strings that referenced them. Stereo calibration (a genuinely
different artifact) keeps its name.

Reuse shared helpers where the feature had its own copies: the panel's
unknown-camera notice now comes from unknownCameraWarning, and the
geojs warp-quad mapping duplicated between AlignedImageLayer and the
keypoint ghost is extracted to geojsWarpQuads in alignedView/homography.
The linkedViewers README's "planned" registration-pair-link section now
describes the shipped composable.

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

* Drive the registration ghost from the source camera's imageRevision

Same replacement as the Aligned View warp: the ghost polled with a
bounded requestAnimationFrame loop to catch the source pane swapping
its <img> element after a seek. The source annotator now bumps
imageRevision on that swap, so LayerManager watches the ghost source
camera's revision (alongside its frame number) and re-renders the
ghost from the element actually on screen -- with no ~1s give-up
window on slow-loading frames.

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

* Streamline the Camera Registration panel around the import/export menus

Rename the Manual Alignment panel to Camera Registration and scope it to
its actual job -- picking points, fitting, and saving:

- Drop the panel's "Load registration" button: the Import menu's
  per-camera registration targets are the one import path.
- Drop the panel's export button too: the Export menu's per-camera
  registration downloads (web and desktop) are the one export path. The
  refined-from-source notice now points there (save first -- the menu
  reads saved meta).
- Remove CameraRegistrationStore.toRegistrationJson, the last whole-rig
  (all pairs in one file) serializer; per-camera
  <camera>_to_<reference>_registration.json files are the only
  registration file format. loadRegistrationText stays as the shared
  parser, and its round-trip specs now serialize through the production
  buildPerCameraRegistrationFiles.
- Protect unsaved panel edits on exit: the browser beforeunload warning,
  the navigate-away prompt, and the desktop close dialog now fire on
  registration dirt too (hasUnsavedChanges = pending annotation saves OR
  a dirty registration), and the desktop dialog's Save choice persists
  the registration via the new Viewer.saveRegistration.

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

* Make loaded registrations first-class in the Camera Registration panel

A registration imported from a producer (e.g. KAMERA) carries matrices but
no picked points, and the panel treated it as incomplete: "Fit pan/zoom"
stayed greyed out (it gated on point count, though the link only needs a
homography), the status line claimed points were still needed, and picking
was force-enabled so stray clicks placed points on a registration that
needed none.

- Add a three-state transform status (loaded from file / fit from N point
  pairs / none) and gate the pan/zoom link on having a transform at all,
  renaming it "Link pan/zoom"; enabling it fits the pair on demand.
- Make picking an explicit "Pick points" toggle scoping the authoring UI
  (cursor readout, correspondences, transform type), defaulting per pair:
  off in review of a file-loaded transform, on when points are needed.
- Decouple linked navigation from picking mode -- it now runs whenever the
  panel has a pair and the link is on; the panel clears the pair (and with
  it the warp ghost) on close to keep that scoped.
- After a registration import, summarize what the loaded transform drives
  and, when the panel is open, re-select the imported pair in review
  posture (hydrate cleared it and left the panel inert).
- Confirm before "Save registration" overwrites existing per-camera
  registration files, naming exactly the files whose content changes --
  untouched pairs rewrite byte-identical and don't warn -- along with the
  provenance stamp being replaced.

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

* Remap Align View warp crops into actual texture pixels

Overview canvases are smaller than native frame size; crop rects must
follow the texture or ghosts/Align View sample the wrong texels.

* Compose large-image overview textures for Align View and registration ghosts

Tiled panes have no media quad; a capped overview canvas lets warps and
ghosts draw while tiles remain the interactive native view.

* Fit Align View from the reference frame and unlock large-image clamp

Tiled maps keep clamp and a native tile fit while Align is off; without
an Align-on snap and shared bounds linking, panes look stranded or ignore
snaps.

* Register multicam alignment navigators during Viewer setup

Calling them from async loadData left pan/zoom listeners without
onBeforeUnmount and triggered Vue instance warnings.

* Exit GeoJS maps on re-init and guard annotation redraw after context loss

Re-creating maps without exit() exhausts WebGL contexts; layers then
throw on draw against a dead renderer.

* linting

* update alignment toggle button to link to camera registration

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Bryon Lewis <Bryon.Lewis@kitware.com>

* Infer the KAMERA format in multicam and batch multicam import (#1756)

* Infer the KAMERA format in multicam and batch multicam import

KAMERA writes each swathe to a flat view folder (left_view/center_view/
right_view) holding up to three modalities side by side, distinguished by
a filename suffix (*_rgb / *_ir / *_uv), plus sidecar metadata files.
Both multicam import modes now detect this layout and create one dataset
per view folder with one camera per modality present.

Cameras sharing a folder are selected by a new optional per-camera glob
on MultiCamImportFolderArgs.sourceList, applied by findImagesInFolder on
desktop and by staged-file filtering in the web upload path. Batch scan
treats flat KAMERA folders as self-describing collects (skipping the
shared camera-set validation) and defaults dataset names to
<flight>_<view>; the parent-folder import auto-enables filename-based
frame alignment since KAMERA modalities drop frames independently.

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

* Remove kamera-specific wording, trim multicam load imports

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix duplicate large-image when in alignment mode

---------

Co-authored-by: Adam Romlein <35015390+romleiaj@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants