Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client/dive-common/components/Viewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,7 @@ export default defineComponent({
watchJob: watchPipelineJob,
loadMetadata: loadConfig,
registration: cameraRegistration,
saveRegistration,
confirmReload: () => prompt({
title: 'Auto Register Finished',
text: 'The auto-register job finished, but this registration has '
Expand Down
191 changes: 191 additions & 0 deletions client/dive-common/use/useAutoRegisterJob.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ function buildService(slots: AlignedSlot[] | null) {
observations: ref({}),
dirty: ref(false),
} as unknown as CameraRegistrationStore,
saveRegistration: async () => undefined,
confirmReload: async () => true,
});
return { service, sent };
Expand Down Expand Up @@ -260,6 +261,7 @@ describe('replaceExisting and the unsaved-edits baseline', () => {
runPipeline: async () => { throw new Error('stop-after-launch'); },
loadMetadata: async () => ({ cameraCorrespondences: {} }),
registration,
saveRegistration: async () => undefined,
confirmReload: async () => true,
});
return { service, removed, calls };
Expand Down Expand Up @@ -298,6 +300,63 @@ describe('replaceExisting and the unsaved-edits baseline', () => {
* it waits, the panel must not keep claiming the job is still matching frames
* -- that reads as a hung job when the work is already done and merged.
*/
describe('launching from saved state', () => {
const timeline = buildAlignedTimeline(IMAGES);
const { slots } = (timeline as { aligned: true; slots: AlignedSlot[] });

/** Records the order of the save, the baseline read and the launch. */
function buildOrderedService() {
const order: string[] = [];
const service = createAutoRegisterJobService({
datasetId: ref('ds1'),
cameras: ref(CAMERAS),
frameCount: (camera: string) => IMAGES[camera].length,
timestampsFor: (camera: string) => IMAGES[camera].map((frame) => frame.timestamp),
alignedSlots: () => slots,
resolveImagePaths: async (camera: string, frames: number[]) => (
frames.map((n) => IMAGES[camera][n].filename)
),
getPipelineList: async () => ({ utility: { pipes: [ALIGN_PIPE] } }),
runPipeline: async () => {
order.push('launch');
throw new Error('stop-after-launch');
},
loadMetadata: async () => {
order.push('baseline');
return { cameraCorrespondences: {} };
},
registration: {
observations: ref({}),
dirty: ref(true),
} as unknown as CameraRegistrationStore,
saveRegistration: async () => { order.push('save'); },
confirmReload: async () => true,
});
return { service, order };
}

it('saves the panel edits before the job exists', async () => {
// The status line sends the user to the Jobs tab, and the viewer's
// navigation guard would stop them over edits the read-only dataset gives
// them no way to save.
const { service, order } = buildOrderedService();
await service.refreshAvailability();
await service.run({ maxFrames: 6, candidatesPerBin: 2 });

expect(order).toEqual(['save', 'baseline', 'launch']);
});

it('reads the completion baseline after the save, not before', async () => {
// Reading first would capture pre-save meta, so the save's own write would
// 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 });

expect(order.indexOf('save')).toBeLessThan(order.indexOf('baseline'));
});
});

describe('status while the completion confirm is open', () => {
const timeline = buildAlignedTimeline(IMAGES);
const { slots } = (timeline as { aligned: true; slots: AlignedSlot[] });
Expand Down Expand Up @@ -329,6 +388,7 @@ describe('status while the completion confirm is open', () => {
observations: ref({}),
dirty: ref(true),
} as unknown as CameraRegistrationStore,
saveRegistration: async () => undefined,
confirmReload: () => {
confirmOpened = true;
return new Promise<boolean>((resolve) => { resolveConfirm = resolve; });
Expand Down Expand Up @@ -398,6 +458,7 @@ describe('completion by job state', () => {
hydrate: (...args: unknown[]) => { hydrated.push(args); },
setActivePair: () => undefined,
} as unknown as CameraRegistrationStore,
saveRegistration: async () => undefined,
confirmReload: async () => true,
});
return { service, hydrated };
Expand Down Expand Up @@ -437,3 +498,133 @@ describe('completion by job state', () => {
expect(service.error.value).toMatch(/exited with code 1/);
});
});

/**
* 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.
*/
describe('reporting what a finished run produced', () => {
const timeline = buildAlignedTimeline(IMAGES);
const { slots } = (timeline as { aligned: true; slots: AlignedSlot[] });
const PAIR_KEYS = ['rgb::ir', 'rgb::uv', 'ir::uv'];

/** `count` matcher observations for each pair, `skipped` of them rejected. */
function correspondences(count: number, skipped: number) {
return Object.fromEntries(PAIR_KEYS.map((key) => [
key,
Array.from({ length: count }, (_unused, i) => ({
imageA: `a${i}.jpg`,
imageB: `b${i}.png`,
source: 'minima_loftr',
enabled: i >= skipped,
points: [],
...(i < skipped ? { stats: { skipped: 'low_texture', textureScore: 1.95 } } : {}),
})),
]));
}

function buildService(meta: Record<string, unknown>, unchanged = false) {
let loads = 0;
const service = createAutoRegisterJobService({
datasetId: ref('ds1'),
cameras: ref(CAMERAS),
frameCount: (camera: string) => IMAGES[camera].length,
timestampsFor: (camera: string) => IMAGES[camera].map((frame) => frame.timestamp),
alignedSlots: () => slots,
resolveImagePaths: async (camera: string, frames: number[]) => (
frames.map((n) => IMAGES[camera][n].filename)
),
getPipelineList: async () => ({ utility: { pipes: [ALIGN_PIPE] } }),
runPipeline: async () => undefined,
watchJob: async () => ({ ok: true }),
loadMetadata: async () => {
loads += 1;
// Pre-launch baseline first, unless the run is meant to change nothing.
return loads > 1 || unchanged ? meta : { cameraCorrespondences: {} };
},
registration: {
observations: ref({}),
dirty: ref(false),
activePair: ref(null),
hydrate: () => undefined,
setActivePair: () => undefined,
} as unknown as CameraRegistrationStore,
saveRegistration: async () => undefined,
confirmReload: async () => true,
});
return service;
}

it('reports a total rejection as a failure, with the reason', async () => {
const service = buildService({
cameraCorrespondences: correspondences(14, 14),
cameraHomographies: {},
});
await service.refreshAvailability();
await service.run({ maxFrames: 6, candidatesPerBin: 2 });

expect(service.status.value).toBeNull();
expect(service.error.value).toMatch(/fitted no camera pairs/);
expect(service.error.value).toMatch(/low_texture/);
});

it('counts rejected frames per pair rather than summing across the rig', async () => {
// 14 frames on a triplet is 42 observations; reporting 42 would misstate
// how much of the flight the matcher actually looked at.
const service = buildService({
cameraCorrespondences: correspondences(14, 14),
cameraHomographies: {},
});
await service.refreshAvailability();
await service.run({ maxFrames: 6, candidatesPerBin: 2 });

expect(service.error.value).toMatch(/14 low_texture/);
expect(service.error.value).not.toMatch(/42/);
});

it('still reports failure when a deterministic re-run changes nothing', async () => {
const service = buildService({
cameraCorrespondences: correspondences(14, 14),
cameraHomographies: {},
}, true);
await service.refreshAvailability();
await service.run({ maxFrames: 6, candidatesPerBin: 2 });

expect(service.error.value).toMatch(/fitted no camera pairs/);
// Not the "already up to date" line the unchanged-merge branch used to give.
expect(service.status.value).toBeNull();
});

it('names the rejected frames on a partial success without calling it a failure', async () => {
const service = buildService({
cameraCorrespondences: correspondences(14, 2),
cameraHomographies: { 'rgb::ir': { AtoB: [], BtoA: [] } },
});
await service.refreshAvailability();
await service.run({ maxFrames: 6, candidatesPerBin: 2 });

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/);
});

it('leaves a clean run reading exactly as it did before', async () => {
const service = buildService({
cameraCorrespondences: correspondences(14, 0),
cameraHomographies: Object.fromEntries(
PAIR_KEYS.map((key) => [key, { AtoB: [], BtoA: [] }]),
),
});
await service.refreshAvailability();
await service.run({ maxFrames: 6, candidatesPerBin: 2 });

expect(service.error.value).toBeNull();
expect(service.status.value).toBe(
'Auto Register complete: review the registration frames below.',
);
});
});
116 changes: 113 additions & 3 deletions client/dive-common/use/useAutoRegisterJob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ export interface AutoRegisterJobDeps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
loadMetadata(datasetId: string): Promise<any>;
registration: CameraRegistrationStore;
/**
* Persist the panel's unsaved registration edits -- the same write its Save
* button does, and a no-op when the store is clean. Run before the job is
* launched: see the call site for why the job cannot be started over unsaved
* state.
*/
saveRegistration(): Promise<void>;
/** Confirm replacing unsaved in-app edits with the job's result. */
confirmReload(): Promise<boolean>;
}
Expand Down Expand Up @@ -136,6 +143,94 @@ export function createAutoRegisterJobService(deps: AutoRegisterJobDeps): AutoReg
}
}

/** What a finished run left behind, read back off the persisted registration. */
interface RunSummary {
/** Camera pairs the matcher produced observations for. */
pairs: number;
/** Of those, the ones that came out with a transform. */
fitted: number;
/** Rejected-frame counts, keyed by the producer's reason. */
skipped: Record<string, number>;
}

/**
* 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.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function summarizeRun(meta: any): RunSummary {
const homographies = meta?.cameraHomographies ?? {};
const correspondences = meta?.cameraCorrespondences ?? {};
const summary: RunSummary = { pairs: 0, fitted: 0, skipped: {} };
Object.entries(correspondences).forEach(([key, rows]) => {
const matcher = (Array.isArray(rows) ? rows : [])
.filter((obs) => obs?.source === MATCHER_SOURCE);
if (!matcher.length) {
return;
}
summary.pairs += 1;
if (homographies[key]) {
summary.fitted += 1;
}
const perPair: Record<string, number> = {};
matcher.forEach((obs) => {
const reason = obs?.stats?.skipped;
if (obs?.enabled === false && typeof reason === 'string') {
perPair[reason] = (perPair[reason] ?? 0) + 1;
}
});
Object.entries(perPair).forEach(([reason, count]) => {
summary.skipped[reason] = Math.max(summary.skipped[reason] ?? 0, count);
});
});
return summary;
}

/** "14 low_texture, 2 low_overlap", commonest first; empty when nothing was rejected. */
function describeSkips(skipped: Record<string, number>): string {
return Object.entries(skipped)
.sort(([, a], [, b]) => b - a)
.map(([reason, count]) => `${count} ${reason}`)
.join(', ');
}

/**
* Say what the run actually achieved. A run that fitted nothing is a failure
* however cleanly the job exited, so it reports through `error` -- including
* on a re-run, where the matcher is deterministic and the merge changes
* nothing (`unchanged`), which otherwise reads as "already up to date".
*/
function reportOutcome(summary: RunSummary, unchanged = false) {
const skips = describeSkips(summary.skipped);
if (summary.pairs > 0 && summary.fitted === 0) {
status.value = null;
error.value = skips
? 'Auto Register fitted no camera pairs: every candidate frame was rejected '
+ `(${skips}). Try frames with more visible structure.`
: 'Auto Register fitted no camera pairs; see the job log for details.';
return;
}
if (unchanged) {
status.value = 'Auto Register complete: the results matched the registration already stored.';
return;
}
// "N of M fitted" rather than "fitted N of M": a pair may carry a transform
// this run had no hand in, and claiming it would misreport a partial run.
status.value = skips
? `Auto Register complete: ${summary.fitted} of ${summary.pairs} pair(s) fitted, `
+ `${skips} rejected. Review the registration frames below.`
: 'Auto Register complete: review the registration frames below.';
}

/** Re-hydrate the store from freshly persisted meta, keeping the panel's pair. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function rehydrate(meta: any) {
Expand Down Expand Up @@ -172,7 +267,7 @@ export function createAutoRegisterJobService(deps: AutoRegisterJobDeps): AutoReg
}
}
rehydrate(meta);
status.value = 'Auto Register complete: review the registration frames below.';
reportOutcome(summarizeRun(meta));
return true;
}

Expand Down Expand Up @@ -203,8 +298,9 @@ export function createAutoRegisterJobService(deps: AutoRegisterJobDeps): AutoReg
}
if (JSON.stringify(meta.cameraCorrespondences ?? {}) === baseline) {
// Same frames, same weights, same points: the merge was a no-op. Say so
// instead of implying the run did nothing at all.
status.value = 'Auto Register complete: the results matched the registration already stored.';
// instead of implying the run did nothing at all -- unless nothing was
// fitted, in which case the run failed the same way it did last time.
reportOutcome(summarizeRun(meta), true);
return;
}
await adoptResult(meta);
Expand Down Expand Up @@ -359,6 +455,20 @@ export function createAutoRegisterJobService(deps: AutoRegisterJobDeps): AutoReg
if (options.minInliers !== undefined) {
kwiverParams['register:min_inliers'] = String(options.minInliers);
}
// Launch from saved state, for two independent reasons.
// Navigation: the status line below sends the user to the Jobs tab, but
// the viewer's guard stops them leaving with unsaved registration edits,
// and the dataset is read-only for the job's duration -- so the prompt
// would offer only "discard" for work they cannot save.
// Correctness: the job's output is merged into the SAVED registration
// (server-side ingest, or the desktop collector), and the result is then
// rehydrated over the store. Replace mode's local removal of prior
// matcher observations would be undone by that reload unless it is
// persisted first.
status.value = 'Saving registration edits…';
await deps.saveRegistration();
// Read the baseline only after the save, or the save itself would look
// like the job's first result.
const meta = await deps.loadMetadata(deps.datasetId.value);
const baseline = JSON.stringify(meta.cameraCorrespondences ?? {});
// Queueing the job is not the same as the job starting: video frames are
Expand Down
Loading
Loading