= {
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 `