diff --git a/client/dive-common/components/CameraCalibration/CalibrationTools.vue b/client/dive-common/components/CameraCalibration/CalibrationTools.vue new file mode 100644 index 000000000..4981a4565 --- /dev/null +++ b/client/dive-common/components/CameraCalibration/CalibrationTools.vue @@ -0,0 +1,678 @@ + + + diff --git a/client/dive-common/components/Viewer.vue b/client/dive-common/components/Viewer.vue index 9e03e3539..d0fa6ec26 100644 --- a/client/dive-common/components/Viewer.vue +++ b/client/dive-common/components/Viewer.vue @@ -31,6 +31,7 @@ import { LargeImageAnnotator, LayerManager, useMediaController, + useCalibrationNavigation, useAlignedNavigation, TrackList, FilterList, @@ -87,6 +88,7 @@ import context from 'dive-common/store/context'; import { MarkChangesPendingFilter } from 'vue-media-annotator/BaseFilterControls'; import GroupSidebarVue from './GroupSidebar.vue'; import MultiCamToolsVue from './MultiCamTools.vue'; +import CalibrationToolsVue from './CameraCalibration/CalibrationTools.vue'; import MultiCamToolbar from './MultiCamToolbar.vue'; import PrimaryAttributeTrackFilter from './PrimaryAttributeTrackFilter.vue'; import UserSettingsDialog from './UserSettingsDialog.vue'; @@ -320,6 +322,29 @@ export default defineComponent({ }); const showUserSettingsDialog = ref(false); + // When the Manual Alignment panel opens, minimize the workspace chrome to + // give the picking view more room: collapse the left type-filter sidebar and + // the bottom detections graph. This is a soft default -- the normal sidebar + // and timeline toggles still work while calibrating, so the user can bring + // either back -- and whatever layout they had before is restored on close. + const calibrationActive = computed(() => context.state.active === CalibrationToolsVue.name); + let preCalibrationSidebarMode: 'left' | 'bottom' | 'collapsed' | null = null; + let preCalibrationControlsCollapsed = false; + watch(calibrationActive, (active) => { + if (active) { + preCalibrationSidebarMode = sidebarMode.value; + preCalibrationControlsCollapsed = controlsCollapsed.value; + if (sidebarMode.value === 'left') { + sidebarMode.value = 'collapsed'; + } + controlsCollapsed.value = true; + } else if (preCalibrationSidebarMode !== null) { + sidebarMode.value = preCalibrationSidebarMode; + controlsCollapsed.value = preCalibrationControlsCollapsed; + preCalibrationSidebarMode = null; + } + }); + watch(sidebarMode, (mode) => { if (mode === 'left' || mode === 'bottom') { clientSettings.layoutSettings.sidebarPosition = mode; @@ -407,10 +432,15 @@ export default defineComponent({ * warp displays and link pan/zoom across all cameras during normal * review. Reference camera = first camera in display order * (multiCamList[0]). Transforms come from the calibration store's pair - * homographies (loaded from a calibration file or the dataset's saved - * meta), composed through the pair graph. + * homographies (picked in-app or loaded from a calibration file via the + * panel), composed through the pair graph -- the single calibration the + * panel edits and saves is exactly what the Align button applies. */ const alignedView = new AlignedViewStore(); + // The calibration pair link maps through the homography for UNWARPED + // panes, so it needs the aligned view state to stand down while displays + // are warped into reference space. + useCalibrationNavigation(aggregateController, cameraCalibration, alignedView); const alignedResolution = computed(() => { if (multiCamList.value.length < 2) { return null; @@ -429,6 +459,12 @@ export default defineComponent({ resolution?.toReference ?? null, ); }, { immediate: true }); + // Calibration point picking records raw native-space clicks and renders + // its own aligned preview; suspend the general warp while it is active + // so picks are never taken against a warped display (risk K2). + watch(cameraCalibration.pickingEnabled, (picking) => { + alignedView.setSuspended(picking); + }, { immediate: true }); useAlignedNavigation(aggregateController, alignedView, multiCamList); const alignedViewAvailable = computed(() => alignedView.available.value); const alignedViewEnabled = computed(() => alignedView.enabled.value); @@ -454,6 +490,34 @@ export default defineComponent({ } return `Align View${mixedNote}`; }); + // The aligned view is suspended while picking, so the button reads as + // unavailable rather than accepting a toggle that has no visible effect. + const calibrationPickingEnabled = computed(() => cameraCalibration.pickingEnabled.value); + /** + * Camera panes currently displayed. While the Manual Alignment panel is + * open with an active pair on a 3+ camera dataset, only the pair's two + * panes show, so the left/right alignment flow reads without unrelated + * panes in between (regardless of whether Pick points is toggled on). + * Panes are hidden (v-show), not unmounted, so their viewers keep state. + */ + const displayedCameras = computed(() => { + const pair = cameraCalibration.activePair.value; + if (calibrationActive.value && pair) { + const pairCameras = multiCamList.value.filter( + (camera) => camera === pair.camA || camera === pair.camB, + ); + if (pairCameras.length === 2) { + return pairCameras; + } + } + return multiCamList.value; + }); + watch(displayedCameras, async () => { + // Hidden/shown siblings change the remaining panes' sizes; resize the + // geojs maps once the DOM has settled. + await nextTick(); + handleResize(); + }); // This context for removal const removeGroups = (id: AnnotationId) => { cameraStore.removeGroups(id); @@ -1464,11 +1528,19 @@ export default defineComponent({ component: MultiCamToolsVue, description: 'Multi Camera Tools', }); + context.register({ + component: CalibrationToolsVue, + description: 'Manual Alignment', + }); } else { context.unregister({ component: MultiCamToolsVue, description: 'Multi Camera Tools', }); + context.unregister({ + component: CalibrationToolsVue, + description: 'Manual Alignment', + }); context.register({ description: 'Group Manager', component: GroupSidebarVue, @@ -1882,6 +1954,8 @@ export default defineComponent({ alignedViewAvailable, alignedViewEnabled, alignedViewTooltip, + calibrationPickingEnabled, + displayedCameras, toggleAlignedView, seekToFrame, resetAggregateZoom, @@ -2086,7 +2160,7 @@ export default defineComponent({ small class="mx-1" :color="alignedViewEnabled ? 'primary' : 'default'" - :disabled="!alignedViewAvailable" + :disabled="!alignedViewAvailable || calibrationPickingEnabled" @click="toggleAlignedView" > @@ -2226,10 +2300,16 @@ export default defineComponent({ class="d-flex flex-column grow" >
+
= { description: 'Multi Camera Tools', component: MultiCamTools, }, + [CalibrationTools.name]: { + description: 'Manual Alignment', + component: CalibrationTools, + }, [AttributesSideBar.name]: { description: 'Attribute Details', component: AttributesSideBar, diff --git a/client/src/CameraCalibrationStore.spec.ts b/client/src/CameraCalibrationStore.spec.ts index 7810d9ff6..7c925f99d 100644 --- a/client/src/CameraCalibrationStore.spec.ts +++ b/client/src/CameraCalibrationStore.spec.ts @@ -2,35 +2,286 @@ import CameraCalibrationStore from './CameraCalibrationStore'; describe('CameraCalibrationStore', () => { - // Pure translation by (+5, -3): trivially invertible. - const translate = [[1, 0, 5], [0, 1, -3], [0, 0, 1]]; - // Four correspondence rows consistent with `translate`: right = left + (5, -3). - const translationPointRows = [ - [0, 0, 5, -3], - [10, 0, 15, -3], - [10, 10, 15, 7], - [0, 10, 5, 7], - ]; - it('produces a directional pairKey that preserves left/right order', () => { const store = new CameraCalibrationStore(); expect(store.pairKey('rgb', 'ir')).toEqual('rgb::ir'); expect(store.pairKey('rgb', 'ir')).not.toEqual(store.pairKey('ir', 'rgb')); }); - it('hydrates homographies and resets prior calibration state', () => { + it('preserves the chosen left/right order on the active pair', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('rgb', 'ir'); + expect(store.activePair.value).toEqual({ camA: 'rgb', camB: 'ir' }); + }); + + it('clears the active pair for identical or empty cameras', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('cam', 'cam'); + expect(store.activePair.value).toBeNull(); + }); + + it('resets alignment to original when switching pairs', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + addFourTranslationPairs(store); + store.setAlignmentMode('AtoB'); + expect(store.alignment.value.mode).toBe('AtoB'); + store.setActivePair('left', 'other'); + expect(store.alignment.value).toMatchObject({ mode: 'original' }); + }); + + it('forms one correspondence from a blue->red two-click sequence', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + store.addPoint('left', [10, 20]); // pending (blue) + expect(store.pendingPoint.value).not.toBeNull(); + expect(store.correspondences.value[key]).toBeUndefined(); + store.addPoint('right', [30, 40]); // completes (red) + expect(store.pendingPoint.value).toBeNull(); + expect(store.correspondences.value[key]).toHaveLength(1); + expect(store.correspondences.value[key][0]).toMatchObject({ a: [10, 20], b: [30, 40] }); + }); + + it('maps points to a/b by camera regardless of click order', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + store.addPoint('right', [30, 40]); // click right first + store.addPoint('left', [10, 20]); + expect(store.correspondences.value[key][0]).toMatchObject({ a: [10, 20], b: [30, 40] }); + }); + + it('replaces the pending point when the same camera is clicked twice', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + store.addPoint('left', [1, 1]); + store.addPoint('left', [2, 2]); + expect(store.pendingPoint.value).toMatchObject({ camera: 'left', coord: [2, 2] }); + }); + + it('ignores points for cameras outside the active pair', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + store.addPoint('other', [1, 1]); + expect(store.pendingPoint.value).toBeNull(); + }); + + it('removes a correspondence by id', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + store.addPoint('left', [1, 1]); + store.addPoint('right', [2, 2]); + const { id } = store.correspondences.value[key][0]; + store.removeCorrespondence(id); + expect(store.correspondences.value[key]).toHaveLength(0); + }); + + it('moves one side of a correspondence via updateCorrespondencePoint', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + store.addPoint('left', [1, 1]); + store.addPoint('right', [2, 2]); + const { id } = store.correspondences.value[key][0]; + store.updateCorrespondencePoint(id, 'left', [5, 6]); + expect(store.correspondences.value[key][0].a).toEqual([5, 6]); + expect(store.correspondences.value[key][0].b).toEqual([2, 2]); + store.updateCorrespondencePoint(id, 'right', [7, 8]); + expect(store.correspondences.value[key][0].b).toEqual([7, 8]); + }); + + it('ignores updateCorrespondencePoint for unknown ids or cameras outside the pair', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + store.addPoint('left', [1, 1]); + store.addPoint('right', [2, 2]); + const { id } = store.correspondences.value[key][0]; + store.updateCorrespondencePoint(id + 99, 'left', [5, 6]); + store.updateCorrespondencePoint(id, 'other', [5, 6]); + expect(store.correspondences.value[key][0]).toMatchObject({ a: [1, 1], b: [2, 2] }); + }); + + it('refits the pair homography when a point is drag-refined while alignment is active', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + addFourTranslationPairs(store); + store.setAlignmentMode('AtoB'); + const before = store.homographies.value[key].AtoB[0][2]; + const { id } = store.correspondences.value[key][0]; + store.updateCorrespondencePoint(id, 'right', [40, 40]); + expect(store.homographies.value[key].AtoB[0][2]).not.toBeCloseTo(before, 5); + }); + + it('moves the pending point only for its own camera', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + store.addPoint('left', [1, 1]); + store.movePendingPoint('right', [9, 9]); + expect(store.pendingPoint.value).toMatchObject({ camera: 'left', coord: [1, 1] }); + store.movePendingPoint('left', [9, 9]); + expect(store.pendingPoint.value).toMatchObject({ camera: 'left', coord: [9, 9] }); + }); + + it('fits a homography from >= 4 pairs and stores both directions', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + // A pure translation by (5, -3). + const pts: [number, number][] = [[0, 0], [10, 0], [10, 10], [0, 10]]; + pts.forEach((p) => { + store.addPoint('left', p); + store.addPoint('right', [p[0] + 5, p[1] - 3]); + }); + const { AtoB, BtoA } = store.fitTransform(key); + expect(AtoB[0][2]).toBeCloseTo(5, 5); + expect(AtoB[1][2]).toBeCloseTo(-3, 5); + expect(BtoA[0][2]).toBeCloseTo(-5, 5); + expect(store.homographies.value[key]).toBeDefined(); + }); + + it('throws when fitting with fewer than 4 pairs (default homography type)', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + store.addPoint('left', [1, 1]); + store.addPoint('right', [2, 2]); + expect(() => store.fitTransform(key)).toThrow(); + }); + + it('surfaces a fitError instead of throwing when maybeFitPair hits a degenerate configuration', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + store.setTransformType(key, 'homography'); + // 4 collinear points satisfy the homography minimum count but are degenerate. + const pts: [number, number][] = [[0, 0], [1, 0], [2, 0], [3, 0]]; + pts.forEach((p) => { + store.addPoint('left', p); + store.addPoint('right', p); + }); + expect(() => store.maybeFitPair(key)).not.toThrow(); + expect(store.fitError.value).toMatch(/degenerate/i); + expect(store.homographies.value[key]).toBeUndefined(); + }); + + it('clears a stale fitError once the active pair fits successfully', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + store.setTransformType(key, 'homography'); + const collinear: [number, number][] = [[0, 0], [1, 0], [2, 0], [3, 0]]; + collinear.forEach((p) => { + store.addPoint('left', p); + store.addPoint('right', p); + }); + store.maybeFitPair(key); + expect(store.fitError.value).not.toBeNull(); + store.clearPair(); + addFourTranslationPairs(store); + store.maybeFitPair(key); + expect(store.fitError.value).toBeNull(); + expect(store.homographies.value[key]).toBeDefined(); + }); + + it('clears fitError when switching to a different active pair', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + store.setTransformType(key, 'homography'); + const collinear: [number, number][] = [[0, 0], [1, 0], [2, 0], [3, 0]]; + collinear.forEach((p) => { + store.addPoint('left', p); + store.addPoint('right', p); + }); + store.maybeFitPair(key); + expect(store.fitError.value).not.toBeNull(); + store.setActivePair('left', 'other'); + expect(store.fitError.value).toBeNull(); + }); + + function addFourTranslationPairs(store: CameraCalibrationStore) { + const pts: [number, number][] = [[0, 0], [10, 0], [10, 10], [0, 10]]; + pts.forEach((p) => { + store.addPoint('left', p); + store.addPoint('right', [p[0] + 5, p[1] - 3]); + }); + } + + it('fits when enabling alignment mode with >= 4 pairs', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + addFourTranslationPairs(store); + store.setAlignmentMode('AtoB'); + expect(store.alignment.value.mode).toBe('AtoB'); + expect(store.homographies.value[key]).toBeDefined(); + expect(store.homographies.value[key].AtoB[0][2]).toBeCloseTo(5, 5); + }); + + it('does not enable alignment mode with fewer than 4 pairs (default homography type)', () => { const store = new CameraCalibrationStore(); - store.loadCalibrationText(JSON.stringify({ - version: 1, - pairs: [{ - left: 'left', right: 'right', points: [[1, 1, 6, -2]], transformType: 'translation', - }], - })); + store.setActivePair('left', 'right'); + store.addPoint('left', [1, 1]); + store.addPoint('right', [2, 2]); + store.setAlignmentMode('AtoB'); + expect(store.alignment.value.mode).toBe('original'); + expect(store.homographies.value).toEqual({}); + }); + + it('refits when correspondences change while alignment mode is active', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + addFourTranslationPairs(store); + store.setAlignmentMode('AtoB'); + const before = store.homographies.value[key].AtoB[0][2]; + store.addPoint('left', [20, 20]); + store.addPoint('right', [30, 14]); + expect(store.homographies.value[key].AtoB[0][2]).not.toBeCloseTo(before, 5); + }); + + it('reverts alignment to original when correspondences drop below the transform minimum', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + addFourTranslationPairs(store); + store.setAlignmentMode('AtoB'); + const { id } = store.correspondences.value[key][0]; + store.removeCorrespondence(id); + store.removeCorrespondence(store.correspondences.value[key][0].id); + store.removeCorrespondence(store.correspondences.value[key][0].id); + store.removeCorrespondence(store.correspondences.value[key][0].id); + expect(store.correspondences.value[key]).toHaveLength(0); + expect(store.alignment.value.mode).toBe('original'); + expect(store.homographies.value[key]).toBeUndefined(); + }); + + it('maybeFitActivePair fits without enabling alignment mode', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + addFourTranslationPairs(store); + store.maybeFitActivePair(); + expect(store.alignment.value.mode).toBe('original'); + expect(store.homographies.value[key]).toBeDefined(); + }); + + it('hydrates homographies and resets transient state', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + store.addPoint('left', [1, 1]); const saved = { 'a::b': { AtoB: [[1, 0, 0], [0, 1, 0], [0, 0, 1]], BtoA: [[1, 0, 0], [0, 1, 0], [0, 0, 1]] } }; store.hydrate(saved); expect(store.homographies.value).toEqual(saved); + expect(store.activePair.value).toBeNull(); + expect(store.pendingPoint.value).toBeNull(); expect(store.correspondences.value).toEqual({}); expect(store.transformTypes.value).toEqual({}); + expect(store.alignment.value).toEqual({ mode: 'original', opacity: 0.5 }); }); it('hydrates transform types alongside homographies', () => { @@ -50,30 +301,267 @@ describe('CameraCalibrationStore', () => { }; store.hydrate({}, correspondences); expect(store.correspondences.value).toEqual(correspondences); - // Points loaded afterwards pick up ids after the highest restored id. - store.loadCalibrationText(JSON.stringify({ - version: 1, - pairs: [{ left: 'rgb', right: 'ir', points: [[9, 9, 10, 10]] }], - })); - expect(store.correspondences.value['rgb::ir'][0].id).toBe(3); + // New points pick up after the highest restored id. + store.setActivePair('rgb', 'ir'); + store.addPoint('rgb', [9, 9]); + store.addPoint('ir', [10, 10]); + expect(store.correspondences.value['rgb::ir'][2].id).toBe(3); }); - describe('dirty / markSaved', () => { - it('tracks unsaved changes and resets on markSaved and hydrate', () => { + describe('transform type selection', () => { + it('fits a rigid transform from 2 pairs where a homography would throw', () => { const store = new CameraCalibrationStore(); - expect(store.dirty.value).toBe(false); - store.loadCalibrationText(JSON.stringify({ - version: 1, - pairs: [{ - left: 'left', right: 'right', points: [], leftToRight: null, rightToLeft: translate, - }], - })); - expect(store.dirty.value).toBe(true); - store.markSaved(); - expect(store.dirty.value).toBe(false); - store.hydrate({ 'a::b': { AtoB: translate, BtoA: translate } }); - // Freshly hydrated state is the saved baseline. - expect(store.dirty.value).toBe(false); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + store.addPoint('left', [0, 0]); + store.addPoint('right', [5, -3]); + store.addPoint('left', [10, 0]); + store.addPoint('right', [15, -3]); + store.setTransformType(key, 'homography'); + expect(() => store.fitTransform(key)).toThrow(); + store.setTransformType(key, 'rigid'); + expect(store.homographies.value[key]).toBeDefined(); + expect(store.homographies.value[key].AtoB[0][2]).toBeCloseTo(5, 4); + }); + + it('clears the fit and reverts alignment when switching to a type needing more points than are picked', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + store.addPoint('left', [0, 0]); + store.addPoint('right', [5, -3]); + store.addPoint('left', [10, 0]); + store.addPoint('right', [15, -3]); + store.setTransformType(key, 'rigid'); + store.setAlignmentMode('AtoB'); + expect(store.alignment.value.mode).toBe('AtoB'); + + store.setTransformType(key, 'homography'); // needs 4, only 2 picked + expect(store.homographies.value[key]).toBeUndefined(); + expect(store.alignment.value.mode).toBe('original'); + }); + }); + + describe('setAlignmentMode guards', () => { + it('setAlignmentMode leaves mode original when the pair lacks enough points', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + store.setAlignmentMode('BtoA'); + expect(store.alignment.value.mode).toBe('original'); + }); + }); + + describe('pickPoint', () => { + it('records a native pick like addPoint in the Picking (original) mode', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + store.pickPoint('right', [15, 7]); + expect(store.pendingPoint.value).toMatchObject({ camera: 'right', coord: [15, 7] }); + }); + + it('is a no-op while an overlay warp is active', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + addFourTranslationPairs(store); + store.setAlignmentMode('AtoB'); + store.pickPoint('right', [15, 7]); + // The warp mode blocks new picks; nothing is pending and the pairs are unchanged. + expect(store.pendingPoint.value).toBeNull(); + expect(store.correspondences.value[store.pairKey('left', 'right')]).toHaveLength(4); + }); + }); + + describe('correspondence selection', () => { + function storeWithOnePair() { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + store.addPoint('left', [1, 1]); + store.addPoint('right', [2, 2]); + const key = store.pairKey('left', 'right'); + const { id } = store.correspondences.value[key][0]; + return { store, key, id }; + } + + it('selects an active-pair correspondence and clears via null or unknown ids', () => { + const { store, id } = storeWithOnePair(); + store.selectCorrespondence(id); + expect(store.selectedCorrespondenceId.value).toBe(id); + store.selectCorrespondence(id + 99); + expect(store.selectedCorrespondenceId.value).toBeNull(); + store.selectCorrespondence(id); + store.selectCorrespondence(null); + expect(store.selectedCorrespondenceId.value).toBeNull(); + }); + + it('removeSelectedCorrespondence removes both cameras\' points and clears the selection', () => { + const { store, key, id } = storeWithOnePair(); + store.selectCorrespondence(id); + store.removeSelectedCorrespondence(); + expect(store.correspondences.value[key]).toHaveLength(0); + expect(store.selectedCorrespondenceId.value).toBeNull(); + // No selection: a further call is a no-op. + store.removeSelectedCorrespondence(); + expect(store.correspondences.value[key]).toHaveLength(0); + }); + + it('clears the selection when the selected pair is removed, undone, or the pair switches', () => { + const first = storeWithOnePair(); + first.store.selectCorrespondence(first.id); + first.store.removeCorrespondence(first.id); + expect(first.store.selectedCorrespondenceId.value).toBeNull(); + + const second = storeWithOnePair(); + second.store.selectCorrespondence(second.id); + second.store.clearLast(); + expect(second.store.selectedCorrespondenceId.value).toBeNull(); + + const third = storeWithOnePair(); + third.store.selectCorrespondence(third.id); + third.store.setActivePair('left', 'other'); + expect(third.store.selectedCorrespondenceId.value).toBeNull(); + }); + + it('clears the selection on clearPair, load, and hydrate', () => { + const { store, id } = storeWithOnePair(); + store.selectCorrespondence(id); + store.clearPair(); + expect(store.selectedCorrespondenceId.value).toBeNull(); + + const loaded = storeWithOnePair(); + loaded.store.selectCorrespondence(loaded.id); + loaded.store.loadCalibrationText(JSON.stringify({ version: 1, pairs: [] })); + expect(loaded.store.selectedCorrespondenceId.value).toBeNull(); + + const hydrated = storeWithOnePair(); + hydrated.store.selectCorrespondence(hydrated.id); + hydrated.store.hydrate(); + expect(hydrated.store.selectedCorrespondenceId.value).toBeNull(); + }); + }); + + describe('linked navigation', () => { + it('linkedPoint maps a point from camA to camB and back, via the fitted homography', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + addFourTranslationPairs(store); // left -> right is +5, -3 + store.maybeFitActivePair(); + const fromLeft = store.linkedPoint('left', [1, 1]); + expect(fromLeft).toMatchObject({ camera: 'right', coord: [6, -2] }); + const fromRight = store.linkedPoint('right', [6, -2]); + expect(fromRight).toMatchObject({ camera: 'left', coord: [1, 1] }); + }); + + it('linkedPoint returns null when the pair has no fitted homography yet', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + expect(store.linkedPoint('left', [1, 1])).toBeNull(); + }); + + it('linkedPoint returns null for a camera outside the active pair', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + addFourTranslationPairs(store); + expect(store.linkedPoint('other', [1, 1])).toBeNull(); + }); + }); + + describe('cursor coordinate readout', () => { + it('records and clears the cursor coordinate', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + store.setCursorCoord('left', [12, 34]); + expect(store.cursorCoord.value).toEqual({ camera: 'left', coord: [12, 34] }); + store.clearCursorCoord(); + expect(store.cursorCoord.value).toBeNull(); + }); + + it('clears the cursor coordinate when switching pairs', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + store.setCursorCoord('left', [12, 34]); + store.setActivePair('left', 'other'); + expect(store.cursorCoord.value).toBeNull(); + }); + }); + + describe('clearLast', () => { + it('drops the pending point without touching completed correspondences', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + store.addPoint('left', [1, 1]); + store.addPoint('right', [2, 2]); + store.addPoint('left', [3, 3]); // pending + store.clearLast(); + expect(store.pendingPoint.value).toBeNull(); + expect(store.correspondences.value[key]).toHaveLength(1); + }); + + it('removes the last completed correspondence when there is no pending point', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + store.addPoint('left', [1, 1]); + store.addPoint('right', [2, 2]); + store.addPoint('left', [3, 3]); + store.addPoint('right', [4, 4]); + store.clearLast(); + expect(store.correspondences.value[key]).toHaveLength(1); + expect(store.correspondences.value[key][0]).toMatchObject({ a: [1, 1], b: [2, 2] }); + }); + + it('is a no-op with nothing to undo', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + expect(() => store.clearLast()).not.toThrow(); + store.clearLast(); + expect(store.pendingPoint.value).toBeNull(); + }); + + it('refits when clearing last while alignment mode is active', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + store.setTransformType(key, 'homography'); + addFourTranslationPairs(store); + store.setAlignmentMode('AtoB'); + store.clearLast(); + expect(store.correspondences.value[key]).toHaveLength(3); + expect(store.alignment.value.mode).toBe('original'); + expect(store.homographies.value[key]).toBeUndefined(); + }); + }); + + describe('requestRecenter', () => { + it('records a recenter request for a camera in the active pair', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + store.requestRecenter('right', [7, 8]); + expect(store.recenterRequest.value).toMatchObject({ camera: 'right', coord: [7, 8] }); + }); + + it('ignores a recenter request for a camera outside the active pair', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + store.requestRecenter('other', [7, 8]); + expect(store.recenterRequest.value).toBeNull(); + }); + + it('assigns a new id to each request so repeated identical requests still change', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + store.requestRecenter('left', [1, 1]); + const firstId = store.recenterRequest.value?.id; + store.requestRecenter('left', [1, 1]); + expect(store.recenterRequest.value?.id).not.toBe(firstId); + }); + + it('clears the recenter request when switching pairs', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + store.requestRecenter('left', [1, 1]); + store.setActivePair('left', 'other'); + expect(store.recenterRequest.value).toBeNull(); }); }); @@ -92,6 +580,9 @@ describe('CameraCalibrationStore', () => { }); describe('loaded (file-sourced) homographies', () => { + // Pure translation by (+5, -3): trivially invertible. + const translate = [[1, 0, 5], [0, 1, -3], [0, 0, 1]]; + /** Load a matrix-only (point-less) pair from calibration JSON, marking it 'loaded'. */ function loadMatrixOnlyPair(store: CameraCalibrationStore, left: string, right: string, rightToLeft: number[][]) { store.loadCalibrationText(JSON.stringify({ @@ -104,6 +595,7 @@ describe('CameraCalibrationStore', () => { it('loads a matrix-only pair as B->A with its inverse as A->B and no points', () => { const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); const key = store.pairKey('left', 'right'); loadMatrixOnlyPair(store, 'left', 'right', translate); expect(store.correspondences.value[key]).toHaveLength(0); @@ -114,46 +606,85 @@ describe('CameraCalibrationStore', () => { expect(homog.AtoB[1][2]).toBeCloseTo(3); }); + it('keeps a loaded homography through refit checks with too few points', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + loadMatrixOnlyPair(store, 'left', 'right', translate); + store.maybeFitPair(key); + expect(store.homographies.value[key]).toBeDefined(); + // Alignment can activate directly off the loaded transform. + store.setAlignmentMode('AtoB'); + expect(store.alignment.value.mode).toBe('AtoB'); + }); + + it('replaces a loaded homography once enough points are picked and fitted', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + loadMatrixOnlyPair(store, 'left', 'right', [[1, 0, 100], [0, 1, 100], [0, 0, 1]]); + addFourTranslationPairs(store); + store.maybeFitPair(key); + expect(store.isLoadedHomography(key)).toBe(false); + // Fitted from points: right = left + (5, -3), so AtoB translates by (5, -3). + expect(store.homographies.value[key].AtoB[0][2]).toBeCloseTo(5); + expect(store.homographies.value[key].AtoB[1][2]).toBeCloseTo(-3); + }); + + it('clearPair removes a loaded homography', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + loadMatrixOnlyPair(store, 'left', 'right', translate); + store.clearPair(); + expect(store.homographies.value[key]).toBeUndefined(); + expect(store.isLoadedHomography(key)).toBe(false); + }); + + it('clearPair also removes a stale fitted homography immediately', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + const key = store.pairKey('left', 'right'); + addFourTranslationPairs(store); + store.fitTransform(key); + store.clearPair(); + expect(store.homographies.value[key]).toBeUndefined(); + }); + it('rejects a singular loaded matrix', () => { const store = new CameraCalibrationStore(); expect(() => loadMatrixOnlyPair(store, 'left', 'right', [[0, 0, 0], [0, 0, 0], [0, 0, 0]])) .toThrow(/singular/); }); - it('hydrate marks an under-pointed homography as loaded', () => { + it('hydrate marks an under-pointed homography as loaded so it survives refit checks', () => { const store = new CameraCalibrationStore(); const key = store.pairKey('left', 'right'); store.hydrate({ [key]: { AtoB: translate, BtoA: translate } }, {}, {}); expect(store.isLoadedHomography(key)).toBe(true); + store.maybeFitPair(key); + expect(store.homographies.value[key]).toBeDefined(); }); }); describe('calibration JSON file round trip', () => { it('serializes and reloads all pairs', () => { const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); const key = store.pairKey('left', 'right'); - store.loadCalibrationText(JSON.stringify({ - version: 1, - pairs: [{ - left: 'left', - right: 'right', - points: translationPointRows, - leftToRight: translate, - rightToLeft: null, - transformType: 'translation', - }], - })); + addFourTranslationPairs(store); + store.setTransformType(key, 'translation'); + store.fitTransform(key); const json = store.toCalibrationJson(); const restored = new CameraCalibrationStore(); + restored.setActivePair('left', 'right'); const result = restored.loadCalibrationText(json); expect(result.pairCount).toBe(1); expect(result.cameras.sort()).toEqual(['left', 'right']); expect(restored.correspondences.value[key]).toHaveLength(4); expect(restored.transformTypeForPair(key)).toBe('translation'); expect(restored.homographies.value[key].AtoB[0][2]).toBeCloseTo(5); - // The missing direction was derived by inversion and round-tripped. - expect(restored.homographies.value[key].BtoA[0][2]).toBeCloseTo(-5); // Enough points back the homography, so it is treated as fitted. expect(restored.isLoadedHomography(key)).toBe(false); }); @@ -173,6 +704,17 @@ describe('CameraCalibrationStore', () => { expect(restored.isLoadedHomography(key)).toBe(true); }); + it('reverts alignment to original and keeps the active pair on load', () => { + const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); + addFourTranslationPairs(store); + store.setAlignmentMode('AtoB'); + const json = store.toCalibrationJson(); + store.loadCalibrationText(json); + expect(store.alignment.value.mode).toBe('original'); + expect(store.activePair.value).toEqual({ camA: 'left', camB: 'right' }); + }); + it('loads a desktop-persisted calibration.json (no "type" field, one direction only)', () => { const store = new CameraCalibrationStore(); const result = store.loadCalibrationText(JSON.stringify({ @@ -194,29 +736,30 @@ describe('CameraCalibrationStore', () => { expect(store.transformTypeForPair(key)).toBe('translation'); }); - it('preserves the producer source stamp across a load/save round trip', () => { + it('preserves the producer source stamp across load, refinement, and save', () => { const store = new CameraCalibrationStore(); const source = { model: 'colmap-2026-07-01', swathe: 'fl07_C' }; + store.setActivePair('left', 'right'); store.loadCalibrationText(JSON.stringify({ version: 1, source, pairs: [{ - left: 'left', right: 'right', points: [], leftToRight: null, rightToLeft: translate, + left: 'left', right: 'right', points: [], leftToRight: null, rightToLeft: [[1, 0, 5], [0, 1, -3], [0, 0, 1]], }], })); expect(store.source.value).toStrictEqual(source); + // In-app refinement replaces the transform but keeps the lineage stamp. + addFourTranslationPairs(store); + store.maybeFitPair(store.pairKey('left', 'right')); + expect(store.source.value).toStrictEqual(source); const saved = JSON.parse(store.toCalibrationJson()); expect(saved.source).toStrictEqual(source); }); it('omits the source key when no stamp was loaded', () => { const store = new CameraCalibrationStore(); - store.loadCalibrationText(JSON.stringify({ - version: 1, - pairs: [{ - left: 'left', right: 'right', points: translationPointRows, leftToRight: translate, rightToLeft: null, - }], - })); + store.setActivePair('left', 'right'); + addFourTranslationPairs(store); expect('source' in JSON.parse(store.toCalibrationJson())).toBe(false); }); @@ -246,69 +789,59 @@ describe('CameraCalibrationStore', () => { expect(store.source.value).toBeNull(); }); - it('flags a point-backed homography as refined when a source stamp is loaded', () => { + it('flags a pair as refined once an in-app fit replaces a stamped matrix', () => { const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); const key = store.pairKey('left', 'right'); - // Fresh from the producer (matrix-only): loaded, not refined. store.loadCalibrationText(JSON.stringify({ version: 1, source: { model: 'colmap-x' }, pairs: [{ - left: 'left', right: 'right', points: [], leftToRight: null, rightToLeft: translate, + left: 'left', right: 'right', points: [], leftToRight: null, rightToLeft: [[1, 0, 5], [0, 1, -3], [0, 0, 1]], }], })); + // Fresh from the producer: loaded, not refined. expect(store.isRefinedFromSource(key)).toBe(false); - // Point-backed under a stamp: the pair has diverged from the producer. - store.loadCalibrationText(JSON.stringify({ - version: 1, - source: { model: 'colmap-x' }, - pairs: [{ - left: 'left', right: 'right', points: translationPointRows, leftToRight: translate, rightToLeft: null, transformType: 'translation', - }], - })); + addFourTranslationPairs(store); + store.maybeFitPair(key); expect(store.isRefinedFromSource(key)).toBe(true); }); - it('does not flag point-backed homographies as refined when no source stamp is loaded', () => { + it('does not flag fits as refined when no source stamp is loaded', () => { const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); const key = store.pairKey('left', 'right'); - store.loadCalibrationText(JSON.stringify({ - version: 1, - pairs: [{ - left: 'left', right: 'right', points: translationPointRows, leftToRight: translate, rightToLeft: null, transformType: 'translation', - }], - })); + addFourTranslationPairs(store); + store.fitTransform(key); expect(store.isRefinedFromSource(key)).toBe(false); }); it('keeps the refined flag across a save/load round trip', () => { const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); const key = store.pairKey('left', 'right'); store.loadCalibrationText(JSON.stringify({ version: 1, source: { model: 'colmap-x' }, pairs: [{ - left: 'left', right: 'right', points: translationPointRows, leftToRight: translate, rightToLeft: null, transformType: 'translation', + left: 'left', right: 'right', points: [], leftToRight: null, rightToLeft: [[1, 0, 100], [0, 1, 100], [0, 0, 1]], }], })); - expect(store.isRefinedFromSource(key)).toBe(true); + addFourTranslationPairs(store); + store.maybeFitPair(key); const restored = new CameraCalibrationStore(); restored.loadCalibrationText(store.toCalibrationJson()); - // The refined pair saved with its backing points, so it re-marks as + // The refit pair saved with its backing points, so it re-marks as // fitted (refined) rather than loaded. expect(restored.isRefinedFromSource(key)).toBe(true); }); it('rejects non-JSON, missing pairs, malformed pairs, and bad matrices without clobbering state', () => { const store = new CameraCalibrationStore(); + store.setActivePair('left', 'right'); const key = store.pairKey('left', 'right'); - store.loadCalibrationText(JSON.stringify({ - version: 1, - pairs: [{ - left: 'left', right: 'right', points: translationPointRows, leftToRight: translate, rightToLeft: null, - }], - })); + addFourTranslationPairs(store); expect(() => store.loadCalibrationText('not json')).toThrow(/valid JSON/); expect(() => store.loadCalibrationText('{"type": "other"}')).toThrow(/pairs/); expect(() => store.loadCalibrationText(JSON.stringify({ diff --git a/client/src/CameraCalibrationStore.ts b/client/src/CameraCalibrationStore.ts index 6842d7608..b9017b2e9 100644 --- a/client/src/CameraCalibrationStore.ts +++ b/client/src/CameraCalibrationStore.ts @@ -2,10 +2,10 @@ import { ref, computed, Ref, ComputedRef, } from 'vue'; import { - invert3, Matrix3, Point, + invert3, applyHomography, Matrix3, Point, } from './homography'; import { - TransformType, TRANSFORM_TYPES, DEFAULT_TRANSFORM_TYPE, minPointsForTransform, + TransformType, TRANSFORM_TYPES, DEFAULT_TRANSFORM_TYPE, minPointsForTransform, estimateTransform, } from './transform'; /** @@ -85,21 +85,76 @@ export type CameraCorrespondences = Record; /** Chosen fit model per pair, keyed by {@link CameraCalibrationStore.pairKey}. Missing entries default to 'similarity'. */ export type CameraTransformTypes = Record; +/** Which image is warped onto which for the in-app aligned-picking preview. */ +export type AlignmentMode = 'original' | 'AtoB' | 'BtoA'; + +export interface AlignmentState { + mode: AlignmentMode; + opacity: number; +} + +/** Active pair. `camA` is the left camera, `camB` the right (user-chosen order). */ +export interface ActivePair { + camA: string; + camB: string; +} + /** - * Shared, reactive store for camera-calibration data (correspondences, - * fitted/loaded homographies, transform-type choices, and producer - * provenance). Lives in vue-media-annotator so both the annotation layers - * (client/src/layers) and the dive-common side can consume it via the - * provide/inject system. Handles persistence: hydrating saved state and - * loading/saving the portable calibration JSON format. + * Shared, reactive state for the interactive camera-calibration tool. Lives in + * vue-media-annotator so both the geojs picking layer (client/src/layers) and the + * dive-common side panel can consume it via the provide/inject system. + * + * Implements the keypointgui blue->red pairing flow: the first click in one camera + * sets a pending point; the next click in the *other* camera completes a pair. */ export default class CameraCalibrationStore { + activePair: Ref; + + pickingEnabled: Ref; + + pendingPoint: Ref<{ camera: string; coord: Point } | null>; + correspondences: Ref; homographies: Ref; transformTypes: Ref; + alignment: Ref; + + /** + * Whether pan/zoom is linked between the active pair's two cameras through + * the fitted transform (see {@link useCalibrationNavigation}). Only has an + * effect once a transform is fitted; toggled from the panel's "Fit pan/zoom". + */ + linkedNav: Ref; + + /** + * Correspondence currently selected in the picking UI (grabbed marker / + * clicked table row), highlighted in BOTH cameras' panes and deletable via + * the panel or the Delete key. Authoring state only -- never persisted. + */ + selectedCorrespondenceId: Ref; + + /** Native-pixel coordinate under the cursor, for the calibration panel's live readout. */ + cursorCoord: Ref<{ camera: string; coord: Point } | null>; + + /** + * A one-shot "recenter here" request (e.g. from a right-click), keyed by an + * incrementing id so repeated requests at the same coordinate still trigger + * watchers. See {@link requestRecenter}. + */ + recenterRequest: Ref<{ camera: string; coord: Point; id: number } | null>; + + /** + * Message from the most recent failed fit attempt (e.g. collinear/degenerate + * points that satisfy the minimum count but can't be solved), or null if the + * active pair's last fit attempt (if any) succeeded. Surfaced by the + * calibration panel instead of letting the estimator's exception escape a + * geojs click handler. + */ + fitError: Ref; + /** * Provenance of the loaded calibration (see {@link CalibrationSource}). * Deliberately NOT cleared by in-app edits or refits -- refinements are @@ -114,6 +169,8 @@ export default class CameraCalibrationStore { private nextId: number; + private nextRecenterId: number; + /** Provenance per homography key; missing entries behave like 'fit'. */ private homographySources: Record; @@ -121,11 +178,21 @@ export default class CameraCalibrationStore { private savedSnapshot: Ref; constructor() { + this.activePair = ref(null); + this.pickingEnabled = ref(false); + this.pendingPoint = ref(null); this.correspondences = ref({}); this.homographies = ref({}); this.transformTypes = ref({}); + this.alignment = ref({ mode: 'original', opacity: 0.5 }); + this.linkedNav = ref(true); + this.selectedCorrespondenceId = ref(null); + this.cursorCoord = ref(null); + this.recenterRequest = ref(null); + this.fitError = ref(null); this.source = ref(null); this.nextId = 1; + this.nextRecenterId = 1; this.homographySources = {}; this.savedSnapshot = ref(this.calibrationSnapshot()); this.dirty = computed(() => this.calibrationSnapshot() !== this.savedSnapshot.value); @@ -167,6 +234,188 @@ export default class CameraCalibrationStore { return `${camA}::${camB}`; } + /** Key of the currently active pair, or null if none selected. */ + activePairKey(): string | null { + const pair = this.activePair.value; + return pair ? this.pairKey(pair.camA, pair.camB) : null; + } + + /** Select a camera pair. `left` becomes camA, `right` becomes camB. */ + setActivePair(left: string | null, right: string | null) { + if (!left || !right || left === right) { + this.activePair.value = null; + } else { + this.activePair.value = { camA: left, camB: right }; + } + this.pendingPoint.value = null; + // Switching pairs invalidates any active overlay warp: drop back to the + // unwarped Picking mode so the new pair starts from its own native views. + this.alignment.value = { mode: 'original', opacity: this.alignment.value.opacity }; + this.selectedCorrespondenceId.value = null; + this.cursorCoord.value = null; + this.recenterRequest.value = null; + this.fitError.value = null; + } + + /** + * Add a clicked image point for `camera`. The first click sets a pending point; + * a subsequent click in the *other* camera of the active pair completes a pair. + * Clicking the same camera again replaces the pending point. + */ + addPoint(camera: string, coord: Point) { + const pair = this.activePair.value; + if (!pair || (camera !== pair.camA && camera !== pair.camB)) { + return; + } + const pending = this.pendingPoint.value; + if (!pending || pending.camera === camera) { + this.pendingPoint.value = { camera, coord }; + return; + } + const key = this.pairKey(pair.camA, pair.camB); + const a = pending.camera === pair.camA ? pending.coord : coord; + const b = pending.camera === pair.camB ? pending.coord : coord; + const list = this.correspondences.value[key] + ? [...this.correspondences.value[key]] + : []; + // eslint-disable-next-line no-plusplus + list.push({ id: this.nextId++, a, b }); + this.correspondences.value = { ...this.correspondences.value, [key]: list }; + this.pendingPoint.value = null; + this.syncAlignmentHomography(); + } + + /** + * Record a click at `coord` (native pixel coords of `camera`'s own pane). + * New points are only picked in the unwarped 'original' (Picking) mode: while + * an overlay warp is active the panes show a warped preview rather than native + * coordinates, so clicks there are ignored. + */ + pickPoint(camera: string, coord: Point) { + if (this.alignment.value.mode !== 'original') { + return; + } + this.addPoint(camera, coord); + } + + /** + * Move one side of an existing correspondence (drag-to-refine). `camera` + * selects which side (a for camA, b for camB); the pair is refit live so + * the alignment ghost and linked navigation track the drag. + */ + updateCorrespondencePoint(id: number, camera: string, coord: Point) { + const pair = this.activePair.value; + if (!pair || (camera !== pair.camA && camera !== pair.camB)) { + return; + } + const key = this.pairKey(pair.camA, pair.camB); + const list = this.correspondences.value[key]; + if (!list || !list.some((c) => c.id === id)) { + return; + } + const side = camera === pair.camA ? 'a' : 'b'; + this.correspondences.value = { + ...this.correspondences.value, + [key]: list.map((c) => (c.id === id ? { ...c, [side]: coord } : c)), + }; + this.syncAlignmentHomography(); + } + + /** Move the pending (blue) point while it is being drag-refined. */ + movePendingPoint(camera: string, coord: Point) { + const pending = this.pendingPoint.value; + if (!pending || pending.camera !== camera) { + return; + } + this.pendingPoint.value = { camera, coord }; + } + + /** Remove a correspondence (by id) from the active pair -- both cameras' points at once. */ + removeCorrespondence(id: number) { + const key = this.activePairKey(); + if (!key) { + return; + } + const list = this.correspondences.value[key]; + if (!list) { + return; + } + this.correspondences.value = { + ...this.correspondences.value, + [key]: list.filter((c) => c.id !== id), + }; + if (this.selectedCorrespondenceId.value === id) { + this.selectedCorrespondenceId.value = null; + } + this.syncAlignmentHomography(); + } + + /** + * Select a correspondence marker for inspection/deletion (null clears). + * Only ids belonging to the active pair are selectable; anything else + * clears the selection. + */ + selectCorrespondence(id: number | null) { + if (id === null) { + this.selectedCorrespondenceId.value = null; + return; + } + const key = this.activePairKey(); + const list = key ? this.correspondences.value[key] : undefined; + this.selectedCorrespondenceId.value = (list && list.some((c) => c.id === id)) ? id : null; + } + + /** Remove the selected correspondence (both cameras' points). No-op without a selection. */ + removeSelectedCorrespondence() { + const id = this.selectedCorrespondenceId.value; + if (id !== null) { + this.removeCorrespondence(id); + } + } + + /** + * Drop all correspondences, the pending point, and any homography + * (fitted or file-loaded) for the active pair. + */ + clearPair() { + const key = this.activePairKey(); + this.pendingPoint.value = null; + this.selectedCorrespondenceId.value = null; + if (!key) { + return; + } + this.correspondences.value = { ...this.correspondences.value, [key]: [] }; + // Clearing is explicit: a file-loaded homography goes too. Dropping the + // 'loaded' mark lets maybeFitPair remove it through the normal path. + delete this.homographySources[key]; + this.maybeFitPair(key); + } + + /** + * Undo one step, mirroring keypointgui's Clear Last button: if there's a + * pending (blue) point, drop it; otherwise remove the most recently + * completed correspondence for the active pair. + */ + clearLast() { + if (this.pendingPoint.value) { + this.pendingPoint.value = null; + return; + } + const key = this.activePairKey(); + if (!key) { + return; + } + const list = this.correspondences.value[key]; + if (!list || list.length === 0) { + return; + } + if (this.selectedCorrespondenceId.value === list[list.length - 1].id) { + this.selectedCorrespondenceId.value = null; + } + this.correspondences.value = { ...this.correspondences.value, [key]: list.slice(0, -1) }; + this.syncAlignmentHomography(); + } + /** * True when `key`'s homography came from a calibration file rather than an * in-app fit. Not independently reactive -- always read alongside @@ -196,6 +445,154 @@ export default class CameraCalibrationStore { return this.transformTypes.value[key] || DEFAULT_TRANSFORM_TYPE; } + /** Choose the fit model for `key` and immediately (re)fit or clear as needed. */ + setTransformType(key: string, type: TransformType) { + this.transformTypes.value = { ...this.transformTypes.value, [key]: type }; + this.maybeFitPair(key); + } + + /** + * Fit `key` when it has enough points for its chosen transform type; otherwise + * clear its homography and, if it's the active (aligned) pair, revert + * alignment to 'original'. A fit can still fail past the minimum-count check + * (e.g. collinear/near-duplicate points make the system unsolvable); that's + * caught here and surfaced via {@link fitError} instead of throwing out of a + * geojs click handler, keeping any previously fitted homography in place. + */ + maybeFitPair(key: string) { + const list = this.correspondences.value[key]; + const required = minPointsForTransform(this.transformTypeForPair(key)); + if (!list || list.length < required) { + // A file-loaded homography has no backing points; it stays in place + // until enough points are picked to fit a replacement (or the pair is + // explicitly cleared, which drops its 'loaded' mark first). + if (this.homographySources[key] !== 'loaded') { + const rest = { ...this.homographies.value }; + delete rest[key]; + this.homographies.value = rest; + delete this.homographySources[key]; + if (this.activePairKey() === key && this.alignment.value.mode !== 'original') { + this.alignment.value = { ...this.alignment.value, mode: 'original' }; + } + } + if (this.activePairKey() === key) { + this.fitError.value = null; + } + return; + } + try { + this.fitTransform(key); + if (this.activePairKey() === key) { + this.fitError.value = null; + } + } catch (err) { + if (this.activePairKey() === key) { + this.fitError.value = err instanceof Error ? err.message : String(err); + } + } + } + + /** Fit the active pair when it has enough points; otherwise clear/revert as in {@link maybeFitPair}. */ + maybeFitActivePair() { + const key = this.activePairKey(); + if (!key) { + return; + } + this.maybeFitPair(key); + } + + /** Enable or change the alignment (ghost overlay) mode, fitting the pair first if needed. */ + setAlignmentMode(mode: AlignmentMode) { + if (mode !== 'original') { + this.maybeFitActivePair(); + const key = this.activePairKey(); + if (!key || !this.homographies.value[key]) { + // Not enough points for the active pair's transform type; stay original. + return; + } + } + this.alignment.value = { ...this.alignment.value, mode }; + } + + /** Ghost overlay opacity, independent of alignment mode. */ + setAlignmentOpacity(opacity: number) { + this.alignment.value = { ...this.alignment.value, opacity }; + } + + /** + * Map `coord` (native pixel space of `camera`) to the corresponding point in + * the *other* camera of the active pair, via the fitted homography. Returns + * `null` when `camera` isn't part of the active pair or the pair has no + * fitted homography yet (not enough correspondences) -- callers should treat + * that as "nothing to link to" rather than an error. + */ + linkedPoint(camera: string, coord: Point): { camera: string; coord: Point } | null { + const pair = this.activePair.value; + if (!pair || (camera !== pair.camA && camera !== pair.camB)) { + return null; + } + const homog = this.homographies.value[this.pairKey(pair.camA, pair.camB)]; + if (!homog) { + return null; + } + const other = camera === pair.camA ? pair.camB : pair.camA; + const matrix = camera === pair.camA ? homog.AtoB : homog.BtoA; + return { camera: other, coord: applyHomography(matrix, coord) }; + } + + /** Record the native-pixel coordinate under the cursor for `camera` (calibration panel readout). */ + setCursorCoord(camera: string, coord: Point) { + this.cursorCoord.value = { camera, coord }; + } + + /** Clear the cursor coordinate readout (e.g. on mouse leave). */ + clearCursorCoord() { + this.cursorCoord.value = null; + } + + /** + * Request that `camera` (native pixel coords `coord`) and, when the pair has + * a fitted homography, the other camera of the active pair (via + * {@link linkedPoint}) recenter their views on this location. Consumed by + * {@link useCalibrationNavigation}; a no-op if `camera` isn't part of the + * active pair. A one-shot "snap to this feature" action, distinct from the + * continuous pan/zoom link that is active while picking. + */ + requestRecenter(camera: string, coord: Point) { + const pair = this.activePair.value; + if (!pair || (camera !== pair.camA && camera !== pair.camB)) { + return; + } + // eslint-disable-next-line no-plusplus + this.recenterRequest.value = { camera, coord, id: this.nextRecenterId++ }; + } + + /** Re-fit the active pair while alignment is active (mode != 'original'). */ + private syncAlignmentHomography() { + if (this.alignment.value.mode !== 'original') { + this.maybeFitActivePair(); + } + } + + /** + * Fit `key`'s chosen transform type from its correspondences (see + * {@link minPointsForTransform} for the required count). Computes both + * directions and stores them. Returns the fitted pair. + */ + fitTransform(key: string): PairHomography { + const list = this.correspondences.value[key]; + const type = this.transformTypeForPair(key); + const required = minPointsForTransform(type); + if (!list || list.length < required) { + throw new Error(`At least ${required} point pair(s) are required to fit a ${type} transform`); + } + const AtoB = estimateTransform(type, list.map((c) => c.a), list.map((c) => c.b)); + const BtoA = invert3(AtoB); + this.homographies.value = { ...this.homographies.value, [key]: { AtoB, BtoA } }; + this.homographySources[key] = 'fit'; + return { AtoB, BtoA }; + } + /** * Serialize every pair with content (points and/or a homography) as the * portable calibration JSON file (see {@link CalibrationFile}). Pairs whose @@ -232,7 +629,9 @@ export default class CameraCalibrationStore { /** * Parse and load a calibration JSON file (the format written by * {@link toCalibrationJson}), REPLACING all pairs' correspondences, - * homographies, and transform types. Throws a descriptive Error on + * homographies, and transform types. The active pair selection and picking + * toggle are left alone; the alignment ghost reverts to 'original' since + * the transform under it changed wholesale. Throws a descriptive Error on * malformed input without touching current state. Returns the camera names * referenced by the file so callers can warn about ones missing from the * loaded dataset. @@ -295,6 +694,10 @@ export default class CameraCalibrationStore { this.transformTypes.value = transformTypes; this.source.value = source; this.markHomographySources(); + this.pendingPoint.value = null; + this.selectedCorrespondenceId.value = null; + this.fitError.value = null; + this.alignment.value = { ...this.alignment.value, mode: 'original' }; return { cameras: [...cameras], pairCount: file.pairs.length }; } @@ -366,6 +769,14 @@ export default class CameraCalibrationStore { this.transformTypes.value = transformTypes ? { ...transformTypes } : {}; this.source.value = source ?? null; this.markHomographySources(); + this.activePair.value = null; + this.pendingPoint.value = null; + this.pickingEnabled.value = false; + this.alignment.value = { mode: 'original', opacity: 0.5 }; + this.selectedCorrespondenceId.value = null; + this.cursorCoord.value = null; + this.recenterRequest.value = null; + this.fitError.value = null; // Resume id allocation past any restored correspondences. let maxId = 0; Object.values(this.correspondences.value).forEach((list) => { diff --git a/client/src/components/LayerManager.vue b/client/src/components/LayerManager.vue index 82ffdccb3..5b3951193 100644 --- a/client/src/components/LayerManager.vue +++ b/client/src/components/LayerManager.vue @@ -12,6 +12,7 @@ import PointLayer from '../layers/AnnotationLayers/PointLayer'; import LineLayer from '../layers/AnnotationLayers/LineLayer'; import TailLayer from '../layers/AnnotationLayers/TailLayer'; import OverlapLayer from '../layers/AnnotationLayers/OverlapLayer'; +import CalibrationKeypointLayer from '../layers/AnnotationLayers/CalibrationKeypointLayer'; import EditAnnotationLayer, { EditAnnotationTypes } from '../layers/EditAnnotationLayer'; import LassoSelectionLayer from '../layers/LassoSelectionLayer'; @@ -44,6 +45,7 @@ import { useAnnotatorPreferences, useGroupStyleManager, useCameraStore, + useCameraCalibration, useAlignedView, useSelectedCamera, useAttributes, @@ -83,6 +85,12 @@ export default defineComponent({ // Viewer may not provide lasso context in tests or minimal embeds. } const cameraStore = useCameraStore(); + let cameraCalibration: ReturnType | undefined; + try { + cameraCalibration = useCameraCalibration(); + } catch { + // calibration store may not be provided in tests or minimal embeds. + } let alignedView: ReturnType | undefined; try { alignedView = useAlignedView(); @@ -349,6 +357,56 @@ export default defineComponent({ } }, { deep: true }); + const calibrationLayer = cameraCalibration + ? new CalibrationKeypointLayer({ + annotator, + stateStyling: trackStyleManager.stateStyles, + typeStyling: typeStylingRef, + calibration: cameraCalibration, + getCameraImage, + }) + : undefined; + + if (cameraCalibration && calibrationLayer) { + const calibration = cameraCalibration; + /** + * Frame number of the camera whose image is being ghosted into another + * pane, or null when no ghost is active. Watched so the ghost re-renders + * when the *source* pane scrubs, not just this pane -- this pane's own + * frameNumberRef can update before (or without) the source's, and the + * source image element itself only swaps after its frame finishes + * loading (see CalibrationKeypointLayer.scheduleGhostRefresh). + */ + const ghostSourceFrame = computed(() => { + const { mode } = calibration.alignment.value; + const pair = calibration.activePair.value; + if (mode === 'original' || !pair) { + return null; + } + const srcCam = mode === 'BtoA' ? pair.camB : pair.camA; + try { + return aggregateController.value.getController(srcCam).frame.value; + } catch { + return null; + } + }); + watch( + [ + cameraCalibration.activePair, + cameraCalibration.pickingEnabled, + cameraCalibration.correspondences, + cameraCalibration.pendingPoint, + cameraCalibration.selectedCorrespondenceId, + cameraCalibration.homographies, + cameraCalibration.alignment, + frameNumberRef, + ghostSourceFrame, + ], + () => calibrationLayer.update(), + { deep: true }, + ); + } + const updateAttributes = () => { const newList = attributes.value.filter((item) => item.render).sort((a, b) => { if (a.render && b.render) { diff --git a/client/src/components/annotators/useCalibrationNavigation.spec.ts b/client/src/components/annotators/useCalibrationNavigation.spec.ts new file mode 100644 index 000000000..39f8ec20d --- /dev/null +++ b/client/src/components/annotators/useCalibrationNavigation.spec.ts @@ -0,0 +1,140 @@ +/// +import { + ref, shallowRef, nextTick, Ref, +} from 'vue'; +import useCalibrationNavigation from './useCalibrationNavigation'; +import type CameraCalibrationStore from '../../CameraCalibrationStore'; +import type { AggregateMediaController } from './mediaControllerType'; +import type { Point } from '../../homography'; + +vi.mock('geojs', () => ({ default: { event: { pan: 'geo_pan', zoom: 'geo_zoom' } } })); + +/** Minimal stand-in for a geojs viewer: center/zoom state + geoOn events. */ +function fakeViewer(baseUnitsPerPixel: number) { + const state = { center: { x: 0, y: 0 }, zoom: 0 }; + const handlers: Record void>> = {}; + return { + geoOn(evt: string, handler: () => void) { + handlers[evt] = handlers[evt] || []; + handlers[evt].push(handler); + }, + geoOff(evt: string, handler: () => void) { + handlers[evt] = (handlers[evt] || []).filter((h) => h !== handler); + }, + center(c?: { x: number; y: number }) { + if (c) { + state.center = { ...c }; + } + return state.center; + }, + zoom(z?: number) { + if (z !== undefined) { + state.zoom = z; + } + return state.zoom; + }, + unitsPerPixel(z: number) { + return baseUnitsPerPixel / 2 ** z; + }, + trigger(evt: string) { + (handlers[evt] || []).forEach((h) => h()); + }, + }; +} + +function makeHarness() { + const eo = fakeViewer(1); + const ir = fakeViewer(8); + const controllers: Record }> = { + eo: { geoViewerRef: ref(eo) }, + ir: { geoViewerRef: ref(ir) }, + }; + const resizing = ref(false); + const resizeTrigger = ref(0); + // shallowRef: a plain ref would deep-unwrap the nested resizing / + // resizeTrigger refs, unlike the real aggregate controller object. + const aggregate = shallowRef({ + resizing, + resizeTrigger, + getController: (name: string) => controllers[name], + }) as unknown as Ref; + + const pickingEnabled = ref(false); + const linkedNav = ref(false); + const homographies = ref>({}); + const fitted = ref(true); + // eo -> ir is a pure +100 x-translation (and ir -> eo its inverse), so the + // linked scale is 1 and centers map by simple offset. + const calibration = { + pickingEnabled, + linkedNav, + activePair: ref({ camA: 'eo', camB: 'ir' }), + homographies, + recenterRequest: ref(null), + linkedPoint(camera: string, coord: Point) { + if (!fitted.value) { + return null; + } + if (camera === 'eo') { + return { camera: 'ir', coord: [coord[0] + 100, coord[1]] as Point }; + } + return { camera: 'eo', coord: [coord[0] - 100, coord[1]] as Point }; + }, + } as unknown as CameraCalibrationStore; + + useCalibrationNavigation(aggregate, calibration); + return { + eo, ir, pickingEnabled, linkedNav, homographies, fitted, + }; +} + +describe('useCalibrationNavigation', () => { + it('snaps the pair immediately when "Fit pan/zoom" turns on', async () => { + const { + eo, ir, pickingEnabled, linkedNav, + } = makeHarness(); + pickingEnabled.value = true; + await nextTick(); + eo.center({ x: 250, y: 150 }); + eo.zoom(1); // units-per-pixel = 0.5 + + // Off: nothing linked yet. + expect(ir.center()).toEqual({ x: 0, y: 0 }); + + linkedNav.value = true; + await nextTick(); + + // No pan/zoom event fired: the toggle itself lined the pair up. + expect(ir.center()).toEqual({ x: 350, y: 150 }); + // Matching extent (scale 1) through ir's own zoom-0 baseline: log2(8 / 0.5). + expect(ir.zoom()).toBeCloseTo(4, 6); + }); + + it('re-snaps when the fitted homography changes under the link', async () => { + const { + eo, ir, pickingEnabled, linkedNav, homographies, + } = makeHarness(); + pickingEnabled.value = true; + linkedNav.value = true; + await nextTick(); + + eo.center({ x: 10, y: 20 }); + homographies.value = { 'eo|ir': 'refit' }; + await nextTick(); + + expect(ir.center()).toEqual({ x: 110, y: 20 }); + }); + + it('does not snap while no fit exists yet', async () => { + const { + ir, pickingEnabled, linkedNav, fitted, + } = makeHarness(); + fitted.value = false; + pickingEnabled.value = true; + linkedNav.value = true; + await nextTick(); + + expect(ir.center()).toEqual({ x: 0, y: 0 }); + expect(ir.zoom()).toBe(0); + }); +}); diff --git a/client/src/components/annotators/useCalibrationNavigation.ts b/client/src/components/annotators/useCalibrationNavigation.ts new file mode 100644 index 000000000..1f96ce778 --- /dev/null +++ b/client/src/components/annotators/useCalibrationNavigation.ts @@ -0,0 +1,121 @@ +import { Ref, watch } from 'vue'; +import type { AggregateMediaController } from './mediaControllerType'; +import type CameraCalibrationStore from '../../CameraCalibrationStore'; +import type AlignedViewStore from '../../AlignedViewStore'; +import { localLinkedScale } from '../../homography'; +import type { Point } from '../../homography'; +import useLinkedViewers from './useLinkedViewers'; + +/** + * Links pan/zoom recentering between the two cameras of the active calibration + * pair: panning or zooming one recenters the other on the same point, mapped + * through the pair's fitted homography ({@link CameraCalibrationStore.linkedPoint}). + * The mapping assumes UNWARPED panes showing native coordinates, so this is + * active only while point picking is (the aligned-view link, + * {@link useAlignedNavigation}, owns navigation otherwise) and stands down if + * the aligned view is somehow active. Distinct from the general "sync cameras" + * toggle (Controls.vue), which assumes identical pixel scale between panes. + */ +export default function useCalibrationNavigation( + aggregateController: Ref, + calibration: CameraCalibrationStore, + alignedView?: AlignedViewStore, +) { + const { + viewer, teardown, attach, guarded, applyView, + } = useLinkedViewers(aggregateController); + + function link(camera: string, otherCamera: string) { + return () => guarded(() => { + // The homography mapping assumes unwarped panes; the aligned-view link + // owns navigation while it is active. + if (alignedView?.active.value) { + return; + } + // Ignore the pan/zoom events onResize emits while resetting each pane to + // its own native bounds, so one pane's reset isn't mapped onto its pair. + if (aggregateController.value.resizing.value) { + return; + } + const source = viewer(camera); + const target = viewer(otherCamera); + if (!source || !target) { + return; + } + const center = source.center(); + const linked = calibration.linkedPoint(camera, [center.x, center.y]); + if (!linked || linked.camera !== otherCamera) { + return; + } + // Match the visible extent: one source pixel spans `scale` target pixels + // here (position-dependent for non-similarity fits, so sampled at center; + // null when unavailable -- leave the target's zoom alone). + const scale = localLinkedScale( + (p) => calibration.linkedPoint(camera, p)?.coord ?? null, + [center.x, center.y], + ); + applyView(target, { + center: { x: linked.coord[0], y: linked.coord[1] }, + unitsPerPixel: scale === null ? null : source.unitsPerPixel(source.zoom()) * scale, + }); + }); + } + + function setup() { + teardown(); + const pair = calibration.activePair.value; + // Authoring UI: active while picking and "Fit pan/zoom" is on (once a fit + // exists, linkedPoint returns matches). attach() no-ops for a not-yet-ready + // pane; the resizeTrigger watch re-runs setup once both viewers exist. + if (calibration.pickingEnabled.value && calibration.linkedNav.value && pair) { + attach(pair.camA, link(pair.camA, pair.camB)); + attach(pair.camB, link(pair.camB, pair.camA)); + // Snap immediately from camA so toggling "Fit pan/zoom" on (or a refit + // under it) lines the pair up right away instead of waiting for the + // first pan/zoom event. No-ops harmlessly while no fit exists yet + // (linkedPoint returns null). + link(pair.camA, pair.camB)(); + } + } + + watch( + [ + calibration.pickingEnabled, + calibration.linkedNav, + calibration.activePair, + calibration.homographies, + aggregateController.value.resizeTrigger, + ], + setup, + { deep: true }, + ); + + /** + * One-shot recenter (right-click while picking): center the clicked camera on + * the clicked point and, when the pair has a fitted homography, the other + * camera on the corresponding point. Guarded so it doesn't loop back through + * the continuous link above. + */ + function handleRecenterRequest( + request: { camera: string; coord: Point; id: number } | null, + ) { + if (!request || alignedView?.active.value) { + return; + } + const pair = calibration.activePair.value; + if (!pair || (request.camera !== pair.camA && request.camera !== pair.camB)) { + return; + } + const source = viewer(request.camera); + if (source) { + guarded(() => source.center({ x: request.coord[0], y: request.coord[1] })); + } + const linked = calibration.linkedPoint(request.camera, request.coord); + const target = linked && viewer(linked.camera); + if (linked && target) { + guarded(() => target.center({ x: linked.coord[0], y: linked.coord[1] })); + } + } + + watch(() => calibration.recenterRequest.value, handleRecenterRequest); +} diff --git a/client/src/components/index.ts b/client/src/components/index.ts index 21164ef6b..2aab7cfc2 100644 --- a/client/src/components/index.ts +++ b/client/src/components/index.ts @@ -28,6 +28,7 @@ import TypePicker from './TypePicker.vue'; export * from './annotators/useMediaController'; export { default as useAnnotatorImageCursor } from './annotators/useAnnotatorImageCursor'; +export { default as useCalibrationNavigation } from './annotators/useCalibrationNavigation'; export { default as useAlignedNavigation } from './annotators/useAlignedNavigation'; export { /* Annotators */ diff --git a/client/src/layers/AlignedImageLayer.ts b/client/src/layers/AlignedImageLayer.ts index 157819181..22e680885 100644 --- a/client/src/layers/AlignedImageLayer.ts +++ b/client/src/layers/AlignedImageLayer.ts @@ -1,15 +1,7 @@ import geo from 'geojs'; import type { MediaController } from '../components/annotators/mediaControllerType'; import { Matrix3, subdivideWarpQuads, warpGridSize } from '../homography'; - -export interface CameraImage { - /** The texture source for the geojs quad feature: an `` for image sequences, a `