Skip to content
Open
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
38 changes: 37 additions & 1 deletion client/dive-common/components/TypeSettingsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ export default defineComponent({
preventCascadeTypes: 'When a track has multiple types, this will prevent the type from displaying if the max type is not visible in the type list',
filterTypesByFrame: 'Filters the type list by only types that are visible in the current frame',
maxCountButton: 'Show a max count button that will jump to the frame with the max count for the type',
suppressionType: 'Detections lying 50% or more under an annotation of this type are hidden and excluded from counts. Leave empty to disable.',
suppressionType: 'Detections lying at least the Suppression Overlap percent under an annotation of this type are hidden and excluded from counts. A detection carrying an attribute of this name set to true stays visible but displays as this type and is excluded from its own type\'s counts. Leave empty to disable.',
suppressionThreshold: 'Minimum percent of a detection that must lie under suppression regions for it to be hidden (default 95).',
});
const importInstructions = ref('Please provide a list of types (separated by a new line) that you would like to import');
const importDialog = ref(false);
Expand Down Expand Up @@ -313,6 +314,41 @@ export default defineComponent({
</v-tooltip>
</v-col>
</v-row>
<v-row class="mt-2">
<v-col class="py-1">
<v-text-field
v-model.number="clientSettings.typeSettings.suppressionThreshold"
label="Suppression Overlap (%)"
type="number"
min="1"
max="100"
step="1"
class="my-0 ml-1 pt-0"
dense
hide-details
/>
</v-col>
<v-col
cols="2"
class="py-1"
align="right"
>
<v-tooltip
open-delay="200"
bottom
>
<template #activator="{ on }">
<v-icon
small
v-on="on"
>
mdi-help
</v-icon>
</template>
<span>{{ help.suppressionThreshold }}</span>
</v-tooltip>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-menu>
Expand Down
9 changes: 7 additions & 2 deletions client/dive-common/store/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,13 @@ interface AnnotationSettings {
preventCascadeTypes?: boolean;
filterTypesByFrame?: boolean;
maxCountButton?: boolean;
// Detections whose geometry lies >=50% under a region of this type are
// hidden and excluded from counts. Empty string disables the feature.
// Detections whose geometry lies at least suppressionThreshold percent
// under a region of this type are hidden and excluded from counts.
// Empty string disables the feature.
suppressionType?: string;
// Minimum covered percent (0-100] for a detection to count as
// suppressed; out-of-range values fall back to the default (95).
suppressionThreshold?: number;
};
trackSettings: {
newTrackSettings: {
Expand Down Expand Up @@ -131,6 +135,7 @@ const defaultSettings: AnnotationSettings = {
preventCascadeTypes: false,
maxCountButton: false,
suppressionType: 'Suppressed',
suppressionThreshold: 95,
},
rowsPerPage: 20,
annotationFPS: 10,
Expand Down
35 changes: 25 additions & 10 deletions client/src/components/FilterList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import BaseFilterControls from '../BaseFilterControls';
import Track from '../track';
import Group from '../Group';
import StyleManager from '../StyleManager';
import { getSuppressedTrackIds } from '../use/suppression';
import { getSuppressedTrackIds, hasSuppressionAttribute } from '../use/suppression';

interface VirtualTypeItem {
type: string;
Expand Down Expand Up @@ -147,14 +147,16 @@ export default defineComponent({
}

/**
* Ids of tracks whose every keyframe detection is suppressed (a region
* covers it on each frame it appears), across all cameras - these are
* excluded from the dataset-wide type totals. Reactive to region edits via
* the save counter, since track geometry is not itself a reactive value.
* Ids of tracks whose every keyframe detection is suppressed - covered by
* a region on each frame it appears, or flagged with the suppression
* attribute - across all cameras. These are excluded from the
* dataset-wide type totals. Reactive to region/attribute edits via the
* save counter, since track geometry is not itself a reactive value.
*/
const fullySuppressedIds = computed(() => {
const editRevision = pendingSaveCount.value;
const suppType = clientSettings.typeSettings.suppressionType;
const suppThreshold = clientSettings.typeSettings.suppressionThreshold;
const excluded = new Set<number>();
if (!suppType || editRevision < 0) {
return excluded;
Expand All @@ -164,7 +166,7 @@ export default defineComponent({
const suppressedAt = (f: number) => {
let s = perFrame.get(f);
if (s === undefined) {
s = getSuppressedTrackIds(store, f, suppType);
s = getSuppressedTrackIds(store, f, suppType, suppThreshold);
perFrame.set(f, s);
}
return s;
Expand All @@ -176,7 +178,8 @@ export default defineComponent({
}
const keyframes = track.features.filter((f) => f && f.keyframe);
if (keyframes.length > 0
&& keyframes.every((f) => suppressedAt(f.frame).has(track.id))) {
&& keyframes.every((f) => suppressedAt(f.frame).has(track.id)
|| hasSuppressionAttribute(track, f.frame, suppType))) {
excluded.add(track.id);
}
});
Expand Down Expand Up @@ -208,15 +211,27 @@ export default defineComponent({
.search([frame.value, frame.value])
.map((str) => parseInt(str, 10));
// Detections suppressed by a region on this frame are dropped so the
// per-frame type counts read off the interface exclude them.
// per-frame type counts read off the interface exclude them, and
// attribute-suppressed detections (visible but displayed as the
// suppression type) don't count toward their own type either.
const suppType = clientSettings.typeSettings.suppressionType;
const suppressedIds = (trackStore && editRevision >= 0)
? getSuppressedTrackIds(trackStore, frame.value, clientSettings.typeSettings.suppressionType)
? getSuppressedTrackIds(
trackStore,
frame.value,
suppType,
clientSettings.typeSettings.suppressionThreshold,
)
: new Set<number>();
const filteredKeyFrameTracks = filteredTracksRef.value.filter((track) => {
if (suppressedIds.has(track.annotation.id)) {
return false;
}
const keyframe = trackStore?.getPossible(track.annotation.id)?.getFeature(frame.value)[0];
const realTrack = trackStore?.getPossible(track.annotation.id);
if (realTrack && hasSuppressionAttribute(realTrack, frame.value, suppType)) {
return false;
}
const keyframe = realTrack?.getFeature(frame.value)[0];
return !!keyframe?.keyframe;
});
return (filteredKeyFrameTracks.filter((track) => trackIdsForFrame?.includes(track.annotation.id)));
Expand Down
18 changes: 14 additions & 4 deletions client/src/components/LayerManager.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import TextLayer, { FormatTextRow } from '../layers/AnnotationLayers/TextLayer';
import AttributeLayer from '../layers/AnnotationLayers/AttributeLayer';
import AttributeBoxLayer from '../layers/AnnotationLayers/AttributeBoxLayer';
import type { AnnotationId } from '../BaseAnnotation';
import { getSuppressedTrackIds } from '../use/suppression';
import { getSuppressedTrackIds, hasSuppressionAttribute } from '../use/suppression';
import { VisibleAnnotationTypes } from '../layers';
import UILayer from '../layers/UILayers/UILayer';
import ToolTipWidget from '../layers/UILayers/ToolTipWidget.vue';
Expand Down Expand Up @@ -347,8 +347,9 @@ export default defineComponent({
}
// Detections lying >=50% under a suppression region on this frame are
// hidden from every layer at once (and excluded from counts elsewhere).
const { suppressionType, suppressionThreshold } = clientSettings.typeSettings;
const suppressedIds = trackStore
? getSuppressedTrackIds(trackStore, frame, clientSettings.typeSettings.suppressionType)
? getSuppressedTrackIds(trackStore, frame, suppressionType, suppressionThreshold)
: new Set<AnnotationId>();
currentFrameIds.forEach(
(trackId: AnnotationId) => {
Expand All @@ -372,14 +373,22 @@ export default defineComponent({
enabledTracks[enabledIndex].context.confidencePairIndex,
);
const groupStyleType = groups?.[0]?.getType() ?? cameraStore.defaultGroup;
// A detection flagged with the suppression attribute (outside any
// region) keeps its real type but displays as the suppression
// type: styleType feeds the color/opacity/fill styling, the text
// label, and the hover tooltip of every layer.
let styleType: [string, number] = colorBy === 'group' ? groupStyleType : trackStyleType;
if (suppressionType && hasSuppressionAttribute(track, frame, suppressionType)) {
styleType = [suppressionType, 1];
}
const trackFrame = {
selected: ((selectedTrackId === track.trackId)
|| (multiSelectList.includes(track.trackId))),
editing: editingTrack,
track,
groups,
features,
styleType: colorBy === 'group' ? groupStyleType : trackStyleType,
styleType,
set: track.set,
};
frameData.push(trackFrame);
Expand Down Expand Up @@ -616,8 +625,9 @@ export default defineComponent({
toRef(props, 'colorBy'),
selectedCamera,
selectedKeyRef,
// re-render when the suppression-region type is changed
// re-render when the suppression-region type or threshold is changed
() => clientSettings.typeSettings.suppressionType,
() => clientSettings.typeSettings.suppressionThreshold,
],
() => {
refreshLayers();
Expand Down
7 changes: 6 additions & 1 deletion client/src/components/Tracks/TrackList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,12 @@ export default defineComponent({
const suppressCamStore = cameraStore.camMap.value.get(selectedCamera.value)?.trackStore;
const suppType = clientSettings.typeSettings.suppressionType;
const suppressedIds = (suppressCamStore && editRevision >= 0)
? getSuppressedTrackIds(suppressCamStore, frameRef.value, suppType)
? getSuppressedTrackIds(
suppressCamStore,
frameRef.value,
suppType,
clientSettings.typeSettings.suppressionThreshold,
)
: new Set<number>();
tracks = tracks.filter((track) => {
if (suppressedIds.has(track.annotation.id)) {
Expand Down
168 changes: 168 additions & 0 deletions client/src/use/suppression.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/// <reference types="vitest" />
import Track, { TrackData } from '../track';
import {
isSuppressedAttributeValue, hasSuppressionAttribute,
getSuppressedTrackIds, normalizeSuppressionThreshold, DEFAULT_SUPPRESSION_THRESHOLD,
} from './suppression';

function makeTrack(overrides: Partial<TrackData> = {}): Track {
return Track.fromJSON({
attributes: {},
begin: 0,
end: 0,
confidencePairs: [['seal', 0.9]],
features: [
{
frame: 0,
bounds: [0, 0, 10, 10],
keyframe: true,
interpolate: false,
},
],
meta: {},
id: 1,
...overrides,
});
}

describe('isSuppressedAttributeValue', () => {
it('accepts common truthy flag spellings', () => {
expect(isSuppressedAttributeValue(true)).toBe(true);
expect(isSuppressedAttributeValue(1)).toBe(true);
expect(isSuppressedAttributeValue('true')).toBe(true);
expect(isSuppressedAttributeValue('True')).toBe(true);
expect(isSuppressedAttributeValue('ON')).toBe(true);
expect(isSuppressedAttributeValue('yes')).toBe(true);
expect(isSuppressedAttributeValue(' 1 ')).toBe(true);
});

it('rejects falsy or unrelated values', () => {
expect(isSuppressedAttributeValue(false)).toBe(false);
expect(isSuppressedAttributeValue(0)).toBe(false);
expect(isSuppressedAttributeValue('false')).toBe(false);
expect(isSuppressedAttributeValue('off')).toBe(false);
expect(isSuppressedAttributeValue('')).toBe(false);
expect(isSuppressedAttributeValue(undefined)).toBe(false);
expect(isSuppressedAttributeValue(null)).toBe(false);
expect(isSuppressedAttributeValue({})).toBe(false);
});
});

describe('hasSuppressionAttribute', () => {
it('is disabled when no suppression type is configured', () => {
const track = makeTrack({ attributes: { Suppressed: true } });
expect(hasSuppressionAttribute(track, 0, undefined)).toBe(false);
expect(hasSuppressionAttribute(track, 0, '')).toBe(false);
});

it('reads a track-level attribute named after the suppression type', () => {
const track = makeTrack({ attributes: { Suppressed: true } });
expect(hasSuppressionAttribute(track, 0, 'Suppressed')).toBe(true);
expect(hasSuppressionAttribute(track, 0, 'OtherName')).toBe(false);
});

it('reads a detection-level attribute on the frame', () => {
const track = makeTrack({
features: [
{
frame: 0,
bounds: [0, 0, 10, 10],
keyframe: true,
interpolate: false,
attributes: { Suppressed: 'true' },
},
],
});
expect(hasSuppressionAttribute(track, 0, 'Suppressed')).toBe(true);
});

it('falls back to the previous keyframe on interpolated frames', () => {
const track = makeTrack({
begin: 0,
end: 10,
features: [
{
frame: 0,
bounds: [0, 0, 10, 10],
keyframe: true,
interpolate: true,
attributes: { Suppressed: true },
},
{
frame: 10,
bounds: [10, 10, 20, 20],
keyframe: true,
interpolate: true,
},
],
});
expect(hasSuppressionAttribute(track, 5, 'Suppressed')).toBe(true);
});

it('is false for an unflagged detection', () => {
const track = makeTrack();
expect(hasSuppressionAttribute(track, 0, 'Suppressed')).toBe(false);
});
});

describe('normalizeSuppressionThreshold', () => {
it('converts a valid percent to a fraction', () => {
expect(normalizeSuppressionThreshold(95)).toBeCloseTo(0.95, 6);
expect(normalizeSuppressionThreshold(50)).toBeCloseTo(0.5, 6);
expect(normalizeSuppressionThreshold(100)).toBeCloseTo(1.0, 6);
expect(normalizeSuppressionThreshold(1)).toBeCloseTo(0.01, 6);
});

it('falls back to the default for missing or out-of-range values', () => {
expect(normalizeSuppressionThreshold(undefined)).toBe(DEFAULT_SUPPRESSION_THRESHOLD);
expect(normalizeSuppressionThreshold(0)).toBe(DEFAULT_SUPPRESSION_THRESHOLD);
expect(normalizeSuppressionThreshold(-5)).toBe(DEFAULT_SUPPRESSION_THRESHOLD);
expect(normalizeSuppressionThreshold(150)).toBe(DEFAULT_SUPPRESSION_THRESHOLD);
expect(normalizeSuppressionThreshold(NaN)).toBe(DEFAULT_SUPPRESSION_THRESHOLD);
});
});

describe('getSuppressedTrackIds threshold', () => {
function makeStore(tracks: Track[]) {
return {
intervalTree: {
search: () => tracks.map((t) => String(t.id)),
},
getPossible: (id: number) => tracks.find((t) => t.id === id),
} as unknown as Parameters<typeof getSuppressedTrackIds>[0];
}
// A 100x100 region and a detection half-covered by it (x in [50, 150))
const region = makeTrack({
id: 1,
confidencePairs: [['Suppressed', 1]],
features: [{
frame: 0, bounds: [0, 0, 100, 100], keyframe: true, interpolate: false,
}],
});
const halfCovered = makeTrack({
id: 2,
features: [{
frame: 0, bounds: [50, 0, 150, 100], keyframe: true, interpolate: false,
}],
});
const fullyCovered = makeTrack({
id: 3,
features: [{
frame: 0, bounds: [10, 10, 90, 90], keyframe: true, interpolate: false,
}],
});
const store = makeStore([region, halfCovered, fullyCovered]);

it('at the default (95%) only the fully covered detection is suppressed', () => {
expect(getSuppressedTrackIds(store, 0, 'Suppressed')).toEqual(new Set([3]));
});

it('a lower threshold also suppresses the half-covered detection', () => {
expect(getSuppressedTrackIds(store, 0, 'Suppressed', 50)).toEqual(new Set([2, 3]));
});

it('out-of-range thresholds fall back to the default', () => {
expect(getSuppressedTrackIds(store, 0, 'Suppressed', 0)).toEqual(new Set([3]));
expect(getSuppressedTrackIds(store, 0, 'Suppressed', 200)).toEqual(new Set([3]));
});
});
Loading
Loading