diff --git a/client/dive-common/autoRegisterSelection.spec.ts b/client/dive-common/autoRegisterSelection.spec.ts index d2f532e50..b10fd5229 100644 --- a/client/dive-common/autoRegisterSelection.spec.ts +++ b/client/dive-common/autoRegisterSelection.spec.ts @@ -3,86 +3,66 @@ import { describe, expect, it } from 'vitest'; import proposeRegistrationFrames from './autoRegisterSelection'; describe('proposeRegistrationFrames', () => { - it('spreads candidates across every temporal bin (no single-scene bias)', () => { - const frames = proposeRegistrationFrames({ - counts: [1200, 1200], - bins: 12, - perBin: 2, - }); - expect(frames.length).toBe(24); - // Every 100-frame bin contributes exactly its share: a scene-rich - // stretch can never supply all the candidates. - for (let bin = 0; bin < 12; bin += 1) { - const inBin = frames.filter((f) => f >= bin * 100 && f < (bin + 1) * 100); - expect(inBin.length).toBe(2); - } - expect(frames).toEqual([...frames].sort((a, b) => a - b)); + it('spreads frames evenly across the whole dataset', () => { + const frames = proposeRegistrationFrames({ counts: [1200, 1200], count: 12 }); + expect(frames).toEqual([50, 150, 250, 350, 450, 550, 650, 750, 850, 950, 1050, 1150]); }); it('spans only the shortest camera', () => { - const frames = proposeRegistrationFrames({ - counts: [1000, 300], - bins: 10, - perBin: 1, - }); + const frames = proposeRegistrationFrames({ counts: [1000, 300], count: 10 }); expect(Math.max(...frames)).toBeLessThan(300); expect(frames.length).toBe(10); }); - it('ranks within a bin by inter-camera timestamp skew', () => { - // Two cameras, 10 frames, one bin: frame 6 is perfectly synced, frame 3 - // is close, everything else is badly skewed. + it('spreads rather than clustering when every frame is perfectly synced', () => { const base = 1_700_000_000; - const camA = Array.from({ length: 10 }, (_, i) => base + i); - const camB = camA.map((t, i) => { - if (i === 6) return t; - if (i === 3) return t + 0.1; - return t + 5; + const stamps = Array.from({ length: 1200 }, (_, i) => base + i); + const frames = proposeRegistrationFrames({ + counts: [1200, 1200], + timestamps: [stamps, [...stamps]], + count: 12, }); + expect(frames).toEqual([50, 150, 250, 350, 450, 550, 650, 750, 850, 950, 1050, 1150]); + }); + + it('excludes frames whose skew exceeds the threshold and spreads over the rest', () => { + const base = 1_700_000_000; + const camA = Array.from({ length: 10 }, (_, i) => base + i); + const camB = camA.map((t, i) => (i < 5 ? t + 5 : t)); const frames = proposeRegistrationFrames({ counts: [10, 10], timestamps: [camA, camB], - bins: 1, - perBin: 2, + count: 5, maxSkewSeconds: 0.5, }); - expect(frames).toEqual([3, 6]); + expect(frames).toEqual([5, 6, 7, 8, 9]); }); - it('drops candidates whose skew exceeds the threshold entirely', () => { + it('returns nothing when every frame is out of sync', () => { const base = 1_700_000_000; const camA = Array.from({ length: 4 }, (_, i) => base + i); - const camB = camA.map((t) => t + 10); // hopeless sync everywhere - const frames = proposeRegistrationFrames({ - counts: [4, 4], - timestamps: [camA, camB], - bins: 1, - perBin: 2, - maxSkewSeconds: 0.5, - }); - expect(frames).toEqual([]); + const camB = camA.map((t) => t + 10); + expect(proposeRegistrationFrames({ + counts: [4, 4], timestamps: [camA, camB], count: 2, maxSkewSeconds: 0.5, + })).toEqual([]); }); - it('falls back to even spread for frames without timestamps', () => { + it('keeps frames whose skew is unknowable', () => { const frames = proposeRegistrationFrames({ counts: [100, 100], timestamps: [ Array.from({ length: 100 }, () => undefined), Array.from({ length: 100 }, () => undefined), ], - bins: 2, - perBin: 2, + count: 4, }); - expect(frames.length).toBe(4); - expect(frames.filter((f) => f < 50).length).toBe(2); - expect(frames.filter((f) => f >= 50).length).toBe(2); + expect(frames).toEqual([12, 37, 62, 87]); }); it('handles degenerate inputs', () => { - expect(proposeRegistrationFrames({ counts: [0, 10], bins: 5, perBin: 2 })).toEqual([]); - expect(proposeRegistrationFrames({ counts: [], bins: 5, perBin: 2 })).toEqual([]); - // More bins than frames: every frame proposed once. - const tiny = proposeRegistrationFrames({ counts: [3, 3], bins: 12, perBin: 2 }); - expect(tiny).toEqual([0, 1, 2]); + expect(proposeRegistrationFrames({ counts: [0, 10], count: 5 })).toEqual([]); + expect(proposeRegistrationFrames({ counts: [], count: 5 })).toEqual([]); + expect(proposeRegistrationFrames({ counts: [10, 10], count: 0 })).toEqual([]); + expect(proposeRegistrationFrames({ counts: [3, 3], count: 12 })).toEqual([0, 1, 2]); }); }); diff --git a/client/dive-common/autoRegisterSelection.ts b/client/dive-common/autoRegisterSelection.ts index 1d93124d7..c47ddc009 100644 --- a/client/dive-common/autoRegisterSelection.ts +++ b/client/dive-common/autoRegisterSelection.ts @@ -1,39 +1,12 @@ -/** - * Candidate-frame proposal for the auto-register pipeline (stage 0 of the - * selection contract): DIVE picks for diversity and synchronization, VIAME - * picks for image quality. - * - * Stratified, not "evenly spread" and not "most featureful": the flight is - * divided into `bins` equal time bins and `perBin` candidates are proposed - * within each, so temporal spread is guaranteed structurally -- a single - * scene-rich stretch can never supply every frame, which would reconstitute - * exactly the single-scene bias the multi-pair restructure exists to remove. - * The pipeline then keeps the best candidate per bin by image quality - * (`max_frames` is the bin count), so oversampling here is deliberate. - * - * Within a bin, candidates rank by inter-camera timestamp skew when every - * camera carries per-frame timestamps: on a survey aircraft at ~100 kt, - * 100 ms of desync is ~5 m of ground motion baked straight into the "ground - * truth" points, and RANSAC cannot reject it because it is consistent - * within the frame. Without timestamps (positional alignment), candidates - * spread evenly within the bin and skew is unknowable -- no threshold - * applies. - */ +/** Even frame spread for auto-register; skew filter because RANSAC can't reject desync. */ export interface ProposalOptions { /** Per-camera usable frame counts; the proposal spans [0, min(counts)). */ counts: number[]; - /** - * Optional per-camera per-frame capture timestamps (epoch seconds; - * undefined entries = unknown). Skew ranking applies only when every - * camera has a timestamp for the frame under consideration. - */ + /** Per-camera per-frame epoch seconds; frames missing any camera's stamp skip the filter. */ timestamps?: (number | undefined)[][]; - /** Number of temporal bins (the pipeline's max_frames budget). */ - bins: number; - /** Candidates proposed per bin. */ - perBin: number; - /** Candidates with a larger inter-camera skew (seconds) are dropped. */ + count: number; + /** Seconds; frames with larger inter-camera skew are excluded. */ maxSkewSeconds?: number; } @@ -53,56 +26,22 @@ function frameSkew( return Math.max(...stamps) - Math.min(...stamps); } -/** - * Propose candidate frame indices, sorted ascending. Returns at most - * `bins * perBin` frames; short datasets simply yield fewer. - */ export default function proposeRegistrationFrames(options: ProposalOptions): number[] { const usable = Math.min(...options.counts); - if (!Number.isFinite(usable) || usable <= 0 || options.bins <= 0 || options.perBin <= 0) { + if (!Number.isFinite(usable) || usable <= 0 || options.count <= 0) { return []; } - const bins = Math.min(options.bins, usable); const { timestamps } = options; const maxSkew = options.maxSkewSeconds ?? 0.5; - const chosen = new Set(); - for (let bin = 0; bin < bins; bin += 1) { - const start = Math.floor((bin * usable) / bins); - const end = Math.floor(((bin + 1) * usable) / bins); // exclusive - // bins <= usable, so every bin spans at least one frame. - const size = end - start; - const perBin = Math.min(options.perBin, size); - if (timestamps && timestamps.length) { - // Rank the whole bin by skew; unknowable-skew frames rank after - // measured ones (evenly spread among themselves), over-threshold - // frames are dropped outright. - const measured: { frame: number; skew: number }[] = []; - const unknowable: number[] = []; - for (let frame = start; frame < end; frame += 1) { - const skew = frameSkew(timestamps, frame); - if (skew === null) { - unknowable.push(frame); - } else if (skew <= maxSkew) { - measured.push({ frame, skew }); - } - } - measured.sort((a, b) => a.skew - b.skew || a.frame - b.frame); - measured.slice(0, perBin).forEach(({ frame }) => chosen.add(frame)); - let still = perBin - Math.min(measured.length, perBin); - if (still > 0 && unknowable.length) { - const step = unknowable.length / still; - for (let i = 0; i < still; i += 1) { - chosen.add(unknowable[Math.floor(i * step)]); - } - still = 0; - } - } else { - // No timeline: spread evenly within the bin. - const step = size / perBin; - for (let i = 0; i < perBin; i += 1) { - chosen.add(start + Math.floor(i * step + step / 2)); - } + const eligible: number[] = []; + for (let frame = 0; frame < usable; frame += 1) { + const skew = timestamps?.length ? frameSkew(timestamps, frame) : null; + if (skew === null || skew <= maxSkew) { + eligible.push(frame); } } - return [...chosen].sort((a, b) => a - b); + const count = Math.min(options.count, eligible.length); + // Centre each pick in its share so neither end is favoured. + const step = eligible.length / count; + return Array.from({ length: count }, (_, i) => eligible[Math.floor((i + 0.5) * step)]); } diff --git a/client/dive-common/components/CameraRegistration/AutoRegisterDialog.vue b/client/dive-common/components/CameraRegistration/AutoRegisterDialog.vue index 47f141e01..1f642399b 100644 --- a/client/dive-common/components/CameraRegistration/AutoRegisterDialog.vue +++ b/client/dive-common/components/CameraRegistration/AutoRegisterDialog.vue @@ -4,14 +4,7 @@ import { } from 'vue'; import type { AutoRegisterRunOptions } from 'dive-common/use/useAutoRegisterJob'; -/** - * Launch dialog for the auto-register pipeline: only the knobs worth - * changing. The candidate spread itself is proposed automatically - * (stratified time bins, ranked by camera sync where timestamps exist) and - * reviewed after the matching -- the matcher is the best measurement of - * whether a frame has usable dense features, and excluding a frame - * afterwards is a free client-side refit. - */ +/** Launch dialog for auto-register; frames are reviewed after matching, not before. */ export default defineComponent({ name: 'AutoRegisterDialog', props: { @@ -29,48 +22,29 @@ export default defineComponent({ }, }, setup(props, { emit }) { - const maxFrames = ref(12); - const candidatesPerBin = ref(2); + const frames = ref(12); const minInliers = ref(30); - const pairMode = ref<'all' | 'star'>('all'); const replaceExisting = ref(false); const isTriplet = computed(() => props.cameraCount >= 3); - const candidateTotal = computed(() => maxFrames.value * candidatesPerBin.value); - const matcherRuns = computed(() => { - let pairCount = 1; - if (isTriplet.value) { - pairCount = pairMode.value === 'all' ? 3 : 2; - } - return maxFrames.value * pairCount; - }); function close() { emit('input', false); } function run() { const options: AutoRegisterRunOptions = { - maxFrames: maxFrames.value, - candidatesPerBin: candidatesPerBin.value, + frames: frames.value, minInliers: minInliers.value, replaceExisting: replaceExisting.value, - // A star to the reference (inputs 1-2, 1-3) is the minimum a - // spanning tree needs; all-pairs additionally buys the - // loop-closure consistency check. - ...(isTriplet.value && pairMode.value === 'star' ? { pairs: '1-2,1-3' } : {}), }; emit('run', options); close(); } return { - maxFrames, - candidatesPerBin, + frames, minInliers, - pairMode, replaceExisting, isTriplet, - candidateTotal, - matcherRuns, close, run, }; @@ -88,16 +62,16 @@ export default defineComponent({ Auto Register Frames

- Proposes {{ candidateTotal }} candidate frames spread across the - whole {{ isTriplet ? 'rig' : 'sequence' }} (ranked by camera sync - where timestamps exist), matches the best one per time bin, and - pools one transform per camera pair over every kept frame. Results + Matches {{ frames }} frames spread evenly across the whole + {{ isTriplet ? 'rig' : 'sequence' }} (skipping captures whose cameras + are out of sync) and pools one transform per camera pair over every + frame that passes the matcher's checks. Results merge with existing points frame by frame; review and exclude frames afterwards from the panel's frame list.

- - - - - - { const total = cameras.value.length; const unresolvedCount = cameraAlignmentStatuses.value .filter((c) => c.status === 'unresolved').length; - const complete = unresolvedCount === 0; return { - icon: complete ? 'mdi-check-circle' : 'mdi-alert', - color: complete ? 'success' : 'warning', text: `${total - unresolvedCount}/${total} cameras ready`, }; }); @@ -210,12 +207,7 @@ export default defineComponent({ current: row.frame !== null && row.frame === currentPairFrame.value, })); }); - /** - * Skipped rows are candidates a producer rejected -- overwhelmingly - * "pruned", the oversampling remainder from proposing candidatesPerBin per - * bin and keeping the best. They carry no points and support no action, so - * they are counted, not listed. - */ + /** Rejected frames carry no points and support no action, so they are counted, not listed. */ const skippedCount = computed(() => frameRows.value.filter((row) => row.skipped).length); const listedRows = computed(() => frameRows.value.filter((row) => !row.skipped)); const autoRows = computed(() => listedRows.value.filter((row) => row.source !== MANUAL_SOURCE)); @@ -308,8 +300,7 @@ export default defineComponent({ } function runQueuedFrames() { autoRegisterJob?.run({ - maxFrames: queuedSlots.value.length, - candidatesPerBin: 1, + frames: queuedSlots.value.length, slots: [...queuedSlots.value], }); queuedSlots.value = []; @@ -418,8 +409,6 @@ export default defineComponent({ const canClearLast = computed( () => registration.pendingPoint.value !== null || correspondences.value.length > 0, ); - /** How many more correspondence pairs are needed before the transform can be fit. */ - const remainingPoints = computed(() => Math.max(0, minPoints.value - correspondences.value.length)); /** The active pair has a usable transform: enough points to fit one, or one loaded from a file. */ const hasTransform = computed(() => canFit.value || Boolean(activeKey.value && registration.homographies.value[activeKey.value])); @@ -454,24 +443,30 @@ export default defineComponent({ return { icon: 'mdi-file-check', color: 'success', - text: 'Transform loaded from a registration file', + text: 'Transform loaded from file', + hint: 'Linked pan/zoom and the overlay warp use the loaded transform. Fitting ' + + `${minPoints.value} or more picked point pairs replaces it.`, }; } if (canFit.value) { const stats = pairStats.value; const frames = stats ? stats.frameCount : 0; - const rms = stats && stats.rmsPx !== null ? ` — rms ${stats.rmsPx.toFixed(1)} px` : ''; + const rms = stats && stats.rmsPx !== null ? ` · rms ${stats.rmsPx.toFixed(1)} px` : ''; return { icon: 'mdi-check-circle', color: fitQualityColor.value, - text: `Transform fit from ${frames} frame${frames === 1 ? '' : 's'} / ` - + `${correspondences.value.length} point pairs${rms}`, + text: `Fit: ${frames} frame${frames === 1 ? '' : 's'} · ` + + `${correspondences.value.length} pairs${rms}`, + hint: 'Green with 12 or more point pairs; yellow when the transform can be fit ' + + 'but has few points to support it.', }; } return { icon: 'mdi-progress-clock', color: 'grey', - text: 'No transform yet: pick points below, or import a registration (Import menu)', + text: 'No transform yet', + hint: 'Auto Register, pick points with Edit points, or import a registration ' + + '(Import menu).', }; }); /** @@ -577,43 +572,47 @@ export default defineComponent({ return `${here} -> ${shortCameraLabel(other.camera)}: (${ox.toFixed(1)}, ${oy.toFixed(1)})`; }); - /** - * Persist the registration (all pairs) with the dataset: it is written as - * the project's per-camera _to__registration.json files and restored - * on every dataset load (so the Align button works across sessions). - * Deliberately not gated on the - * active pair having correspondences: saving must also be able to persist - * a cleared state (so stale saved registration doesn't survive Clear All / - * per-row deletes) and state belonging to non-active pairs. Portable - * copies for sharing come from the Export menu's per-camera registration - * downloads, which read this saved state. - * - * Overwriting an existing saved registration (e.g. one imported from a - * producer like KAMERA) is confirmed first, naming only the per-camera - * file(s) whose content this save actually changes -- pairs the user - * didn't touch are rewritten byte-identical, which isn't an overwrite - * worth warning about. - */ + const fileReference = computed( + () => alignedView.reference.value ?? cameras.value[0] ?? null, + ); + const pairLabel = computed(() => `${camLeft.value ?? 'A'} ↔ ${camRight.value ?? 'B'}`); + const pairDirty = computed( + () => (activeKey.value ? registration.pairDirty(activeKey.value) : false), + ); + const otherPairsDirty = computed( + () => (activeKey.value ? registration.dirtyOutsidePair(activeKey.value) : false), + ); + /** Falls back to the saved baseline because a deleted pair is only there. */ + const pairFileName = computed(() => { + const key = activeKey.value; + if (!key) { + return null; + } + const [left, right] = key.split('::'); + const holdsPair = (file: ReturnType[number]) => ( + file.body.pairs.some((pair) => pair.left === left && pair.right === right)); + const reference = fileReference.value; + const next = buildPerCameraRegistrationFiles(registration.valuesSavingPair(key), reference); + const saved = buildPerCameraRegistrationFiles(registration.savedRegistrationValues(), reference); + return (next.find(holdsPair) ?? saved.find(holdsPair))?.name ?? null; + }); + + /** Persist the selected pair's file, confirming first if it overwrites a changed saved file. */ async function save() { + const key = activeKey.value; + if (!key) { + return; + } // Fit before diffing so the comparison reflects what will be written. registration.maybeFitActivePair(); - // Group the saved baseline and the current state into per-camera files - // exactly the way the persistence layer writes them, against the same - // reference camera the backend uses (the dataset's Reference Camera - // choice, published by the viewer). - const reference = alignedView.reference.value ?? cameras.value[0] ?? null; + // Group into per-camera files exactly as the persistence layer does. const savedFiles = buildPerCameraRegistrationFiles( registration.savedRegistrationValues(), - reference, + fileReference.value, ); const nextFiles = new Map(buildPerCameraRegistrationFiles( - { - homographies: registration.homographies.value, - observations: registration.observations.value, - transformTypes: registration.transformTypes.value, - source: registration.source.value, - }, - reference, + registration.valuesSavingPair(key), + fileReference.value, ).map((file) => [file.name, file])); // Existing files this save replaces with different content (or removes, // for a cleared pair) -- the actual overwrites. @@ -641,27 +640,66 @@ export default defineComponent({ return; } } + await persistPair(key); + } + + /** Other pairs are sent at their saved state so their files and pending edits are kept. */ + async function persistPair(key: string) { + const values = registration.valuesSavingPair(key); saving.value = true; try { await saveConfig(datasetId.value, { - cameraHomographies: registration.homographies.value, - cameraCorrespondences: registration.observations.value, - cameraTransformTypes: registration.transformTypes.value, - cameraRegistrationSource: registration.source.value, + cameraHomographies: values.homographies, + cameraCorrespondences: values.observations, + cameraTransformTypes: values.transformTypes, + cameraRegistrationSource: values.source, }); - registration.markSaved(); + registration.markPairSaved(key); } finally { saving.value = false; } } - /** - * Auto Register Frames: launch the align_cameras pipeline over a - * stratified spread of candidate frames (one job registers the whole - * rig; a triplet solves up to three pairs at once). The service is - * provided by the viewer; availability tracks whether the align pipes - * are installed, which hides the button entirely when they aren't. - */ + const canDeletePair = computed(() => { + const key = activeKey.value; + if (!key) { + return false; + } + const saved = registration.savedRegistrationValues(); + return [ + registration.homographies.value, registration.observations.value, + registration.transformTypes.value, + saved.homographies, saved.observations, saved.transformTypes, + ].some((map) => key in map); + }); + + /** Disables the aligned view when no transform is left, so it can't re-enable itself later. */ + async function deletePair() { + const key = activeKey.value; + if (!key) { + return; + } + const file = pairFileName.value; + const confirmed = await prompt({ + title: `Delete ${pairLabel.value} Registration?`, + text: `Remove the ${pairLabel.value} points and transform` + + `${file ? `, and its entry in ${file}` : ''}? Other camera pairs are ` + + 'not affected. This cannot be undone.', + positiveButton: 'Delete', + negativeButton: 'Cancel', + confirm: true, + }); + if (!confirmed) { + return; + } + registration.deletePair(key); + if (!Object.keys(registration.homographies.value).length) { + alignedView.setEnabled(false); + } + await persistPair(key); + } + + /** Auto Register: one align_cameras job over evenly spread frames; hidden without the align pipes. */ const autoRegisterJob = useAutoRegisterJob(); const autoRegisterAvailable = computed(() => !!autoRegisterJob?.available.value); const autoRegistering = computed(() => !!autoRegisterJob?.running.value); @@ -669,6 +707,13 @@ export default defineComponent({ const autoRegisterStatus = computed(() => autoRegisterJob?.status.value ?? null); const autoRegisterDialog = ref(false); + const autoRegisterTooltip = computed(() => { + const pipe = autoRegisterJob?.pipe.value; + return pipe + ? `Auto Register: runs ${pipe.name} (${pipe.pipe})` + : 'Auto Register'; + }); + function openAutoRegisterDialog() { autoRegisterDialog.value = true; } @@ -797,10 +842,8 @@ export default defineComponent({ transformType, transformTypeItems: TRANSFORM_TYPES, minPoints, - remainingPoints, alignmentModeItems, hasTransform, - hasLoadedTransform, refinedFromSource, canClearPair, canClearLast, @@ -809,17 +852,23 @@ export default defineComponent({ transformStatus, setLinkedNav, linkedNav: registration.linkedNav, - dirty: registration.dirty, + pairLabel, + pairDirty, + otherPairsDirty, + pairFileName, saving, sourceReadout, setTransformType, setAlignmentMode, save, + canDeletePair, + deletePair, autoRegisterAvailable, autoRegistering, autoRegisterError, autoRegisterStatus, autoRegisterDialog, + autoRegisterTooltip, openAutoRegisterDialog, runAutoRegister, loopClosure, @@ -836,146 +885,167 @@ export default defineComponent({ ]" class="mx-4" > - - Register cameras by importing a registration file (Import menu) or by - picking corresponding points between two cameras. - - - - - Source: {{ sourceReadout }} - - - - This pair has been refined in-app since the source registration was - produced. - - - -
-
- {{ alignmentSummary.icon }} + {{ cam.status === 'reference' ? 'mdi-star' + : (cam.status === 'resolved' ? 'mdi-check' : 'mdi-alert-outline') }} - {{ alignmentSummary.text }} -
-
- + {{ cam.name }} + + + + @@ -1355,55 +1363,80 @@ export default defineComponent({ -

Overlay Warp

- - - + Overlay + - {{ item.text }} - - - - Warp Opacity + + {{ item.text }} + + +
- - + + + Writes {{ pairFileName }} + + + Other camera pairs have unsaved changes; select a pair to save it. + - {{ dirty ? 'Save registration' : 'Registration saved' }} + Delete {{ pairLabel }}
diff --git a/client/dive-common/use/useAutoRegisterJob.spec.ts b/client/dive-common/use/useAutoRegisterJob.spec.ts index 511b7574a..6d5d7aa74 100644 --- a/client/dive-common/use/useAutoRegisterJob.spec.ts +++ b/client/dive-common/use/useAutoRegisterJob.spec.ts @@ -104,16 +104,15 @@ describe('auto-register candidate selection', () => { expect(mispaired).toHaveLength(274); }); - it('sends one capture per candidate when a timeline is available', async () => { + it('sends one capture per frame when a timeline is available', async () => { const { service, sent } = buildService(slots); await service.refreshAvailability(); - await service.run({ maxFrames: 12, candidatesPerBin: 2 }); + await service.run({ frames: 12 }); const pairs = sent.imagePairs as Record; expect(Object.keys(pairs).sort()).toEqual(['ir', 'rgb', 'uv']); expect(pairs.rgb.length).toBeGreaterThan(0); pairs.rgb.forEach((name, i) => { - // Every camera in a candidate must be the same instant. expect(timestampOf('ir', pairs.ir[i])).toBe(timestampOf('rgb', name)); expect(timestampOf('uv', pairs.uv[i])).toBe(timestampOf('rgb', name)); }); @@ -122,7 +121,7 @@ describe('auto-register candidate selection', () => { it('never proposes a capture that is missing on some camera', async () => { const { service, sent } = buildService(slots); await service.refreshAvailability(); - await service.run({ maxFrames: 24, candidatesPerBin: 4 }); + await service.run({ frames: 24 }); const pairs = sent.imagePairs as Record; CAMERAS.forEach((camera) => { @@ -136,30 +135,16 @@ describe('auto-register candidate selection', () => { }); }); - /** - * Without the timeline this dataset produces nothing at all: candidates are - * ranked by inter-camera skew read off raw local indices, every index is a - * full 1s cadence out, and the 0.5s gate drops all of them. That is the - * pre-fix behavior a user hit -- "No candidate frames could be proposed" on - * a flight whose captures are in fact 271-deep. - */ it('proposes nothing without a timeline, and never launches', async () => { const { service, sent } = buildService(null); await service.refreshAvailability(); - await service.run({ maxFrames: 12, candidatesPerBin: 2 }); + await service.run({ frames: 12 }); expect(sent.imagePairs).toBeUndefined(); - expect(service.error.value).toMatch(/No candidate frames/); + expect(service.error.value).toMatch(/No frames could be proposed/); }); }); -/** - * Queued frames: the user has already chosen the captures, so the stratified - * proposal (temporal bins, per-bin oversampling, skew ranking) does not apply. - * Every queued capture is matched; only captures a camera is missing get - * dropped, because the pipeline reads image lists off disk and a gap has no - * path to send. - */ describe('running an explicitly queued frame set', () => { const timeline = buildAlignedTimeline(IMAGES); const { slots } = (timeline as { aligned: true; slots: AlignedSlot[] }); @@ -168,7 +153,7 @@ describe('running an explicitly queued frame set', () => { const { service, sent } = buildService(slots); await service.refreshAvailability(); await service.run({ - maxFrames: 3, candidatesPerBin: 1, slots: [140, 141, 200], + frames: 3, slots: [140, 141, 200], }); const pairs = sent.imagePairs as Record; @@ -182,15 +167,15 @@ describe('running an explicitly queued frame set', () => { }); }); - it('gives the pipeline one bin per queued frame so none are pruned', async () => { + it('matches every queued frame regardless of the frames setting', async () => { const { service, sent } = buildService(slots); await service.refreshAvailability(); await service.run({ - maxFrames: 99, candidatesPerBin: 7, slots: [140, 141, 200], + frames: 1, slots: [140, 141, 200], }); - // maxFrames/candidatesPerBin are the proposal's knobs and must not leak in. - expect(sent.kwiverParams?.['register:max_frames']).toBe('3'); + expect((sent.imagePairs as Record).rgb).toHaveLength(3); + expect(sent.kwiverParams?.['register:max_frames']).toBeUndefined(); }); it('drops queued captures a camera is missing', async () => { @@ -198,7 +183,7 @@ describe('running an explicitly queued frame set', () => { await service.refreshAvailability(); // Slot 0 is RGB-only; 93 has no RGB. Only 140 is registerable. await service.run({ - maxFrames: 3, candidatesPerBin: 1, slots: [0, 93, 140], + frames: 3, slots: [0, 93, 140], }); const pairs = sent.imagePairs as Record; @@ -209,7 +194,7 @@ describe('running an explicitly queued frame set', () => { it('errors rather than launching when no queued capture is registerable', async () => { const { service, sent } = buildService(slots); await service.refreshAvailability(); - await service.run({ maxFrames: 2, candidatesPerBin: 1, slots: [0, 93] }); + await service.run({ frames: 2, slots: [0, 93] }); expect(sent.imagePairs).toBeUndefined(); expect(service.error.value).toMatch(/None of the queued frames/); @@ -270,7 +255,7 @@ describe('replaceExisting and the unsaved-edits baseline', () => { it('re-baselines a clean store so its own removals do not read as edits', async () => { const { service, removed, calls } = buildStoreService(false); await service.refreshAvailability(); - await service.run({ maxFrames: 6, candidatesPerBin: 2, replaceExisting: true }); + await service.run({ frames: 6, replaceExisting: true }); // Only the matcher's own observations are dropped; hand picks survive. expect(removed).toEqual(['a.jpg:minima_loftr']); @@ -280,7 +265,7 @@ describe('replaceExisting and the unsaved-edits baseline', () => { it('leaves a genuinely dirty store dirty', async () => { const { service, calls } = buildStoreService(true); await service.refreshAvailability(); - await service.run({ maxFrames: 6, candidatesPerBin: 2, replaceExisting: true }); + await service.run({ frames: 6, replaceExisting: true }); expect(calls.markSaved).toBe(0); }); @@ -288,7 +273,7 @@ describe('replaceExisting and the unsaved-edits baseline', () => { it('does not touch the baseline when not replacing', async () => { const { service, removed, calls } = buildStoreService(false); await service.refreshAvailability(); - await service.run({ maxFrames: 6, candidatesPerBin: 2 }); + await service.run({ frames: 6 }); expect(removed).toEqual([]); expect(calls.markSaved).toBe(0); @@ -341,7 +326,7 @@ describe('launching from saved state', () => { // them no way to save. const { service, order } = buildOrderedService(); await service.refreshAvailability(); - await service.run({ maxFrames: 6, candidatesPerBin: 2 }); + await service.run({ frames: 6 }); expect(order).toEqual(['save', 'baseline', 'launch']); }); @@ -351,7 +336,7 @@ describe('launching from saved state', () => { // register as the job's first result and end the run immediately. const { service, order } = buildOrderedService(); await service.refreshAvailability(); - await service.run({ maxFrames: 6, candidatesPerBin: 2 }); + await service.run({ frames: 6 }); expect(order.indexOf('save')).toBeLessThan(order.indexOf('baseline')); }); @@ -395,7 +380,7 @@ describe('status while the completion confirm is open', () => { }, }); await service.refreshAvailability(); - const settled = service.run({ maxFrames: 6, candidatesPerBin: 2 }); + const settled = service.run({ frames: 6 }); // Launch settles, then the 5s poll tick fires. await vi.advanceTimersByTimeAsync(0); @@ -468,7 +453,7 @@ describe('completion by job state', () => { // The dataset already holds exactly what this run produced. const { service, hydrated } = buildJobService({ ok: true }, {}); await service.refreshAvailability(); - await service.run({ maxFrames: 6, candidatesPerBin: 2 }); + await service.run({ frames: 6 }); expect(service.running.value).toBe(false); expect(service.error.value).toBeNull(); @@ -479,7 +464,7 @@ describe('completion by job state', () => { it('adopts results when the registration did change', async () => { const { service, hydrated } = buildJobService({ ok: true }, { 'rgb::ir': [1] }); await service.refreshAvailability(); - await service.run({ maxFrames: 6, candidatesPerBin: 2 }); + await service.run({ frames: 6 }); expect(service.status.value).toMatch(/complete: review the registration frames/); expect(hydrated).toHaveLength(1); @@ -491,7 +476,7 @@ describe('completion by job state', () => { {}, ); await service.refreshAvailability(); - await service.run({ maxFrames: 6, candidatesPerBin: 2 }); + await service.run({ frames: 6 }); expect(service.running.value).toBe(false); expect(service.status.value).toBeNull(); @@ -499,14 +484,7 @@ describe('completion by job state', () => { }); }); -/** - * What the run achieved, not merely that it ended. - * - * Fixture shape is a real 3-cam job over flat sea ice (ice_seals fl01): the - * matcher prefiltered all 14 candidates as low_texture, so each pair carries 14 - * disabled observations with a skip reason and no transform came out. Before - * this, the panel reported that exactly like a clean fit. - */ +/** Fixture mirrors a real 3-cam sea-ice job where every frame was rejected. */ describe('reporting what a finished run produced', () => { const timeline = buildAlignedTimeline(IMAGES); const { slots } = (timeline as { aligned: true; slots: AlignedSlot[] }); @@ -522,7 +500,7 @@ describe('reporting what a finished run produced', () => { source: 'minima_loftr', enabled: i >= skipped, points: [], - ...(i < skipped ? { stats: { skipped: 'low_texture', textureScore: 1.95 } } : {}), + ...(i < skipped ? { stats: { skipped: 'insufficient_matches' } } : {}), })), ])); } @@ -565,11 +543,11 @@ describe('reporting what a finished run produced', () => { cameraHomographies: {}, }); await service.refreshAvailability(); - await service.run({ maxFrames: 6, candidatesPerBin: 2 }); + await service.run({ frames: 6 }); expect(service.status.value).toBeNull(); expect(service.error.value).toMatch(/fitted no camera pairs/); - expect(service.error.value).toMatch(/low_texture/); + expect(service.error.value).toMatch(/insufficient_matches/); }); it('counts rejected frames per pair rather than summing across the rig', async () => { @@ -580,9 +558,9 @@ describe('reporting what a finished run produced', () => { cameraHomographies: {}, }); await service.refreshAvailability(); - await service.run({ maxFrames: 6, candidatesPerBin: 2 }); + await service.run({ frames: 6 }); - expect(service.error.value).toMatch(/14 low_texture/); + expect(service.error.value).toMatch(/14 insufficient_matches/); expect(service.error.value).not.toMatch(/42/); }); @@ -592,7 +570,7 @@ describe('reporting what a finished run produced', () => { cameraHomographies: {}, }, true); await service.refreshAvailability(); - await service.run({ maxFrames: 6, candidatesPerBin: 2 }); + await service.run({ frames: 6 }); expect(service.error.value).toMatch(/fitted no camera pairs/); // Not the "already up to date" line the unchanged-merge branch used to give. @@ -605,11 +583,11 @@ describe('reporting what a finished run produced', () => { cameraHomographies: { 'rgb::ir': { AtoB: [], BtoA: [] } }, }); await service.refreshAvailability(); - await service.run({ maxFrames: 6, candidatesPerBin: 2 }); + await service.run({ frames: 6 }); expect(service.error.value).toBeNull(); expect(service.status.value).toMatch(/1 of 3 pair\(s\) fitted/); - expect(service.status.value).toMatch(/2 low_texture rejected/); + expect(service.status.value).toMatch(/2 insufficient_matches rejected/); }); it('leaves a clean run reading exactly as it did before', async () => { @@ -620,7 +598,7 @@ describe('reporting what a finished run produced', () => { ), }); await service.refreshAvailability(); - await service.run({ maxFrames: 6, candidatesPerBin: 2 }); + await service.run({ frames: 6 }); expect(service.error.value).toBeNull(); expect(service.status.value).toBe( diff --git a/client/dive-common/use/useAutoRegisterJob.ts b/client/dive-common/use/useAutoRegisterJob.ts index e3b4f091e..e5bab4c84 100644 --- a/client/dive-common/use/useAutoRegisterJob.ts +++ b/client/dive-common/use/useAutoRegisterJob.ts @@ -5,21 +5,7 @@ import type CameraRegistrationStore from 'vue-media-annotator/alignedView/Camera import proposeRegistrationFrames from 'dive-common/autoRegisterSelection'; import type { AlignedSlot } from 'dive-common/alignedTimeline'; -/** - * Auto-register job bridge for the Camera Registration panel. - * - * Replaces the old single-frame interactive-service bridge: registration is - * now computed from MANY image pairs by the `utility_align_cameras_{2,3}-cam` - * pipeline (one job per rig; a triplet registers in one job). The panel - * calls {@link AutoRegisterJobService.run} with the knobs worth changing; - * the service proposes a stratified candidate spread (DIVE picks for - * diversity and synchronization; the VIAME process picks for image quality - * within each temporal bin), launches the pipeline with the frame subset, - * and refreshes the registration store when the job's output lands in the - * dataset. Availability is "is the align pipe in the pipeline list" -- the - * add-on packaging makes pipe present <=> weights present by construction, - * so no separate weights probe exists (and none could work on web). - */ +/** Auto-register job bridge; availability is pipe presence, which implies weights. */ /** Minimal pipe shape from the pipeline list (see apispec Pipe). */ export interface AlignPipe { @@ -29,15 +15,7 @@ export interface AlignPipe { } export interface AutoRegisterRunOptions { - /** Candidate frames proposed per temporal bin (oversampling factor). */ - candidatesPerBin: number; - /** Temporal bins == the pipeline's max_frames budget. */ - maxFrames: number; - /** - * Camera pairs for a triplet as 1-based input indices ("1-2,1-3,2-3"). - * Undefined = all pairs (the pipe default). - */ - pairs?: string; + frames: number; /** Per-frame minimum-inlier gate override. */ minInliers?: number; /** @@ -46,19 +24,15 @@ export interface AutoRegisterRunOptions { * merging over them frame by frame. */ replaceExisting?: boolean; - /** - * Explicit global aligned-timeline slots to match, bypassing the stratified - * proposal. Used by "queue these frames and run": the user has already - * chosen the captures, so temporal spread and per-bin oversampling do not - * apply -- every queued capture is matched, and only captures missing a - * frame on some camera are dropped. maxFrames/candidatesPerBin are ignored. - */ + /** Queued aligned-timeline slots to match instead of the even spread; `frames` is ignored. */ slots?: number[]; } export interface AutoRegisterJobService { /** Whether the align pipeline is installed (reactive; resolves after mount). */ available: Readonly>; + /** Null when not installed. */ + pipe: Readonly>; running: Readonly>; /** Progress/status line for the panel, or null when idle. */ status: Readonly>; @@ -72,7 +46,7 @@ export interface AutoRegisterJobDeps { /** Rig cameras in display order (the job is rig-wide). */ cameras: Ref; frameCount(camera: string): number; - /** Per-frame capture timestamps for skew ranking, or null when unknown. */ + /** Per-frame capture timestamps for the skew filter, or null when unknown. */ timestampsFor(camera: string): (number | undefined)[] | null; /** * The dataset's aligned timeline (alignedTimeline.ts), or null when the @@ -153,19 +127,7 @@ export function createAutoRegisterJobService(deps: AutoRegisterJobDeps): AutoReg skipped: Record; } - /** - * Summarize the run from the merged registration. - * - * The pipeline already records why it discarded a candidate -- stats.skipped, - * e.g. low_texture over flat ice or open water -- and DIVE persists that per - * observation, but nothing read it back: a run that rejected every frame and - * fitted nothing reported the same "complete" as one that fitted the whole - * rig, leaving the reason visible only in the job log. - * - * Reason counts are per pair rather than summed. Every pair sees the same - * candidate spread, so summing would report a 14-frame run as 42 rejections - * on a triplet. - */ + /** Skip counts take the worst pair, not the sum: every pair sees the same frames. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function summarizeRun(meta: any): RunSummary { const homographies = meta?.cameraHomographies ?? {}; @@ -195,7 +157,7 @@ export function createAutoRegisterJobService(deps: AutoRegisterJobDeps): AutoReg return summary; } - /** "14 low_texture, 2 low_overlap", commonest first; empty when nothing was rejected. */ + /** "14 insufficient_matches, 2 low_confidence", commonest first. */ function describeSkips(skipped: Record): string { return Object.entries(skipped) .sort(([, a], [, b]) => b - a) @@ -214,7 +176,7 @@ export function createAutoRegisterJobService(deps: AutoRegisterJobDeps): AutoReg if (summary.pairs > 0 && summary.fitted === 0) { status.value = null; error.value = skips - ? 'Auto Register fitted no camera pairs: every candidate frame was rejected ' + ? 'Auto Register fitted no camera pairs: every frame was rejected ' + `(${skips}). Try frames with more visible structure.` : 'Auto Register fitted no camera pairs; see the job log for details.'; return; @@ -399,17 +361,16 @@ export function createAutoRegisterJobService(deps: AutoRegisterJobDeps): AutoReg ? cameras.map((camera, i) => slots.map((slot) => lists[i][slot[camera] as number])) : lists; })(), - bins: options.maxFrames, - perBin: options.candidatesPerBin, + count: options.frames, }); if (frames.length === 0) { throw new Error(aligned - ? 'No candidate frames could be proposed: no capture has a frame on every camera.' - : 'No candidate frames could be proposed for this dataset.'); + ? 'No frames could be proposed: no capture has a frame on every camera.' + : 'No frames could be proposed for this dataset.'); } status.value = queued ? `Preparing ${frames.length} queued frame(s)…` - : `Proposing ${frames.length} candidate frames…`; + : `Preparing ${frames.length} frame(s)…`; const imagePairs: Record = {}; // eslint-disable-next-line no-restricted-syntax for (const camera of cameras) { @@ -442,16 +403,7 @@ export function createAutoRegisterJobService(deps: AutoRegisterJobDeps): AutoReg registration.markSaved(); } } - const kwiverParams: Record = { - // max_frames is the pipeline's bin budget: it keeps the best candidate - // per bin and prunes the rest. A queued run has already chosen its - // captures, so give it one bin per frame or it would prune them back - // down to the proposal's budget. - 'register:max_frames': String(queued ? frames.length : options.maxFrames), - }; - if (options.pairs) { - kwiverParams['register:pairs'] = options.pairs; - } + const kwiverParams: Record = {}; if (options.minInliers !== undefined) { kwiverParams['register:min_inliers'] = String(options.minInliers); } @@ -495,6 +447,7 @@ export function createAutoRegisterJobService(deps: AutoRegisterJobDeps): AutoReg return { available: computed(() => alignPipe.value !== null), + pipe: computed(() => alignPipe.value), running, status, error, diff --git a/client/platform/web-girder/api/waitForFolderDatasetReady.spec.ts b/client/platform/web-girder/api/waitForFolderDatasetReady.spec.ts new file mode 100644 index 000000000..03424f081 --- /dev/null +++ b/client/platform/web-girder/api/waitForFolderDatasetReady.spec.ts @@ -0,0 +1,49 @@ +import { + beforeEach, describe, expect, it, vi, +} from 'vitest'; + +import girderRest from 'platform/web-girder/plugins/girder'; +import { getFolder } from './girder.service'; +import { waitForFolderDatasetReady } from './waitForFolderDatasetReady'; + +vi.mock('platform/web-girder/plugins/girder', () => ({ + default: { get: vi.fn() }, +})); +vi.mock('./girder.service', () => ({ + getFolder: vi.fn(), + getItemsInFolder: vi.fn(), +})); + +const RUNNING = 2; +const SUCCESS = 3; + +describe('waitForFolderDatasetReady', () => { + beforeEach(() => { + vi.mocked(girderRest.get).mockReset(); + vi.mocked(getFolder).mockReset(); + }); + + it('reports summed item counts across jobs, not just a fraction', async () => { + vi.mocked(girderRest.get).mockImplementation(async (url: string) => ({ + data: url === 'job/a' + ? { status: SUCCESS, progress: { current: 900, total: 900 } } + : { status: RUNNING, progress: { current: 100, total: 300 } }, + })); + let polls = 0; + vi.mocked(getFolder).mockImplementation(async () => { + polls += 1; + return { data: { meta: { annotate: polls > 1 } } } as never; + }); + const onProgress = vi.fn(); + + await waitForFolderDatasetReady( + 'folder', + { pollIntervalMs: 1, timeoutMs: 1000, onProgress }, + ['a', 'b'], + ); + expect(onProgress).toHaveBeenCalledWith( + expect.closeTo((1 + 1 / 3) / 2, 5), + { current: 1000, total: 1200 }, + ); + }); +}); diff --git a/client/platform/web-girder/api/waitForFolderDatasetReady.ts b/client/platform/web-girder/api/waitForFolderDatasetReady.ts index 45843d8db..ffd757baf 100644 --- a/client/platform/web-girder/api/waitForFolderDatasetReady.ts +++ b/client/platform/web-girder/api/waitForFolderDatasetReady.ts @@ -62,8 +62,8 @@ export async function waitForFolderDatasetReady( options?: { pollIntervalMs?: number; timeoutMs?: number; - /** Called with average job completion fraction in [0, 1] when jobs report progress. */ - onProgress?: (fraction: number) => void; + /** Called with the average job completion fraction in [0, 1] and summed item counts. */ + onProgress?: (fraction: number, counts: { current: number; total: number }) => void; /** When true, keep polling until at least one web-safe image exists in the folder. */ requireViewableImages?: boolean; /** When true, keep polling until at least one tiled/large-image file exists in the folder. */ @@ -115,7 +115,14 @@ export async function waitForFolderDatasetReady( (sum, job) => sum + job.progress!.current / job.progress!.total, 0, ) / jobsWithProgress.length; - options.onProgress(fraction); + const counts = jobsWithProgress.reduce( + (sum, job) => ({ + current: sum.current + job.progress!.current, + total: sum.total + job.progress!.total, + }), + { current: 0, total: 0 }, + ); + options.onProgress(fraction, counts); } } const allTerminal = jobs.every((job) => TERMINAL_JOB_STATUSES.includes(job.status)); diff --git a/client/platform/web-girder/views/Upload.vue b/client/platform/web-girder/views/Upload.vue index 42cbf4db2..244379885 100644 --- a/client/platform/web-girder/views/Upload.vue +++ b/client/platform/web-girder/views/Upload.vue @@ -109,6 +109,8 @@ export interface PendingUpload { interface GirderUpload { formatSize: (a: number) => string; + /** The fileUploader mixin's current batch, one entry per file. */ + files: { status: string }[]; totalProgress: number; totalProgressPercent: number; totalSize: number; @@ -134,6 +136,12 @@ const MULTICAM_CAMERA_UPLOAD_WEIGHT = 0.72; interface MulticamImportProgress { percent: number; message: string; + /** Concrete count for the current step, e.g. "812 / 2,955 files uploaded". */ + detail?: string; +} + +function countDetail(current: number, total: number, noun: string): string { + return `${current.toLocaleString()} / ${total.toLocaleString()} ${noun}`; } function multicamCameraSlotPercent( @@ -237,10 +245,11 @@ export default defineComponent({ } }; - const setMulticamImportProgress = (percent: number, message: string) => { + const setMulticamImportProgress = (percent: number, message: string, detail?: string) => { multicamImportProgress.value = { percent: Math.max(0, Math.min(100, Math.round(percent))), message, + detail, }; }; @@ -252,6 +261,8 @@ export default defineComponent({ clearMulticamUploadProgressTimer(); multicamUploadProgressTimer = setInterval(() => { const uploadPct = girderUpload.value?.totalProgressPercent ?? 0; + const files = girderUpload.value?.files ?? []; + const done = files.filter((file) => file.status === 'done').length; setMulticamImportProgress( multicamCameraSlotPercent( cameraIndex, @@ -259,6 +270,8 @@ export default defineComponent({ (uploadPct / 100) * MULTICAM_CAMERA_UPLOAD_WEIGHT, ), `Uploading ${cameraName} (${cameraIndex + 1} of ${totalCameras})`, + // The mixin empties its file list once post-processing starts. + files.length ? countDetail(done, files.length, 'files uploaded') : undefined, ); }, 250); }; @@ -583,7 +596,7 @@ export default defineComponent({ ); // eslint-disable-next-line no-await-in-loop -- finalize only after post-process marks folder as a dataset await waitForFolderDatasetReady(folder._id, { - onProgress: (fraction) => { + onProgress: (fraction, counts) => { const processShare = 1 - MULTICAM_CAMERA_UPLOAD_WEIGHT; setMulticamImportProgress( multicamCameraSlotPercent( @@ -592,6 +605,7 @@ export default defineComponent({ MULTICAM_CAMERA_UPLOAD_WEIGHT + fraction * processShare, ), `${labelPrefix}Processing ${cameraName} (${i + 1} of ${totalCameras})`, + countDetail(counts.current, counts.total, 'images processed'), ); }, requireViewableImages: uploadType === ImageSequenceType, @@ -1000,9 +1014,12 @@ export default defineComponent({ />
- {{ multicamImportProgress.percent }}% + + {{ multicamImportProgress.percent }}% overall
diff --git a/client/src/alignedView/CameraRegistrationStore.spec.ts b/client/src/alignedView/CameraRegistrationStore.spec.ts index a89cec922..536add683 100644 --- a/client/src/alignedView/CameraRegistrationStore.spec.ts +++ b/client/src/alignedView/CameraRegistrationStore.spec.ts @@ -251,6 +251,57 @@ describe('CameraRegistrationStore', () => { }); } + /** left<->right and left<->third, each with four fitted translation pairs. */ + function registerTwoPairs(store: CameraRegistrationStore) { + const pts: [number, number][] = [[0, 0], [10, 0], [10, 10], [0, 10]]; + [['right', 5], ['third', 7]].forEach(([camera, dx]) => { + store.setActivePair('left', camera as string); + pts.forEach((p) => { + store.addPoint('left', p); + store.addPoint(camera as string, [p[0] + (dx as number), p[1] - 3]); + }); + store.fitTransform(store.pairKey('left', camera as string)); + }); + } + + describe('pair-scoped save', () => { + it('saving one pair leaves the other pairs dirty', () => { + const store = new CameraRegistrationStore(); + const lr = store.pairKey('left', 'right'); + const lt = store.pairKey('left', 'third'); + registerTwoPairs(store); + expect(store.pairDirty(lr)).toBe(true); + expect(store.pairDirty(lt)).toBe(true); + + // Reverse order, so baseline key order differs from the live maps'. + store.markPairSaved(lt); + expect(store.pairDirty(lt)).toBe(false); + expect(store.pairDirty(lr)).toBe(true); + expect(store.dirtyOutsidePair(lt)).toBe(true); + expect(store.dirty.value).toBe(true); + + store.markPairSaved(lr); + expect(store.dirty.value).toBe(false); + }); + + it('writes the other pairs at their saved state, not their unsaved edits', () => { + const store = new CameraRegistrationStore(); + const lr = store.pairKey('left', 'right'); + const lt = store.pairKey('left', 'third'); + registerTwoPairs(store); + store.markSaved(); + store.setActivePair('left', 'third'); + store.addPoint('left', [20, 20]); + store.addPoint('third', [27, 17]); + + const values = store.valuesSavingPair(lr); + expect(values.observations[lt] + .reduce((sum, obs) => sum + obs.points.length, 0)).toBe(4); + expect(store.pairDirty(lt)).toBe(true); + expect(store.pairDirty(lr)).toBe(false); + }); + }); + it('fits when enabling alignment mode with >= 4 pairs', () => { const store = new CameraRegistrationStore(); store.setActivePair('left', 'right'); @@ -985,6 +1036,22 @@ describe('CameraRegistrationStore', () => { expect(store.homographies.value[key]).toBeUndefined(); }); + it('deletePair removes one pair entirely and leaves the others', () => { + const store = new CameraRegistrationStore(); + const lr = store.pairKey('left', 'right'); + const lt = store.pairKey('left', 'third'); + registerTwoPairs(store); + store.setActivePair('left', 'right'); + store.setAlignmentMode('AtoB'); + store.deletePair(lr); + expect(lr in store.observations.value).toBe(false); + expect(lr in store.homographies.value).toBe(false); + expect(lr in store.transformTypes.value).toBe(false); + expect(store.alignment.value.mode).toBe('original'); + expect(pointsFor(store, lt)).toHaveLength(4); + expect(store.homographies.value[lt]).toBeDefined(); + }); + it('rejects a singular loaded matrix', () => { const store = new CameraRegistrationStore(); expect(() => loadMatrixOnlyPair(store, 'left', 'right', [[0, 0, 0], [0, 0, 0], [0, 0, 0]])) diff --git a/client/src/alignedView/CameraRegistrationStore.ts b/client/src/alignedView/CameraRegistrationStore.ts index dd033f80b..f17306f6c 100644 --- a/client/src/alignedView/CameraRegistrationStore.ts +++ b/client/src/alignedView/CameraRegistrationStore.ts @@ -43,14 +43,7 @@ export interface Correspondence extends CorrespondencePoint { source: string; } -/** - * Free-form per-observation quality statistics, written by a producer (the - * align_cameras pipeline reports numMatches / numInliers / inlierRatio / - * rmsPx / coverage / textureScore, and `skipped` with a machine-readable - * reason for rejected candidates). Never interpreted structurally by the - * store -- preserved verbatim through round trips and surfaced by the - * review UI. - */ +/** Free-form producer stats (incl. `skipped` reason); preserved verbatim, never interpreted. */ export type ObservationStats = Record; /** @@ -224,6 +217,14 @@ function pseudoImageName(frame: number): string { return `frame://${frame}`; } +/** Key-sorted JSON, so string snapshot comparison ignores map key order. */ +function canonicalJson(value: unknown): string { + return JSON.stringify(value, (_key, item) => ( + item && typeof item === 'object' && !Array.isArray(item) + ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b))) + : item)); +} + /** * Shared, reactive store for camera-registration data (per-image-pair * correspondence observations, fitted/loaded homographies, transform-type @@ -347,7 +348,7 @@ export default class CameraRegistrationStore { /** Serialize the saved-to-dataset calibration state (observations, transforms, provenance). */ private registrationSnapshot(): string { - return JSON.stringify({ + return canonicalJson({ homographies: this.homographies.value, observations: this.observations.value, transformTypes: this.transformTypes.value, @@ -388,6 +389,72 @@ export default class CameraRegistrationStore { || Object.values(saved.observations).some((list) => list.length > 0); } + private static pairState( + values: { + homographies: CameraHomographies; + observations: CameraObservations; + transformTypes: CameraTransformTypes; + }, + key: string, + ): string { + return canonicalJson({ + homography: values.homographies[key] ?? null, + // A cleared pair keeps an empty list. + observations: values.observations[key] ?? [], + transformType: values.transformTypes[key] ?? null, + }); + } + + pairDirty(key: string): boolean { + const current = { + homographies: this.homographies.value, + observations: this.observations.value, + transformTypes: this.transformTypes.value, + }; + return CameraRegistrationStore.pairState(current, key) + !== CameraRegistrationStore.pairState(this.savedRegistrationValues(), key); + } + + /** Saved baseline with only this pair replaced; other unsaved edits stay pending. */ + valuesSavingPair(key: string): { + homographies: CameraHomographies; + observations: CameraObservations; + transformTypes: CameraTransformTypes; + source: RegistrationSource | null; + } { + const saved = JSON.parse(this.savedSnapshot.value); + const current = JSON.parse(canonicalJson({ + homography: this.homographies.value[key], + observations: this.observations.value[key], + transformType: this.transformTypes.value[key], + source: this.source.value, + })); + function withPair(map: Record, value: T | undefined): Record { + const next = { ...map }; + if (value === undefined) { + delete next[key]; + } else { + next[key] = value; + } + return next; + } + return { + homographies: withPair(saved.homographies, current.homography), + observations: withPair(saved.observations, current.observations), + transformTypes: withPair(saved.transformTypes, current.transformType), + source: current.source ?? null, + }; + } + + /** Rebaselines only this pair so other pairs' edits stay dirty. */ + markPairSaved(key: string) { + this.savedSnapshot.value = canonicalJson(this.valuesSavingPair(key)); + } + + dirtyOutsidePair(key: string): boolean { + return canonicalJson(this.valuesSavingPair(key)) !== this.registrationSnapshot(); + } + /** * True when the loaded calibration was assembled from per-camera files * whose producer stamps disagree (the loader records that as a @@ -861,6 +928,26 @@ export default class CameraRegistrationStore { this.maybeFitPair(key); } + /** Unlike clearPair, removes the keys so saving drops the pair from its file. */ + deletePair(key: string) { + function without(map: Record): Record { + const next = { ...map }; + delete next[key]; + return next; + } + this.observations.value = without(this.observations.value); + this.homographies.value = without(this.homographies.value); + this.transformTypes.value = without(this.transformTypes.value); + delete this.homographySources[key]; + this.pendingPoint.value = null; + this.selectedCorrespondenceId.value = null; + this.fitError.value = null; + // With its transform gone the pair can no longer be warped. + if (key === this.activePairKey()) { + this.alignment.value = { ...this.alignment.value, mode: 'original' }; + } + } + /** * Undo one step, mirroring keypointgui's Clear Last button: if there's a * pending (blue) point, drop it; otherwise remove the most recently