diff --git a/client/dive-common/components/TypeSettingsPanel.vue b/client/dive-common/components/TypeSettingsPanel.vue
index 49db05311..e0a2c4836 100644
--- a/client/dive-common/components/TypeSettingsPanel.vue
+++ b/client/dive-common/components/TypeSettingsPanel.vue
@@ -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 99).',
});
const importInstructions = ref('Please provide a list of types (separated by a new line) that you would like to import');
const importDialog = ref(false);
@@ -313,6 +314,41 @@ export default defineComponent({
+
+
+
+
+
+
+
+
+ mdi-help
+
+
+ {{ help.suppressionThreshold }}
+
+
+
diff --git a/client/dive-common/store/settings.ts b/client/dive-common/store/settings.ts
index badebebb1..6d44896e3 100644
--- a/client/dive-common/store/settings.ts
+++ b/client/dive-common/store/settings.ts
@@ -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 (99).
+ suppressionThreshold?: number;
};
trackSettings: {
newTrackSettings: {
@@ -131,6 +135,7 @@ const defaultSettings: AnnotationSettings = {
preventCascadeTypes: false,
maxCountButton: false,
suppressionType: 'Suppressed',
+ suppressionThreshold: 99,
},
rowsPerPage: 20,
annotationFPS: 10,
diff --git a/client/src/StyleManager.spec.ts b/client/src/StyleManager.spec.ts
index 401aa9bdf..5dc4fa1f4 100644
--- a/client/src/StyleManager.spec.ts
+++ b/client/src/StyleManager.spec.ts
@@ -38,3 +38,17 @@ describe('StyleManager', () => {
expect(sm.getTypeStyles(ref(['foo']))).toEqual({ foo: { color: '#ffe080' } });
});
});
+
+describe('suppressed-display style blending', () => {
+ it('blends the suppression style 2/3 over the natural type', () => {
+ const markChangesPending = () => undefined;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const sm = new StyleManager({ markChangesPending } as any);
+ sm.updateTypeStyle({ type: 'B', value: { color: '#000000', opacity: 0.5 } });
+ sm.updateTypeStyle({ type: 'Suppressed', value: { color: '#ffffff', opacity: 1.0 } });
+ // (2/3) * 255 + (1/3) * 0 = 170 = 0xaa per channel
+ expect(sm.typeStyling.value.suppressedColor('B', 'Suppressed')).toBe('#aaaaaa');
+ // (2/3) * 1.0 + (1/3) * 0.5
+ expect(sm.typeStyling.value.suppressedOpacity('B', 'Suppressed')).toBeCloseTo(5 / 6, 6);
+ });
+});
diff --git a/client/src/StyleManager.ts b/client/src/StyleManager.ts
index 5d10b0682..f953bde84 100644
--- a/client/src/StyleManager.ts
+++ b/client/src/StyleManager.ts
@@ -36,10 +36,19 @@ export interface TypeStyling {
strokeWidth: (type: string, set?: boolean) => number;
fill: (type: string, set?: boolean) => boolean;
opacity: (type: string, set?: boolean) => number;
+ /** Weighted blend for suppressed-display detections: SUPPRESSED_STYLE_WEIGHT
+ * of the suppression type's color over the detection's own type color. */
+ suppressedColor: (type: string, suppressionType: string) => string;
+ /** Same weighted blend applied to the opacity. */
+ suppressedOpacity: (type: string, suppressionType: string) => number;
labelSettings: (type: string, set?: boolean) => { showLabel: boolean; showConfidence: boolean };
annotationSetColor: (set: string) => string;
}
+/** Fraction of the suppression type's styling used for suppressed-display
+ * detections (the remainder comes from the detection's own type). */
+export const SUPPRESSED_STYLE_WEIGHT = 2 / 3;
+
interface UseStylingParams {
markChangesPending: () => void;
vuetify?: Vuetify;
@@ -149,20 +158,30 @@ export default class StyleManager {
if (this.revisionCounter.value) noop();
const _customStyles = this.customStyles.value;
const _annotationSetStyles = this.annotationSetStyles.value;
- return {
- color: (type: string, set?: boolean) => {
- if (set && _annotationSetStyles[type] && _annotationSetStyles[type].color) {
- return _annotationSetStyles[type].color;
- }
+ const colorFor = (type: string, set?: boolean) => {
+ if (set && _annotationSetStyles[type] && _annotationSetStyles[type].color) {
+ return _annotationSetStyles[type].color;
+ }
- if (_customStyles[type] && _customStyles[type].color) {
- return _customStyles[type].color;
- }
- if (type === '') {
- return this.typeColors.range()[0];
- }
- return this.typeColors(type);
- },
+ if (_customStyles[type] && _customStyles[type].color) {
+ return _customStyles[type].color;
+ }
+ if (type === '') {
+ return this.typeColors.range()[0];
+ }
+ return this.typeColors(type);
+ };
+ const opacityFor = (type: string, set?: boolean) => {
+ if (set && _annotationSetStyles[type] && _annotationSetStyles[type].opacity) {
+ return _annotationSetStyles[type].opacity;
+ }
+ if (_customStyles[type] && _customStyles[type].opacity) {
+ return _customStyles[type].opacity;
+ }
+ return this.stateStyles.standard.opacity;
+ };
+ return {
+ color: colorFor,
annotationSetColor: (set: string) => {
if (!set) {
return 'white';
@@ -191,15 +210,26 @@ export default class StyleManager {
}
return this.stateStyles.standard.fill;
},
- opacity: (type: string, set?: boolean) => {
- if (set && _annotationSetStyles[type] && _annotationSetStyles[type].opacity) {
- return _annotationSetStyles[type].opacity;
- }
- if (_customStyles[type] && _customStyles[type].opacity) {
- return _customStyles[type].opacity;
+ opacity: opacityFor,
+ suppressedColor: (type: string, suppressionType: string) => {
+ const supp = d3.color(colorFor(suppressionType));
+ const natural = d3.color(colorFor(type));
+ if (!supp || !natural) {
+ return colorFor(suppressionType);
}
- return this.stateStyles.standard.opacity;
+ const sr = supp.rgb();
+ const nr = natural.rgb();
+ const w = SUPPRESSED_STYLE_WEIGHT;
+ return d3.rgb(
+ (w * sr.r) + ((1 - w) * nr.r),
+ (w * sr.g) + ((1 - w) * nr.g),
+ (w * sr.b) + ((1 - w) * nr.b),
+ ).hex();
},
+ suppressedOpacity: (type: string, suppressionType: string) => (
+ (SUPPRESSED_STYLE_WEIGHT * opacityFor(suppressionType))
+ + ((1 - SUPPRESSED_STYLE_WEIGHT) * opacityFor(type))
+ ),
labelSettings: (type: string, set?: boolean) => {
let { showLabel, showConfidence } = this.stateStyles.standard;
if (_customStyles[type]) {
diff --git a/client/src/components/FilterList.vue b/client/src/components/FilterList.vue
index 311f2cc94..58b54d9ff 100644
--- a/client/src/components/FilterList.vue
+++ b/client/src/components/FilterList.vue
@@ -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;
@@ -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();
if (!suppType || editRevision < 0) {
return excluded;
@@ -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;
@@ -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);
}
});
@@ -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();
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)));
diff --git a/client/src/components/LayerManager.vue b/client/src/components/LayerManager.vue
index 12e7db7a0..b48e22879 100644
--- a/client/src/components/LayerManager.vue
+++ b/client/src/components/LayerManager.vue
@@ -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';
@@ -345,10 +345,12 @@ export default defineComponent({
if (currentFrameIds === undefined) {
return;
}
- // Detections lying >=50% under a suppression region on this frame are
- // hidden from every layer at once (and excluded from counts elsewhere).
+ // Detections lying under a suppression region on this frame (by at
+ // least the configured overlap) 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();
currentFrameIds.forEach(
(trackId: AnnotationId) => {
@@ -372,6 +374,15 @@ export default defineComponent({
enabledTracks[enabledIndex].context.confidencePairIndex,
);
const groupStyleType = groups?.[0]?.getType() ?? cameraStore.defaultGroup;
+ // A detection flagged with the suppression attribute (it is NOT
+ // under a region - those are hidden above) stays visible but
+ // displays as suppressed: layers label it 'Type - SuppressionType'
+ // and blend its styling 2/3 toward the suppression type. It keeps
+ // its real type everywhere else.
+ const styleType: [string, number] = colorBy === 'group' ? groupStyleType : trackStyleType;
+ const suppressed = (suppressionType
+ && hasSuppressionAttribute(track, frame, suppressionType))
+ ? suppressionType : undefined;
const trackFrame = {
selected: ((selectedTrackId === track.trackId)
|| (multiSelectList.includes(track.trackId))),
@@ -379,7 +390,8 @@ export default defineComponent({
track,
groups,
features,
- styleType: colorBy === 'group' ? groupStyleType : trackStyleType,
+ styleType,
+ suppressed,
set: track.set,
};
frameData.push(trackFrame);
@@ -616,8 +628,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();
@@ -683,6 +696,7 @@ export default defineComponent({
const annotationHoverTooltip = (
found: {
styleType: [string, number];
+ suppressed?: string;
trackId: number;
polygon: { coordinates: Array>};
}[],
@@ -700,7 +714,8 @@ export default defineComponent({
}
});
hoveredVals.push({
- type: item.styleType[0],
+ type: item.suppressed
+ ? `${item.styleType[0]} - ${item.suppressed}` : item.styleType[0],
confidence: item.styleType[1],
trackId: item.trackId,
maxX,
diff --git a/client/src/components/Tracks/TrackList.vue b/client/src/components/Tracks/TrackList.vue
index 49e755d87..d6585389e 100644
--- a/client/src/components/Tracks/TrackList.vue
+++ b/client/src/components/Tracks/TrackList.vue
@@ -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();
tracks = tracks.filter((track) => {
if (suppressedIds.has(track.annotation.id)) {
diff --git a/client/src/layers/AnnotationLayers/PolygonLayer.ts b/client/src/layers/AnnotationLayers/PolygonLayer.ts
index 1f06102bc..3af281aef 100644
--- a/client/src/layers/AnnotationLayers/PolygonLayer.ts
+++ b/client/src/layers/AnnotationLayers/PolygonLayer.ts
@@ -11,6 +11,8 @@ interface PolyGeoJSData{
styleType: [string, number] | null;
polygon: GeoJSON.Polygon;
polygonKey: string;
+ /** Suppression type name when displaying as suppressed (blended style) */
+ suppressed?: string;
set?: string;
isHole?: boolean; // True if this is a hole polygon (for styling)
}
@@ -157,6 +159,7 @@ export default class PolygonLayer extends BaseLayer {
styleType: frameData.styleType,
polygon,
polygonKey,
+ suppressed: frameData.suppressed,
set: frameData.set,
isHole: false,
};
@@ -178,6 +181,7 @@ export default class PolygonLayer extends BaseLayer {
styleType: frameData.styleType,
polygon: holePolygon,
polygonKey, // Same key as parent polygon
+ suppressed: frameData.suppressed,
set: frameData.set,
isHole: true,
};
@@ -224,6 +228,8 @@ export default class PolygonLayer extends BaseLayer {
let color: string;
if (data.selected) {
color = this.stateStyling.selected.color;
+ } else if (data.suppressed && data.styleType) {
+ color = this.typeStyling.value.suppressedColor(data.styleType[0], data.suppressed);
} else if (data.styleType) {
color = this.typeStyling.value.color(data.styleType[0]);
} else {
@@ -247,7 +253,9 @@ export default class PolygonLayer extends BaseLayer {
},
fillColor: (_point, _index, data) => {
let color: string;
- if (data.styleType) {
+ if (data.suppressed && data.styleType) {
+ color = this.typeStyling.value.suppressedColor(data.styleType[0], data.suppressed);
+ } else if (data.styleType) {
color = this.typeStyling.value.color(data.styleType[0]);
} else {
color = this.typeStyling.value.color('');
@@ -263,6 +271,9 @@ export default class PolygonLayer extends BaseLayer {
if (data.set) {
return this.typeStyling.value.opacity(data.set);
}
+ if (data.suppressed && data.styleType) {
+ return this.typeStyling.value.suppressedOpacity(data.styleType[0], data.suppressed);
+ }
if (data.styleType) {
return this.typeStyling.value.opacity(data.styleType[0]);
}
@@ -272,6 +283,9 @@ export default class PolygonLayer extends BaseLayer {
if (data.selected) {
return this.stateStyling.selected.opacity;
}
+ if (data.suppressed && data.styleType) {
+ return this.typeStyling.value.suppressedOpacity(data.styleType[0], data.suppressed);
+ }
if (data.styleType) {
return this.typeStyling.value.opacity(data.styleType[0]);
}
diff --git a/client/src/layers/AnnotationLayers/RectangleLayer.ts b/client/src/layers/AnnotationLayers/RectangleLayer.ts
index bb845898d..25c6eff3b 100644
--- a/client/src/layers/AnnotationLayers/RectangleLayer.ts
+++ b/client/src/layers/AnnotationLayers/RectangleLayer.ts
@@ -20,6 +20,8 @@ interface RectGeoJSData{
styleType: [string, number] | null;
polygon: GeoJSON.Polygon;
hasPoly: boolean;
+ /** Suppression type name when displaying as suppressed (blended style) */
+ suppressed?: string;
set?: string;
dashed?: boolean;
rotation?: number;
@@ -171,6 +173,7 @@ export default class RectangleLayer extends BaseLayer {
styleType: track.styleType,
polygon,
hasPoly,
+ suppressed: track.suppressed,
set: track.set,
dashed,
rotation,
@@ -222,6 +225,9 @@ export default class RectangleLayer extends BaseLayer {
if (data.selected) {
return this.stateStyling.selected.color;
}
+ if (data.suppressed && data.styleType) {
+ return this.typeStyling.value.suppressedColor(data.styleType[0], data.suppressed);
+ }
if (data.styleType) {
return this.typeStyling.value.color(data.styleType[0]);
}
@@ -240,6 +246,9 @@ export default class RectangleLayer extends BaseLayer {
if (data.set) {
return this.typeStyling.value.annotationSetColor(data.set);
}
+ if (data.suppressed && data.styleType) {
+ return this.typeStyling.value.suppressedColor(data.styleType[0], data.suppressed);
+ }
if (data.styleType) {
return this.typeStyling.value.color(data.styleType[0]);
}
@@ -249,6 +258,9 @@ export default class RectangleLayer extends BaseLayer {
if (data.set) {
return this.typeStyling.value.opacity(data.set, true);
}
+ if (data.suppressed && data.styleType) {
+ return this.typeStyling.value.suppressedOpacity(data.styleType[0], data.suppressed);
+ }
if (data.styleType) {
return this.typeStyling.value.opacity(data.styleType[0]);
}
@@ -266,6 +278,9 @@ export default class RectangleLayer extends BaseLayer {
if (data.selected) {
return this.stateStyling.selected.opacity;
}
+ if (data.suppressed && data.styleType) {
+ return this.typeStyling.value.suppressedOpacity(data.styleType[0], data.suppressed);
+ }
if (data.styleType) {
return this.typeStyling.value.opacity(data.styleType[0]);
}
diff --git a/client/src/layers/AnnotationLayers/TextLayer.ts b/client/src/layers/AnnotationLayers/TextLayer.ts
index 2b6230af6..40c778de8 100644
--- a/client/src/layers/AnnotationLayers/TextLayer.ts
+++ b/client/src/layers/AnnotationLayers/TextLayer.ts
@@ -7,6 +7,8 @@ export interface TextData {
selected: boolean;
editing: boolean | string;
type: string;
+ /** Suppression type name when displaying as suppressed (blended color) */
+ suppressed?: string;
confidence: number;
text: string;
x: number;
@@ -57,20 +59,24 @@ function defaultFormatter(
const userCreated = annotation.track.attributes?.userCreated === true;
// Show pencil icon if detection is userModified OR if track is userCreated, and showUserCreatedIcon is true
const modifiedIndicator = (showUserCreatedIcon && (userModified || userCreated)) ? ' ✏️' : '';
+ // Suppressed-display detections label as 'Type - SuppressionType'
+ const displayType = annotation.suppressed
+ ? `${type} - ${annotation.suppressed}` : type;
if (typeStyling) {
const { showLabel, showConfidence } = typeStyling.labelSettings(type);
if (showLabel && !showConfidence) {
- text = type + modifiedIndicator;
+ text = displayType + modifiedIndicator;
} else if (showConfidence && !showLabel) {
text = `${confidence.toFixed(2)}${modifiedIndicator}`;
} else if (showConfidence && showLabel) {
- text = `${type}: ${confidence.toFixed(2)}${modifiedIndicator}`;
+ text = `${displayType}: ${confidence.toFixed(2)}${modifiedIndicator}`;
}
}
arr.push({
selected: annotation.selected,
editing: annotation.editing,
type: annotation.styleType[0],
+ suppressed: annotation.suppressed,
confidence,
text,
x: bounds[2],
@@ -161,6 +167,12 @@ export default class TextLayer extends BaseLayer {
if (this.stateStyling.disabled.color !== 'type') {
return this.stateStyling.disabled.color;
}
+ // Editing some OTHER detection must not strip the suppressed
+ // blend from this one ('editing' is set on every annotation
+ // while any edit is active).
+ if (data.suppressed) {
+ return this.typeStyling.value.suppressedColor(data.type, data.suppressed);
+ }
return this.typeStyling.value.color(data.type);
}
if (data.selected) {
@@ -168,6 +180,9 @@ export default class TextLayer extends BaseLayer {
}
return this.typeStyling.value.color(data.type);
}
+ if (data.suppressed) {
+ return this.typeStyling.value.suppressedColor(data.type, data.suppressed);
+ }
return this.typeStyling.value.color(data.type);
},
offset: (data) => ({
diff --git a/client/src/layers/LayerTypes.ts b/client/src/layers/LayerTypes.ts
index 0df9ae050..7a21d268b 100644
--- a/client/src/layers/LayerTypes.ts
+++ b/client/src/layers/LayerTypes.ts
@@ -20,6 +20,12 @@ export interface FrameDataTrack {
/* The exact pair to base the style on */
styleType: [string, number];
+ /* Suppression type name when the detection displays as suppressed
+ * (under a region or flagged by the suppression attribute): layers
+ * label it 'Type - SuppressionType' and blend styling 80/20 toward
+ * the suppression type. */
+ suppressed?: string;
+
/* The Set if it exists for the Track */
set?: string;
diff --git a/client/src/use/suppression.spec.ts b/client/src/use/suppression.spec.ts
new file mode 100644
index 000000000..aa9b1a041
--- /dev/null
+++ b/client/src/use/suppression.spec.ts
@@ -0,0 +1,168 @@
+///
+import Track, { TrackData } from '../track';
+import {
+ isSuppressedAttributeValue, hasSuppressionAttribute,
+ getSuppressedTrackIds, normalizeSuppressionThreshold, DEFAULT_SUPPRESSION_THRESHOLD,
+} from './suppression';
+
+function makeTrack(overrides: Partial = {}): 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[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 (99%) 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]));
+ });
+});
diff --git a/client/src/use/suppression.ts b/client/src/use/suppression.ts
index a67871269..aa6d05a30 100644
--- a/client/src/use/suppression.ts
+++ b/client/src/use/suppression.ts
@@ -3,9 +3,10 @@
*
* A "suppression region" is an annotation whose type matches the configured
* suppression type (clientSettings.typeSettings.suppressionType). Any detection
- * whose geometry lies at least SUPPRESSION_THRESHOLD (50%) under one or more
- * suppression regions on a given frame is treated as suppressed: it is hidden
- * from the canvas and excluded from type/track/detection counts.
+ * whose geometry lies at least the configured overlap threshold
+ * (clientSettings.typeSettings.suppressionThreshold, default 99%) under one
+ * or more suppression regions on a given frame is treated as suppressed: it
+ * is hidden from the canvas and excluded from type/track/detection counts.
*
* Overlap uses whichever geometry each side actually has: the region's polygon
* if it has one, else its bounding box; likewise for the detection. The overlap
@@ -19,14 +20,28 @@ import type BaseAnnotationStore from 'vue-media-annotator/BaseAnnotationStore';
import type Track from 'vue-media-annotator/track';
import type { Feature } from 'vue-media-annotator/track';
-export const SUPPRESSION_THRESHOLD = 0.5;
+export const DEFAULT_SUPPRESSION_THRESHOLD = 0.99;
+
+/**
+ * Normalize the stored suppression-overlap setting (a percent, 0-100) to a
+ * fraction, falling back to the default (99%) for missing or out-of-range
+ * values.
+ */
+export function normalizeSuppressionThreshold(percent: number | undefined): number {
+ const p = Number(percent);
+ if (!Number.isFinite(p) || p <= 0 || p > 100) {
+ return DEFAULT_SUPPRESSION_THRESHOLD;
+ }
+ return p / 100;
+}
type Pt = [number, number];
type Rect = [number, number, number, number];
interface Shape { poly?: Pt[]; bbox: Rect }
-// Sampling resolution used to estimate the covered-area fraction. 16x16 is
-// plenty for a 50% threshold and keeps the per-frame cost low.
+// Sampling resolution used to estimate the covered-area fraction. 16x16
+// keeps the per-frame cost low; at a 99% threshold it resolves the covered
+// fraction to ~0.4% granularity on typical detections.
const GRID = 16;
function bboxOfPoly(poly: Pt[]): Rect {
@@ -95,17 +110,54 @@ function overlapFraction(det: Shape, regions: Shape[]): number {
return inDet === 0 ? 0 : covered / inDet;
}
+/**
+ * Loose truthiness for a suppression attribute value set by a user or
+ * pipeline: true, a nonzero number, or 'true'/'yes'/'on'/'1' (any case).
+ */
+export function isSuppressedAttributeValue(value: unknown): boolean {
+ if (value === true) return true;
+ if (typeof value === 'number') return value !== 0;
+ if (typeof value === 'string') {
+ return ['true', 'yes', 'on', '1'].includes(value.trim().toLowerCase());
+ }
+ return false;
+}
+
+/**
+ * True when the detection carries the suppression attribute: an attribute
+ * named after the suppression type, set on the track or on its detection at
+ * `frame` (falling back to the previous keyframe when the frame is
+ * interpolated). Unlike region suppression this is display-only: the
+ * detection stays visible but is styled, labeled, and counted as the
+ * suppression type instead of its own.
+ */
+export function hasSuppressionAttribute(
+ track: Track,
+ frame: number,
+ suppressionType: string | undefined,
+): boolean {
+ if (!suppressionType) return false;
+ if (isSuppressedAttributeValue(track.attributes?.[suppressionType])) return true;
+ const [real, lower] = track.getFeature(frame);
+ const feature = real?.attributes ? real : lower;
+ return isSuppressedAttributeValue(feature?.attributes?.[suppressionType]);
+}
+
/**
* Track ids whose detection on `frame` is suppressed by a region on that frame.
* Empty when suppressionType is falsy (feature disabled) or no regions exist.
+ * `thresholdPercent` is the minimum covered fraction as a percent (0-100];
+ * missing or out-of-range values fall back to the default (99%).
*/
export function getSuppressedTrackIds(
trackStore: BaseAnnotationStore