Skip to content
Draft
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
33 changes: 31 additions & 2 deletions client/dive-common/components/Viewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import {
Track, Group,
CameraStore,
formatDivergentClassificationWarning,
CameraRegistrationStore,
AlignedViewStore,
StyleManager, TrackFilterControls, GroupFilterControls,
Expand Down Expand Up @@ -647,13 +648,24 @@ export default defineComponent({
cameraStore.setTrackType(id, newType, confidenceVal, currentType);
};
const removeTypes = (id: AnnotationId, types: string[]) => cameraStore.removeTypes(id, types);
const setGroupType = (
id: AnnotationId,
newType: string,
confidenceVal?: number,
currentType?: string,
) => {
cameraStore.setGroupType(id, newType, confidenceVal, currentType);
};
const removeGroupTypes = (id: AnnotationId, types: string[]) => (
cameraStore.removeGroupTypes(id, types)
);
const getTrackProjection = (id: AnnotationId) => cameraStore.getTrackProjection(id);
const groupFilters = new GroupFilterControls({
sorted: cameraStore.sortedGroups,
markChangesPending: (markChangesPending as MarkChangesPendingFilter),
remove: removeGroups,
setType: setTrackType,
removeTypes,
setType: setGroupType,
removeTypes: removeGroupTypes,
});

// This context for removal
Expand Down Expand Up @@ -1533,6 +1545,7 @@ export default defineComponent({
multiCamList.value = ['singleCam'];
resetMulticamAlignment();
}
cameraStore.setCameraOrder(multiCamList.value);
/* Otherwise, complete loading of the dataset */
/**
* When shared colors are enabled, overlay the cross-dataset styles on
Expand Down Expand Up @@ -1781,6 +1794,22 @@ export default defineComponent({
removeSaveCamera(key);
}
});
if (multiCamList.value.length > 1 && props.comparisonSets.length === 0) {
const divergenceWarning = formatDivergentClassificationWarning(
cameraStore.divergentClassificationTrackIds(),
);
if (divergenceWarning) {
trackFilters.queueLoadWarning(divergenceWarning);
const loadWarning = trackFilters.consumeLoadWarning();
if (loadWarning) {
await prompt({
title: 'Divergent Track Classifications',
text: loadWarning,
positiveButton: 'OK',
});
}
}
}
// Needs to be done after the cameraMap is created
if (meta.attributeTrackFilters) {
trackFilters.loadTrackAttributesFilter(Object.values(meta.attributeTrackFilters));
Expand Down
46 changes: 46 additions & 0 deletions client/dive-common/typeHierarchy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from 'fs-extra';
import {
acceptPairAsCorrect,
compileHierarchy,
mergePairs,
normalizeTypeHierarchy,
reassignPairs,
removePair,
Expand Down Expand Up @@ -247,3 +248,48 @@ describe('type hierarchy index', () => {
});
});
});

describe('pair merging', () => {
const cases: [Array<[string, number][]>, [string, number][]][] = [
[
[[['fish', 0.7], ['shark', 1.0]], [['fish', 0.9], ['bird', 0.4]]],
[['shark', 1.0], ['fish', 0.9], ['bird', 0.4]],
],
[
[[['bird', 0.4], ['fish', 0.9]], [['shark', 1.0], ['fish', 0.7]]],
[['shark', 1.0], ['fish', 0.9], ['bird', 0.4]],
],
];

it.each(cases)(
'unions names and keeps the maximum duplicate score independent of input order',
(inputs, expected) => {
expect(mergePairs(inputs)).toEqual(expected);
},
);

it('uses deterministic type order for equal scores', () => {
expect(mergePairs([
[['tern', 0.8]],
[['cod', 0.8]],
])).toEqual([
['cod', 0.8],
['tern', 0.8],
]);
});

it('returns independent arrays and tuples without changing its inputs', () => {
const first: [string, number][] = [['fish', 0.7]];
const second: [string, number][] = [['shark', 1.0]];
const before = [first.map((pair) => [...pair]), second.map((pair) => [...pair])];

const result = mergePairs([first, second]);

expect([first, second]).toEqual(before);
expect(result).not.toBe(first);
result.forEach((pair) => {
expect(first).not.toContain(pair);
expect(second).not.toContain(pair);
});
});
});
18 changes: 18 additions & 0 deletions client/dive-common/typeHierarchy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,24 @@ export function removePair(
.map(([pairType, confidence]) => [pairType, confidence]);
}

// Track merge combines stored evidence without invoking assignment or acceptance behavior.
// Ties use code-point order so the result does not depend on track or camera iteration order.
export function mergePairs(
pairLists: readonly (readonly (readonly [string, number])[])[],
): [string, number][] {
const confidenceByType = new Map<string, number>();
pairLists.forEach((pairs) => {
pairs.forEach(([type, confidence]) => {
const current = confidenceByType.get(type);
if (current === undefined || confidence > current) {
confidenceByType.set(type, confidence);
}
});
});
return Array.from(confidenceByType.entries())
.sort((left, right) => (right[1] - left[1]) || codePointCompare(left[0], right[0]));
}

export function selectPairIndex(
index: TypeHierarchyIndex,
pairs: readonly (readonly [string, number])[],
Expand Down
109 changes: 107 additions & 2 deletions client/dive-common/use/useModeManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { IDENTITY3 } from 'vue-media-annotator/alignedView/alignedView';
import type { Matrix3 } from 'vue-media-annotator/alignedView/homography';
import type { AggregateMediaController } from 'vue-media-annotator/components/annotators/mediaControllerType';
import type { AnnotationId } from 'vue-media-annotator/BaseAnnotation';
import type { MarkChangesPending } from 'vue-media-annotator/BaseAnnotationStore';
import Track from 'vue-media-annotator/track';
import { ROTATION_ATTRIBUTE_NAME } from 'vue-media-annotator/utils';
import useModeManager from './useModeManager';
Expand All @@ -21,8 +22,8 @@ function translation(tx: number, ty: number): Matrix3 {
return [[1, 0, tx], [0, 1, ty], [0, 0, 1]];
}

function makeHarness() {
const cameraStore = new CameraStore({ markChangesPending: () => undefined });
function makeHarness(markChangesPending: MarkChangesPending = () => undefined) {
const cameraStore = new CameraStore({ markChangesPending });
cameraStore.removeCamera('singleCam');
cameraStore.addCamera('left');
cameraStore.addCamera('right');
Expand Down Expand Up @@ -236,6 +237,110 @@ describe('useModeManager counterpart creation', () => {
});
});

describe('useModeManager multicamera merge', () => {
it('canonicalizes every target and source replica before removing sources', () => {
const changes: string[] = [];
const { cameraStore, modeManager } = makeHarness((change) => {
changes.push(`${change.action}:${change.track?.id}`);
});
const leftStore = cameraStore.camMap.value.get('left')?.trackStore;
const rightStore = cameraStore.camMap.value.get('right')?.trackStore;
leftStore?.insert(new Track(1, {
confidencePairs: [['fish', 0.4]],
features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }],
}), { imported: true });
leftStore?.insert(Track.fromJSON({
id: 2,
begin: 1,
end: 1,
attributes: {},
confidencePairs: [['fish', 0.7]],
features: [{ frame: 1, bounds: [1, 1, 2, 2], keyframe: true }],
}), { imported: true });
leftStore?.insert(Track.fromJSON({
id: 3,
begin: 2,
end: 2,
attributes: {},
confidencePairs: [['turtle', 0.8]],
features: [{ frame: 2, bounds: [2, 2, 3, 3], keyframe: true }],
}), { imported: true });
rightStore?.insert(new Track(1, {
confidencePairs: [['rock', 0.6]],
features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }],
}), { imported: true });
rightStore?.insert(Track.fromJSON({
id: 2,
begin: 1,
end: 1,
attributes: {},
confidencePairs: [['shark', 0.9]],
features: [{ frame: 1, bounds: [1, 1, 2, 2], keyframe: true }],
}), { imported: true });
const leftTarget = cameraStore.getTrack(1, 'left');
const setConfidencePairs = leftTarget.setConfidencePairs.bind(leftTarget);
vi.spyOn(leftTarget, 'setConfidencePairs').mockImplementation((pairs) => {
changes.push('canonical:left');
setConfidencePairs(pairs);
});
const rightTarget = cameraStore.getTrack(1, 'right');
const setRightConfidencePairs = rightTarget.setConfidencePairs.bind(rightTarget);
vi.spyOn(rightTarget, 'setConfidencePairs').mockImplementation((pairs) => {
changes.push('canonical:right');
setRightConfidencePairs(pairs);
});
modeManager.multiSelectList.value = [1, 2, 3];

modeManager.handler.commitMerge();

const leftPairs = cameraStore.getTrack(1, 'left').confidencePairs;
const rightPairs = cameraStore.getTrack(1, 'right').confidencePairs;
expect(leftPairs).toEqual([
['shark', 0.9], ['turtle', 0.8], ['fish', 0.7], ['rock', 0.6],
]);
expect(rightPairs).toEqual(leftPairs);
expect(rightPairs).not.toBe(leftPairs);
expect(cameraStore.getPossibleTrack(2, 'left')).toBeUndefined();
expect(cameraStore.getPossibleTrack(2, 'right')).toBeUndefined();
expect(cameraStore.getPossibleTrack(3, 'left')).toBeUndefined();
['canonical:left', 'canonical:right'].forEach((canonical) => {
expect(changes.indexOf(canonical)).toBeLessThan(changes.indexOf('delete:2'));
expect(changes.indexOf(canonical)).toBeLessThan(changes.indexOf('delete:3'));
});
});

it('creates a target replica in a source-only camera without losing local data', () => {
const { cameraStore, modeManager } = makeHarness();
const leftStore = cameraStore.camMap.value.get('left')?.trackStore;
const rightStore = cameraStore.camMap.value.get('right')?.trackStore;
leftStore?.insert(Track.fromJSON({
id: 2,
begin: 4,
end: 4,
attributes: { camera: 'left' },
confidencePairs: [['fish', 0.8]],
features: [{ frame: 4, bounds: [4, 5, 6, 7], keyframe: true }],
}), { imported: true });
rightStore?.insert(new Track(1, {
confidencePairs: [['shark', 0.9]],
features: [{ frame: 0, bounds: [0, 0, 1, 1], keyframe: true }],
}), { imported: true });
modeManager.multiSelectList.value = [1, 2];

modeManager.handler.commitMerge();

const leftTarget = cameraStore.getTrack(1, 'left');
const rightTarget = cameraStore.getTrack(1, 'right');
expect(leftTarget.features[4]?.bounds).toEqual([4, 5, 6, 7]);
expect(leftTarget.attributes).toEqual({ camera: 'left' });
expect(leftTarget.confidencePairs).toEqual([['shark', 0.9], ['fish', 0.8]]);
expect(rightTarget.confidencePairs).toEqual(leftTarget.confidencePairs);
expect(rightTarget.confidencePairs).not.toBe(leftTarget.confidencePairs);
expect(rightTarget.confidencePairs[0]).not.toBe(leftTarget.confidencePairs[0]);
expect(cameraStore.getPossibleTrack(2, 'left')).toBeUndefined();
});
});

describe('TrackFilterControls construction', () => {
it('provides complete stored-track enumeration for hierarchy renames', () => {
const { cameraStore, trackFilterControls } = makeSingleCamHarness();
Expand Down
8 changes: 3 additions & 5 deletions client/dive-common/use/useModeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1334,14 +1334,12 @@ export default function useModeManager({
*/
function handleCommitMerge() {
if (multiSelectList.value.length >= 2) {
const track = cameraStore.getTrack(multiSelectList.value[0], selectedCamera.value);
const targetTrackId = multiSelectList.value[0];
const otherTrackIds = multiSelectList.value.slice(1);
track.merge(otherTrackIds.map(
(trackId) => cameraStore.getTrack(trackId, selectedCamera.value),
));
cameraStore.mergeTracks(targetTrackId, otherTrackIds);
handleRemoveTrack(otherTrackIds, true);
handleToggleMerge();
handleSelectTrack(track.id, false);
handleSelectTrack(targetTrackId, false);
}
}

Expand Down
7 changes: 6 additions & 1 deletion client/src/BaseFilterControls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,9 +218,14 @@ export default abstract class BaseFilterControls<T extends Track | Group> {
}

removeTypeAnnotations(types: string[]) {
const processedIds = new Set<AnnotationId>();
this.filteredAnnotations.value.forEach((filtered) => {
if (processedIds.has(filtered.annotation.id)) {
return;
}
processedIds.add(filtered.annotation.id);
const filteredType = filtered.annotation.getType(filtered.context.confidencePairIndex);
if (filteredType && types.includes(filteredType[0])) {
if (filteredType && types.includes(filteredType)) {
//Remove the type from the annotation if multiple types exist
const newConfidencePairs = this.removeTypes(filtered.annotation.id, types);
if (newConfidencePairs.length === 0) {
Expand Down
Loading
Loading