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
14 changes: 13 additions & 1 deletion client/dive-common/components/TrackDetailsPanel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const state = vi.hoisted(() => ({
displayPairIndex: vi.fn(() => 1),
track: null as Track | null,
multiSelectList: [] as number[],
editingMultiTrack: false,
acceptTrackType: vi.fn(),
assignTrackType: vi.fn(),
}));
Expand Down Expand Up @@ -40,7 +41,7 @@ vi.mock('vue-media-annotator/provides', () => ({
typeStyling: ref({ color: (type: string) => `color:${type}` }),
}),
useEditingGroupId: () => ref(null),
useEditingMultiTrack: () => ref(false),
useEditingMultiTrack: () => ref(state.editingMultiTrack),
useGroupFilterControls: () => ({ allTypes: ref([]) }),
useCameraStore: () => ({
camMap: ref(new Map([['singleCam', { groupStore: undefined }]])),
Expand Down Expand Up @@ -81,6 +82,7 @@ describe('TrackDetailsPanel hierarchy summary', () => {
beforeEach(() => {
state.displayPairIndex.mockReturnValue(1);
state.multiSelectList = [];
state.editingMultiTrack = false;
state.acceptTrackType.mockClear();
state.assignTrackType.mockClear();
state.track = new Track(1, {
Expand Down Expand Up @@ -125,6 +127,7 @@ describe('TrackDetailsPanel hierarchy summary', () => {

it('routes bulk assignment through the same hierarchy-aware command', () => {
state.multiSelectList = [1];
state.editingMultiTrack = true;
const { vm } = mountPanel();
vm.updateMultiTrackType('new leaf');
vm.updateSelectedTracksType();
Expand All @@ -133,4 +136,13 @@ describe('TrackDetailsPanel hierarchy summary', () => {
replaceType: 'leaf',
});
});

it('does not bulk-assign the hidden default during ordinary track selection', () => {
const { wrapper, vm } = mountPanel();

vm.updateSelectedTracksType();

expect(state.assignTrackType).not.toHaveBeenCalled();
expect(wrapper.text()).not.toContain('Update type for selected tracks');
});
});
2 changes: 2 additions & 0 deletions client/dive-common/components/TrackDetailsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ export default defineComponent({
});

function updateSelectedTracksType() {
if (!editingMultiTrack.value) return;
selectedTrackList.value.forEach((track) => {
const pairIndex = Math.max(trackFilters.displayPairIndex(track, 0), 0);
cameraStore.assignTrackType(track.id, multiTrackType.value, {
Expand Down Expand Up @@ -617,6 +618,7 @@ export default defineComponent({
/>
</div>
<v-btn
v-if="editingMultiTrack"
class="mx-2 mb-2"
:disabled="readOnlyMode || disabled"
color="primary"
Expand Down
1 change: 0 additions & 1 deletion client/dive-common/components/Viewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -677,7 +677,6 @@ export default defineComponent({
remove: removeTracks,
markChangesPending: (markChangesPending as MarkChangesPendingFilter),
lookupGroups: cameraStore.lookupGroups,
getTrack: (track: AnnotationId, camera = 'singleCam') => (cameraStore.getTrack(track, camera)),
getTracks: (track: AnnotationId) => cameraStore.getTrackAll(track),
renameTrackPair: (id, currentType, newType) => (
cameraStore.renameTrackPair(id, currentType, newType)
Expand Down
23 changes: 23 additions & 0 deletions client/dive-common/typeHierarchy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
removePair,
resolveTypeHierarchy,
rewriteHierarchyType,
selectFlatPairIndex,
selectPairIndex,
setPairConfidence,
TypeHierarchyError,
Expand Down Expand Up @@ -249,6 +250,28 @@ describe('type hierarchy index', () => {
});
});

describe('flat pair selection', () => {
const pairs: [string, number][] = [['top', 0.5], ['fallback', 0.8]];

it('uses zero when the default threshold is absent', () => {
expect(selectFlatPairIndex(pairs, {
checkedSet: new Set(['fallback']),
confidenceFilters: {},
filtersDisabled: false,
preventCascade: false,
})).toBe(1);
});

it('keeps the strict Prevent Cascade threshold comparison', () => {
expect(selectFlatPairIndex(pairs, {
checkedSet: new Set(['top', 'fallback']),
confidenceFilters: { top: 0.5, default: 0.1 },
filtersDisabled: false,
preventCascade: true,
})).toBe(-1);
});
});

describe('pair merging', () => {
const cases: [Array<[string, number][]>, [string, number][]][] = [
[
Expand Down
34 changes: 34 additions & 0 deletions client/dive-common/typeHierarchy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,40 @@ export interface TypeHierarchyIndex {
ancestors: Readonly<Record<string, readonly string[]>>;
}

interface FlatPairSelectionOptions {
checkedSet: ReadonlySet<string>;
confidenceFilters: Readonly<Record<string, number>>;
filtersDisabled: boolean;
preventCascade: boolean;
}

/** Select the visible classification pair when no hierarchy is active. */
export function selectFlatPairIndex(
pairs: readonly (readonly [string, number])[],
{
checkedSet, confidenceFilters, filtersDisabled, preventCascade,
}: FlatPairSelectionOptions,
): number {
if (pairs.length === 0) return -1;
if (filtersDisabled) return 0;
const passes = ([type, confidence]: readonly [string, number]) => {
const threshold = Math.max(
confidenceFilters[type] || 0,
confidenceFilters.default || 0,
);
return checkedSet.has(type) && confidence >= threshold;
};
if (preventCascade) {
const [type, confidence] = pairs[0];
const threshold = Math.max(
confidenceFilters[type] || 0,
confidenceFilters.default || 0,
);
return checkedSet.has(type) && confidence > threshold ? 0 : -1;
}
return pairs.findIndex(passes);
}

// Python orders strings by code point; JS compares UTF-16 units, which sorts astral
// names before U+E000-U+FFFF. Compare code points so both platforms agree.
function codePointCompare(left: string, right: string): number {
Expand Down
2 changes: 0 additions & 2 deletions client/dive-common/use/useModeManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ function makeHarness(markChangesPending: MarkChangesPending = () => undefined) {
remove: () => undefined,
markChangesPending: () => undefined,
lookupGroups: cameraStore.lookupGroups.bind(cameraStore),
getTrack: (id: AnnotationId, camera = 'singleCam') => cameraStore.getTrack(id, camera),
getTracks: (id: AnnotationId) => cameraStore.getTrackAll(id),
renameTrackPair: (id, currentType, newType) => (
cameraStore.renameTrackPair(id, currentType, newType)
Expand Down Expand Up @@ -199,7 +198,6 @@ function makeSingleCamHarness() {
remove: () => undefined,
markChangesPending: () => undefined,
lookupGroups: cameraStore.lookupGroups.bind(cameraStore),
getTrack: (id: AnnotationId, camera = 'singleCam') => cameraStore.getTrack(id, camera),
getTracks: (id: AnnotationId) => cameraStore.getTrackAll(id),
renameTrackPair: (id, currentType, newType) => (
cameraStore.renameTrackPair(id, currentType, newType)
Expand Down
8 changes: 5 additions & 3 deletions client/dive-common/use/useSave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,12 @@ export default function useSave(
globalMetadataPending += 1;
}
pendingSaveCount.value += 1;
} else if (pendingChangeMaps[cameraName]) {
const pendingChangeMap = pendingChangeMaps[cameraName];
} else {
const globalDefinition = attribute !== undefined || attributeTrackFilter !== undefined;
const pendingChangeMap = pendingChangeMaps[cameraName]
?? (globalDefinition ? Object.values(pendingChangeMaps)[0] : undefined);

if (!readonlyMode.value) {
if (pendingChangeMap && !readonlyMode.value) {
if (track !== undefined) {
_updatePendingChangeMap(
track.trackId,
Expand Down
24 changes: 24 additions & 0 deletions client/dive-common/use/useSaveClassification.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ref } from 'vue';

import CameraStore from 'vue-media-annotator/CameraStore';
import Track, { Feature, TrackData } from 'vue-media-annotator/track';
import type { Attribute } from 'vue-media-annotator/use/AttributeTypes';

import useSave from './useSave';

Expand Down Expand Up @@ -112,4 +113,27 @@ describe('classification save and reload', () => {
expect(leftReloaded.confidencePairs).not.toContain(pair)
));
});

it('persists global attribute definitions after multicamera setup', async () => {
const saveControls = useSave(ref('multicam-dataset'), ref(false));
saveControls.removeCamera('singleCam');
saveControls.addCamera('left');
saveControls.addCamera('right');
const trackAttribute: Attribute = {
belongs: 'track', datatype: 'text', key: 'track_note', name: 'note',
};
const detectionAttribute: Attribute = {
belongs: 'detection', datatype: 'text', key: 'detection_state', name: 'state',
};

saveControls.markChangesPending({ action: 'upsert', attribute: trackAttribute });
saveControls.markChangesPending({ action: 'upsert', attribute: detectionAttribute });
await saveControls.save();

expect(apiMocks.saveAttributes).toHaveBeenCalledOnce();
expect(apiMocks.saveAttributes).toHaveBeenCalledWith('multicam-dataset', {
upsert: [trackAttribute, detectionAttribute],
delete: [],
});
});
});
9 changes: 5 additions & 4 deletions client/platform/desktop/backend/serializers/coco.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,15 +638,16 @@ describe('COCO serializer', () => {
await serializeFile('/output/filtered.json', source, {
...imageMeta,
typeHierarchy: { leaf: 'root' },
}, new Set(['leaf']));
}, new Set(['root']));
const out = await fs.readJSON('/output/filtered.json');
expect(out.annotations[0].dive_confidence_pairs).toEqual([['leaf', 0.8]]);
expect(out.annotations[0].prob).toEqual([0.8, 0]);
// Export filters raw stored names even though hierarchy display resolves this track to leaf.
expect(out.annotations[0].dive_confidence_pairs).toEqual([['root', 0.2]]);
expect(out.annotations[0].prob).toEqual([0.2, 0]);
expect(source.tracks[4].confidencePairs).toEqual([['root', 0.2], ['leaf', 0.8]]);

await fs.writeJSON('/input/filtered.json', out);
const [parsed] = await parseFile('/input/filtered.json');
expect(parsed.tracks[4].confidencePairs).toEqual([['leaf', 0.8]]);
expect(parsed.tracks[4].confidencePairs).toEqual([['root', 0.2]]);
});
});

Expand Down
38 changes: 38 additions & 0 deletions client/platform/desktop/backend/serializers/dive.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';

import { AnnotationsCurrentVersion, JsonConfig } from 'platform/desktop/constants';
import { AnnotationSchema } from 'dive-common/apispec';
import { filterTracks } from './dive';

describe('DIVE JSON serializer', () => {
it('clones kept tracks with threshold- and type-pruned raw confidence pairs', () => {
const data: AnnotationSchema = {
version: AnnotationsCurrentVersion,
groups: {},
tracks: {
1: {
id: 1,
begin: 0,
end: 0,
attributes: {},
confidencePairs: [['fish', 0.9], ['shark', 0.2], ['whale', 0.8], ['zero', 0]],
features: [{ frame: 0, bounds: [0, 0, 1, 1] }],
},
},
};
const original = data.tracks[1].confidencePairs.map(([name, score]) => [name, score]);
const meta = {
confidenceFilters: { default: 0.1, fish: 0.95, zero: 0 },
} as unknown as JsonConfig;

const filtered = filterTracks(data, meta, new Set(['fish', 'whale', 'zero']), {
excludeBelowThreshold: true,
header: true,
});

expect(filtered).not.toBe(data);
expect(filtered.tracks[1]).not.toBe(data.tracks[1]);
expect(filtered.tracks[1].confidencePairs).toEqual([['whale', 0.8], ['zero', 0]]);
expect(data.tracks[1].confidencePairs).toEqual(original);
});
});
10 changes: 8 additions & 2 deletions client/platform/desktop/backend/serializers/dive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ function filterTracks(
header: true,
},
): AnnotationSchema {
const filteredTracks = Object.values(data.tracks).filter((track) => {
const filteredTracks = Object.values(data.tracks).flatMap((track) => {
const filters = meta.confidenceFilters || {};
/* Include only the pairs that exceed the threshold in CSV output */
const confidencePairs = options.excludeBelowThreshold
Expand All @@ -59,7 +59,13 @@ function filterTracks(
const filteredPairs = typeFilter.size > 0
? confidencePairs.filter((x) => typeFilter.has(x[0]))
: confidencePairs;
return filteredPairs.length > 0;
if (!filteredPairs.length) {
return [];
}
return [{
...track,
confidencePairs: filteredPairs.map(([name, confidence]) => [name, confidence] as [string, number]),
}];
});
// Convert the track list back into an object
const updatedFilteredTracks: Record<number, TrackData> = {};
Expand Down
17 changes: 17 additions & 0 deletions client/platform/desktop/backend/serializers/viame.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,23 @@ describe('VIAME serialize testing', () => {
const expectedOutput = ['first_type', '0.9', 'second_type', '0.7'];
expect(checkConfidenceOutput(output)).toEqual(expectedOutput);
});
it('keeps an explicit zero score when its type threshold is zero', async () => {
const path = '/home/zero-threshold.csv';
const stream = fs.createWriteStream(path);
const zeroData = JSON.parse(JSON.stringify(data)) as AnnotationSchema;
const [zeroTrack] = Object.values(zeroData.tracks);
zeroTrack.confidencePairs.push(['zero_type', 0]);
await serialize(stream, zeroData, {
...meta,
confidenceFilters: { default: 0.65, zero_type: 0 },
} as JsonConfig, new Set<string>(), {
excludeBelowThreshold: true,
header: true,
});
const output = fs.readFileSync(path).toString().split('\n');
expect(checkConfidenceOutput(output)).toContain('zero_type');
expect(checkConfidenceOutput(output)).toContain('0');
});
});

// Returns the entries of the `# metadata` row (without the leading marker), or null if absent
Expand Down
2 changes: 1 addition & 1 deletion client/platform/desktop/frontend/components/Export.vue
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ export default defineComponent({
v-model="data.excludeUncheckedTypes"
label="export checked types only"
dense
hint="Export only the track types currently enabled in the type filter"
hint="Export only stored confidence pairs whose raw type names are checked; other pairs are removed from exported tracks"
persistent-hint
class="pt-0"
/>
Expand Down
2 changes: 1 addition & 1 deletion client/platform/web-girder/views/Export.vue
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ export default defineComponent({
v-model="excludeUncheckedTypes"
label="export checked types only"
dense
hint="Export only the track types currently enabled in the type filter"
hint="Export only stored confidence pairs whose raw type names are checked; other pairs are removed from exported tracks"
persistent-hint
class="pt-0"
/>
Expand Down
31 changes: 20 additions & 11 deletions client/src/AttributeTrackFilterControls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export const trackIdPassesFilter = (
filters: AttributeTrackFilter[],
userDefinedvals: userDefinedVals[],
enabled: boolean[],
displayType: string | undefined,
) => {
const track = getTrack(id);
const trackAttributes = track.attributes;
Expand All @@ -132,24 +133,32 @@ export const trackIdPassesFilter = (
});
for (let i = 0; i < trackFilters.length; i += 1) {
const filter = trackFilters[i];
// If we have a type filter only filter by the types specified
if (filter.typeFilter.length > 0 && !filter.typeFilter.includes(track.getType()[0])) {
return true;
}
if (trackAttributes[filter.attribute] === undefined && !filter.ignoreUndefined) {
return false;
}
const result = checkAttributes(filter.filter, trackAttributes[filter.attribute] as userDefinedVals, trackUserVals[i]);
if (!result) {
return false;
// Attribute type filters apply to the type the UI resolved for display, not
// necessarily confidencePairs[0] (which can be a hierarchy ancestor).
const appliesToDisplayType = filter.typeFilter.length === 0
|| filter.typeFilter.includes(displayType || '');
if (appliesToDisplayType) {
if (trackAttributes[filter.attribute] === undefined && !filter.ignoreUndefined) {
return false;
}
const result = checkAttributes(
filter.filter,
trackAttributes[filter.attribute] as userDefinedVals,
trackUserVals[i],
);
if (!result) {
return false;
}
}
}
for (let i = 0; i < detectionFilters.length; i += 1) {
for (let k = 0; k < track.featureIndex.length; k += 1) {
const index = track.featureIndex[k];
const detectionAttributes = track.features[index].attributes;
const filter = detectionFilters[i];
if (detectionAttributes) {
const appliesToDisplayType = filter.typeFilter.length === 0
|| filter.typeFilter.includes(displayType || '');
if (detectionAttributes && appliesToDisplayType) {
if (detectionAttributes[filter.attribute] === undefined && !filter.ignoreUndefined) {
return false;
}
Expand Down
Loading
Loading