Skip to content

Commit 5bfd57f

Browse files
Fix paired stereo review in the web interface (#1984)
* Fix web stereo review selection and camera configuration * Review complete stereo sequences when selecting web camera folders * prevent showing sub cameras as their own datasets in the picker --------- Co-authored-by: Bryon Lewis <Bryon.Lewis@kitware.com>
1 parent 18cb766 commit 5bfd57f

15 files changed

Lines changed: 578 additions & 25 deletions

‎client/dive-common/apispec.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,11 @@ interface Api {
480480
* dataset list the review page offers.
481481
*/
482482
listScoringDatasets?(): Promise<ScoringDatasetSummary[]>;
483+
/** Resolve a selected camera to its whole sequence before loading review. */
484+
resolveReviewDatasetId?(datasetId: string): Promise<string>;
485+
/** Review includes whole stereo/multicamera sequences, unlike scoring. */
486+
listReviewDatasets?(): Promise<ScoringDatasetSummary[]>;
487+
pickReviewDataset?(excludeIds: string[]): Promise<ScoringDatasetSummary | null>;
483488
/**
484489
* Open a platform dataset picker; returns null when the user cancels.
485490
* Shared by the scoring and review pages.

‎client/dive-common/components/Review/ReviewDatasetsPanel.vue‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ export default defineComponent({
1616
const api = useApi();
1717
const review = useReview();
1818
const picking = ref(false);
19-
const usePicker = computed(() => typeof api.pickScoringDataset === 'function');
19+
const pickDataset = api.pickReviewDataset ?? api.pickScoringDataset;
20+
const usePicker = computed(() => typeof pickDataset === 'function');
2021
2122
const selectedIds = computed(() => review.datasets.value.map((d) => d.id));
2223
@@ -35,10 +36,10 @@ export default defineComponent({
3536
}
3637
3738
async function openPicker() {
38-
if (!api.pickScoringDataset || picking.value) return;
39+
if (!pickDataset || picking.value) return;
3940
picking.value = true;
4041
try {
41-
const picked = await api.pickScoringDataset(selectedIds.value);
42+
const picked = await pickDataset(selectedIds.value);
4243
if (picked) await review.addDataset(picked.id, picked, { defer: true });
4344
} finally {
4445
picking.value = false;

‎client/dive-common/use/useReview.spec.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ function makeApi(tracksById: Record<string, TrackData[]>, overrides: Partial<Rev
5353
}
5454

5555
describe('createReviewService', () => {
56+
it('uses the review-specific dataset list when a platform separates it from scoring', async () => {
57+
const listReviewDatasets = vi.fn(async () => [{ id: 'rig', name: 'Stereo', type: 'multi' }]);
58+
const api = makeApi({}, { listReviewDatasets });
59+
const service = createReviewService({ api });
60+
await service.refreshAvailable();
61+
expect(service.available.value).toEqual([{ id: 'rig', name: 'Stereo', type: 'multi' }]);
62+
expect(api.listScoringDatasets).not.toHaveBeenCalled();
63+
service.dispose();
64+
});
65+
5666
it('uses the web tracks-only reader without loading unused annotation data', async () => {
5767
const loadReviewTracks = vi.fn(async () => [track(1, [['fish', 0.9]], [0])]);
5868
const api = makeApi({}, { loadReviewTracks });
@@ -91,6 +101,40 @@ describe('createReviewService', () => {
91101
expect(api.loadDetections).toHaveBeenCalledTimes(1);
92102
});
93103

104+
it('queues the resolved parent for a deferred camera pick and skips duplicates', async () => {
105+
const resolveReviewDatasetId = vi.fn(async (id: string) => (id === 'leftFolder' ? 'rig' : id));
106+
const api = makeApi({
107+
'rig/left': [track(1, [['fish', 1]], [0])],
108+
'rig/right': [track(1, [['fish', 1]], [0])],
109+
}, {
110+
resolveReviewDatasetId,
111+
loadConfig: vi.fn(async (id: string) => (id === 'rig'
112+
? config('rig', {
113+
type: 'multi',
114+
name: 'Stereo',
115+
multiCamMedia: {
116+
defaultDisplay: 'left',
117+
cameras: {
118+
left: { type: 'image-sequence', imageData: [{ url: 'l.jpg', filename: 'l.jpg' }], videoUrl: '' },
119+
right: { type: 'image-sequence', imageData: [{ url: 'r.jpg', filename: 'r.jpg' }], videoUrl: '' },
120+
},
121+
},
122+
})
123+
: config(id))),
124+
});
125+
const service = createReviewService({ api });
126+
await service.addDataset('leftFolder', { id: 'leftFolder', name: 'left' }, { defer: true });
127+
expect(resolveReviewDatasetId).toHaveBeenCalledWith('leftFolder');
128+
expect(service.datasets.value).toMatchObject([{ id: 'rig', status: 'queued' }]);
129+
expect(api.loadConfig).not.toHaveBeenCalled();
130+
await service.loadQueued();
131+
expect(service.datasets.value).toMatchObject([{ id: 'rig', status: 'ready' }]);
132+
// Deferred browse of a camera folder must not sit beside the loaded rig.
133+
await service.addDataset('leftFolder', { id: 'leftFolder', name: 'left' }, { defer: true });
134+
expect(service.datasets.value).toEqual([expect.objectContaining({ id: 'rig', status: 'ready' })]);
135+
service.dispose();
136+
});
137+
94138
it('loads datasets, prefers peekConfig, and builds items for a query', async () => {
95139
const peekConfig = vi.fn(async (id: string) => config(id));
96140
const api = makeApi({

‎client/dive-common/use/useReview.ts‎

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { orderedMultiCamCameraNames } from 'dive-common/multicamDisplay';
12
import { orderedHeadTail } from 'vue-media-annotator/headTail';
23
/**
34
* State behind the Review page: the datasets under review (with their
@@ -47,7 +48,8 @@ export interface ReviewGeometryEdit {
4748

4849
export type ReviewApi = Pick<Api,
4950
'loadConfig' | 'peekConfig' | 'loadDetections' | 'loadReviewTracks' | 'saveDetections'
50-
| 'listScoringDatasets' | 'pickScoringDataset'>;
51+
| 'listScoringDatasets' | 'pickScoringDataset' | 'listReviewDatasets' | 'pickReviewDataset'
52+
| 'resolveReviewDatasetId'>;
5153

5254
export interface ReviewServiceDeps {
5355
api: ReviewApi;
@@ -322,9 +324,10 @@ function createScopedReviewService(deps: ReviewServiceDeps): ReviewService {
322324
}
323325

324326
async function refreshAvailable() {
325-
if (!api.listScoringDatasets) return;
327+
const listDatasets = api.listReviewDatasets ?? api.listScoringDatasets;
328+
if (!listDatasets) return;
326329
try {
327-
const result = await requests.run(() => api.listScoringDatasets!());
330+
const result = await requests.run(() => listDatasets());
328331
if (!disposed) available.value = result;
329332
} catch (err) {
330333
fail(err, 'Could not list datasets');
@@ -365,14 +368,28 @@ function createScopedReviewService(deps: ReviewServiceDeps): ReviewService {
365368
const isCurrent = () => loadTokens.get(id) === token && !!entry(id);
366369
loading.value = true;
367370
try {
371+
// Normalize user selections, but keep expanded cameras separate internally
372+
// so media, annotations, and writes continue using their own folders.
373+
if (api.resolveReviewDatasetId && !memberships.has(id)) {
374+
const resolvedId = await requests.run(() => api.resolveReviewDatasetId!(id));
375+
if (!isCurrent()) return;
376+
if (resolvedId !== id) {
377+
datasets.value = datasets.value.filter((d) => d.id !== id);
378+
await addDataset(resolvedId);
379+
return;
380+
}
381+
}
368382
const config = await requests.run(() => {
369383
if (!isCurrent()) throw new Error('Dataset removed');
370384
return loadConfig(id);
371385
});
372386
if (!isCurrent()) return;
373387
if (config.type === 'multi') {
374388
// Load each camera separately while exposing the parent as one selected sequence.
375-
const cameras = Object.keys(config.multiCamMedia?.cameras || {});
389+
const cameras = [...new Set([
390+
...orderedMultiCamCameraNames(config.multiCamMedia),
391+
...Object.keys(config.multiCamMedia?.cameras || {}),
392+
])];
376393
const parentName = entry(id)?.name || config.name;
377394
if (!cameras.length) throw new Error('This sequence has no cameras');
378395
parentNames.set(id, parentName);
@@ -429,20 +446,36 @@ function createScopedReviewService(deps: ReviewServiceDeps): ReviewService {
429446
/**
430447
* Add a dataset; with `defer` it only joins the list and loads on the
431448
* next `loadQueued`, so picking many datasets costs nothing until the
432-
* results are actually wanted.
449+
* results are actually wanted. Deferred picks still resolve camera folders
450+
* to their sequence so a browse pick cannot sit beside an already-loaded rig.
433451
*/
434452
async function addDataset(id: string, summary?: ScoringDatasetSummary, options: { defer?: boolean } = {}) {
435453
if (disposed || !id || entry(id) || selectedDatasets.value.some((dataset) => dataset.id === id)) return;
454+
let selectedId = id;
455+
let selectedSummary = summary;
456+
if (options.defer && api.resolveReviewDatasetId) {
457+
try {
458+
const resolvedId = await api.resolveReviewDatasetId(id);
459+
if (resolvedId !== id) {
460+
selectedId = resolvedId;
461+
// Drop the camera-folder summary; the parent owns the sequence name.
462+
selectedSummary = undefined;
463+
}
464+
} catch {
465+
// Keep the original id; load() will surface the error.
466+
}
467+
}
468+
if (entry(selectedId) || selectedDatasets.value.some((dataset) => dataset.id === selectedId)) return;
436469
datasets.value = [...datasets.value, {
437-
id,
438-
name: summary?.name || datasetName(id),
439-
type: summary?.type,
470+
id: selectedId,
471+
name: selectedSummary?.name || datasetName(selectedId),
472+
type: selectedSummary?.type,
440473
status: options.defer ? 'queued' : 'loading',
441474
trackCount: 0,
442475
croppable: false,
443476
}];
444477
if (options.defer) return;
445-
await load(id);
478+
await load(selectedId);
446479
}
447480

448481
/** Load every queued dataset; annotations are read and the query rerun as each arrives. */

‎client/platform/web-girder/App.vue‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,12 @@ import {
5050
deleteScoringResult,
5151
listScoringSources,
5252
listScoringDatasets,
53+
listReviewDatasets,
54+
resolveReviewDatasetId,
5355
saveScoringExport,
5456
exportScoringPdf,
5557
pickScoringDataset,
58+
pickReviewDataset,
5659
} from './api';
5760
import ScoringDatasetPickerDialog from './components/ScoringDatasetPickerDialog.vue';
5861
import {
@@ -127,7 +130,10 @@ export default defineComponent({
127130
deleteScoringResult,
128131
listScoringSources: unwrap(listScoringSources),
129132
listScoringDatasets,
133+
listReviewDatasets,
134+
resolveReviewDatasetId,
130135
pickScoringDataset,
136+
pickReviewDataset,
131137
saveScoringExport,
132138
exportScoringPdf,
133139
});

‎client/platform/web-girder/api/dataset.service.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,23 @@ function mergeDatasetConfig(
107107
*/
108108
async function loadDatasetConfig(datasetId: string): Promise<GirderConfig> {
109109
const { compositeId } = await resolveDatasetFolderId(datasetId);
110-
const [metaStatic, media] = await Promise.all([
110+
const [metaStatic, media, parentConfig] = await Promise.all([
111111
getDataset(datasetId),
112112
getDatasetMedia(datasetId),
113+
compositeId
114+
? girderRest.get<DatasetConfigMutable>(`dive_dataset/${parentDatasetId(datasetId)}/configuration`)
115+
: Promise.resolve(null),
113116
]);
114-
return mergeDatasetConfig(metaStatic.data, media.data, compositeId);
117+
const config = mergeDatasetConfig(metaStatic.data, media.data, compositeId);
118+
if (parentConfig) {
119+
// The parent owns the shared hierarchy. In particular, an absent parent
120+
// hierarchy must not revive obsolete edges stored on a camera folder.
121+
config.typeHierarchy = parentConfig.data.typeHierarchy;
122+
config.customTypeStyling = {
123+
...config.customTypeStyling, ...parentConfig.data.customTypeStyling,
124+
};
125+
}
126+
return config;
115127
}
116128

117129
function clone({

‎client/platform/web-girder/api/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export * from './girder.service';
1313
export * from './multicamResolve';
1414
export * from './rpc.service';
1515
export * from './scoring.service';
16-
export { pickScoringDataset } from './scoringDatasetPicker';
16+
export { pickScoringDataset, pickReviewDataset } from './scoringDatasetPicker';
1717
export * from './waitForFolderDatasetReady';
1818
export { default as watchPipelineJob, watchScoringJob } from './watchPipelineJob';
1919
export * from './largeImage.service';

‎client/platform/web-girder/api/multicamResolve.spec.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
clearMultiCamMetaCache,
1212
parseCompositeDatasetId,
1313
resolveDatasetFolderId,
14+
resolveReviewDatasetId,
1415
} from './multicamResolve';
1516

1617
describe('multicamResolve', () => {
@@ -75,4 +76,32 @@ describe('multicamResolve', () => {
7576
'Unknown camera "missing"',
7677
);
7778
});
79+
80+
it('keeps a standalone dataset under an ordinary folder', async () => {
81+
vi.spyOn(girderRest, 'get')
82+
.mockResolvedValueOnce({ data: { parentId: 'ordinary', parentCollection: 'folder' } } as never)
83+
.mockResolvedValueOnce({ data: { meta: {} } } as never);
84+
expect(await resolveReviewDatasetId('standalone')).toBe('standalone');
85+
});
86+
87+
it('does not include unrelated datasets nested beneath a stereo parent', async () => {
88+
vi.spyOn(girderRest, 'get')
89+
.mockResolvedValueOnce({ data: { parentId: 'rig', parentCollection: 'folder' } } as never)
90+
.mockResolvedValueOnce({
91+
data: {
92+
meta: {
93+
type: 'multi', multiCam: { cameras: { left: { folderId: 'left' } } },
94+
},
95+
},
96+
} as never);
97+
expect(await resolveReviewDatasetId('unrelated')).toBe('unrelated');
98+
});
99+
100+
it('does not resolve a collection id as a folder', async () => {
101+
const get = vi.spyOn(girderRest, 'get').mockResolvedValueOnce({
102+
data: { parentId: 'collection', parentCollection: 'collection' },
103+
} as never);
104+
expect(await resolveReviewDatasetId('standalone')).toBe('standalone');
105+
expect(get).toHaveBeenCalledTimes(1);
106+
});
78107
});

‎client/platform/web-girder/api/multicamResolve.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,25 @@ export function clearMultiCamMetaCache(parentId?: string): void {
5656
multiCamMetaCache.clear();
5757
}
5858
}
59+
60+
/** Review a whole rig even when entered from a camera folder or viewer link. */
61+
export async function resolveReviewDatasetId(datasetId: string): Promise<string> {
62+
const { parentId, cameraName } = parseCompositeDatasetId(datasetId);
63+
if (cameraName) return parentId;
64+
const { data: folder } = await girderRest.get<{
65+
parentId?: string;
66+
parentCollection?: string;
67+
meta?: { type?: string };
68+
}>(`folder/${datasetId}`);
69+
if (folder.meta?.type === 'multi' || folder.parentCollection !== 'folder' || !folder.parentId) {
70+
return datasetId;
71+
}
72+
const { data: parent } = await girderRest.get<{
73+
meta?: { type?: string; multiCam?: MultiCamStorageMeta };
74+
}>(`folder/${folder.parentId}`);
75+
const cameras = parent.meta?.multiCam?.cameras;
76+
// Only registered cameras belong to the sequence.
77+
return parent.meta?.type === 'multi'
78+
&& Object.values(cameras ?? {}).some((camera) => camera.folderId === datasetId)
79+
? folder.parentId : datasetId;
80+
}

0 commit comments

Comments
 (0)