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
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
46 changes: 46 additions & 0 deletions client/dive-common/typeHierarchy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import {
normalizeTypeHierarchy,
reassignPairs,
removePair,
resolveConfidenceThreshold,
resolveTypeHierarchy,
rewriteHierarchyType,
selectFlatPairIndex,
selectPairIndex,
setPairConfidence,
TypeHierarchyError,
Expand Down Expand Up @@ -249,6 +251,50 @@ 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);
});

it('honors an explicit type threshold of zero the same way export does', () => {
const filters = { default: 0.1, zero: 0 };
const stored: [string, number][] = [['zero', 0], ['other', 0.9]];

expect(resolveConfidenceThreshold(filters, 'zero')).toBe(0);
expect(selectFlatPairIndex(stored, {
checkedSet: new Set(['zero']),
confidenceFilters: filters,
filtersDisabled: false,
preventCascade: false,
})).toBe(0);
});

it('lets a type-specific threshold override a higher default', () => {
expect(selectFlatPairIndex([['fish', 0.4]], {
checkedSet: new Set(['fish']),
confidenceFilters: { default: 0.5, fish: 0.3 },
filtersDisabled: false,
preventCascade: false,
})).toBe(0);
});
});

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

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

/**
* Resolve the threshold used by both viewer display and export.
* A type-specific value, including 0, overrides default instead of flooring against it.
*/
export function resolveConfidenceThreshold(
confidenceFilters: Readonly<Record<string, number>>,
type: string,
): number {
return confidenceFilters[type] ?? confidenceFilters.default ?? 0;
}

/** 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]) => (
checkedSet.has(type)
&& confidence >= resolveConfidenceThreshold(confidenceFilters, type)
);
if (preventCascade) {
const [type, confidence] = pairs[0];
return checkedSet.has(type)
&& confidence > resolveConfidenceThreshold(confidenceFilters, type) ? 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 @@ -664,15 +664,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
31 changes: 31 additions & 0 deletions client/platform/desktop/backend/serializers/dive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,35 @@ describe('DIVE JSON filterTracks', () => {
expect(exported.tracks[4]).toBeUndefined();
expect(source.tracks[4].confidencePairs).toEqual([['root', 0.2], ['leaf', 0.8]]);
});

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