From 96e5db8e8dcaa8a5a7a8c416d829273b648b8a94 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Tue, 21 Jul 2026 17:13:17 -0400 Subject: [PATCH 1/2] Display detections flagged with a suppression attribute as suppressed A detection outside any suppression region can carry an attribute named after the suppression type ("Suppressed" by default), set on the track or on the detection at a frame. Such a detection now stays visible but is treated as suppressed for display purposes: - it inherits the suppression type's style (color, opacity, fill) and its canvas label and hover tooltip show the suppression type, via a styleType override in LayerManager; - it no longer counts toward its own type in the type-list totals or the per-frame counts; - selecting it still shows its real type, which remains editable; the attribute only overrides display, never the stored type. Attribute values true, nonzero numbers, and 'true'/'yes'/'on'/'1' (any case) are all recognized. Interpolated frames fall back to the previous keyframe's detection attributes. Co-Authored-By: Claude Fable 5 --- .../components/TypeSettingsPanel.vue | 2 +- client/src/components/FilterList.vue | 27 +++-- client/src/components/LayerManager.vue | 15 ++- client/src/use/suppression.spec.ts | 103 ++++++++++++++++++ client/src/use/suppression.ts | 33 ++++++ 5 files changed, 167 insertions(+), 13 deletions(-) create mode 100644 client/src/use/suppression.spec.ts diff --git a/client/dive-common/components/TypeSettingsPanel.vue b/client/dive-common/components/TypeSettingsPanel.vue index 49db05311..ea908de03 100644 --- a/client/dive-common/components/TypeSettingsPanel.vue +++ b/client/dive-common/components/TypeSettingsPanel.vue @@ -25,7 +25,7 @@ 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 50% or more 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.', }); const importInstructions = ref('Please provide a list of types (separated by a new line) that you would like to import'); const importDialog = ref(false); diff --git a/client/src/components/FilterList.vue b/client/src/components/FilterList.vue index 311f2cc94..acd9cfedf 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,10 +147,11 @@ 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; @@ -176,7 +177,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 +210,22 @@ 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) : 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..db23aa71e 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'; @@ -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 } = clientSettings.typeSettings; const suppressedIds = trackStore - ? getSuppressedTrackIds(trackStore, frame, clientSettings.typeSettings.suppressionType) + ? getSuppressedTrackIds(trackStore, frame, suppressionType) : new Set(); currentFrameIds.forEach( (trackId: AnnotationId) => { @@ -372,6 +373,14 @@ 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))), @@ -379,7 +388,7 @@ export default defineComponent({ track, groups, features, - styleType: colorBy === 'group' ? groupStyleType : trackStyleType, + styleType, set: track.set, }; frameData.push(trackFrame); diff --git a/client/src/use/suppression.spec.ts b/client/src/use/suppression.spec.ts new file mode 100644 index 000000000..1edbf685d --- /dev/null +++ b/client/src/use/suppression.spec.ts @@ -0,0 +1,103 @@ +/// +import Track, { TrackData } from '../track'; +import { isSuppressedAttributeValue, hasSuppressionAttribute } 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); + }); +}); diff --git a/client/src/use/suppression.ts b/client/src/use/suppression.ts index a67871269..a14545af9 100644 --- a/client/src/use/suppression.ts +++ b/client/src/use/suppression.ts @@ -95,6 +95,39 @@ 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. From 53402603662451dbff601fbb5e05e128bb8636a8 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Tue, 21 Jul 2026 21:13:55 -0400 Subject: [PATCH 2/2] Make the suppression-region overlap threshold configurable, default 95% Region suppression previously hid any detection lying >=50% under a suppression region. The minimum covered fraction is now a setting (Type Settings > Suppression Overlap %), defaulting to 95%, so only detections almost entirely under a region are hidden and excluded from counts. Out-of-range or missing values fall back to the default. Co-Authored-By: Claude Fable 5 --- .../components/TypeSettingsPanel.vue | 38 ++++++++++- client/dive-common/store/settings.ts | 9 ++- client/src/components/FilterList.vue | 10 ++- client/src/components/LayerManager.vue | 7 +- client/src/components/Tracks/TrackList.vue | 7 +- client/src/use/suppression.spec.ts | 67 ++++++++++++++++++- client/src/use/suppression.ts | 33 +++++++-- 7 files changed, 154 insertions(+), 17 deletions(-) diff --git a/client/dive-common/components/TypeSettingsPanel.vue b/client/dive-common/components/TypeSettingsPanel.vue index ea908de03..364a7dbed 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. 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.', + 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); @@ -313,6 +314,41 @@ export default defineComponent({ + + + + + + + + {{ help.suppressionThreshold }} + + + diff --git a/client/dive-common/store/settings.ts b/client/dive-common/store/settings.ts index badebebb1..ee54fb5da 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 (95). + suppressionThreshold?: number; }; trackSettings: { newTrackSettings: { @@ -131,6 +135,7 @@ const defaultSettings: AnnotationSettings = { preventCascadeTypes: false, maxCountButton: false, suppressionType: 'Suppressed', + suppressionThreshold: 95, }, rowsPerPage: 20, annotationFPS: 10, diff --git a/client/src/components/FilterList.vue b/client/src/components/FilterList.vue index acd9cfedf..58b54d9ff 100644 --- a/client/src/components/FilterList.vue +++ b/client/src/components/FilterList.vue @@ -156,6 +156,7 @@ export default defineComponent({ 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; @@ -165,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; @@ -215,7 +216,12 @@ export default defineComponent({ // suppression type) don't count toward their own type either. const suppType = clientSettings.typeSettings.suppressionType; const suppressedIds = (trackStore && editRevision >= 0) - ? getSuppressedTrackIds(trackStore, frame.value, suppType) + ? getSuppressedTrackIds( + trackStore, + frame.value, + suppType, + clientSettings.typeSettings.suppressionThreshold, + ) : new Set(); const filteredKeyFrameTracks = filteredTracksRef.value.filter((track) => { if (suppressedIds.has(track.annotation.id)) { diff --git a/client/src/components/LayerManager.vue b/client/src/components/LayerManager.vue index db23aa71e..723e980c2 100644 --- a/client/src/components/LayerManager.vue +++ b/client/src/components/LayerManager.vue @@ -347,9 +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 } = clientSettings.typeSettings; + const { suppressionType, suppressionThreshold } = clientSettings.typeSettings; const suppressedIds = trackStore - ? getSuppressedTrackIds(trackStore, frame, suppressionType) + ? getSuppressedTrackIds(trackStore, frame, suppressionType, suppressionThreshold) : new Set(); currentFrameIds.forEach( (trackId: AnnotationId) => { @@ -625,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(); 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/use/suppression.spec.ts b/client/src/use/suppression.spec.ts index 1edbf685d..27d2b7db0 100644 --- a/client/src/use/suppression.spec.ts +++ b/client/src/use/suppression.spec.ts @@ -1,6 +1,9 @@ /// import Track, { TrackData } from '../track'; -import { isSuppressedAttributeValue, hasSuppressionAttribute } from './suppression'; +import { + isSuppressedAttributeValue, hasSuppressionAttribute, + getSuppressedTrackIds, normalizeSuppressionThreshold, DEFAULT_SUPPRESSION_THRESHOLD, +} from './suppression'; function makeTrack(overrides: Partial = {}): Track { return Track.fromJSON({ @@ -101,3 +104,65 @@ describe('hasSuppressionAttribute', () => { 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 (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])); + }); +}); diff --git a/client/src/use/suppression.ts b/client/src/use/suppression.ts index a14545af9..dcbde1c63 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 95%) 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.95; + +/** + * Normalize the stored suppression-overlap setting (a percent, 0-100) to a + * fraction, falling back to the default (95%) 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 95% threshold it resolves the covered +// fraction to ~0.4% granularity on typical detections. const GRID = 16; function bboxOfPoly(poly: Pt[]): Rect { @@ -131,14 +146,18 @@ export function hasSuppressionAttribute( /** * 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 (95%). */ export function getSuppressedTrackIds( trackStore: BaseAnnotationStore, frame: number, suppressionType: string | undefined, + thresholdPercent?: number, ): Set { const result = new Set(); if (!suppressionType) return result; + const threshold = normalizeSuppressionThreshold(thresholdPercent); const ids = trackStore.intervalTree.search([frame, frame]) .map((s: string) => parseInt(s, 10)); if (ids.length === 0) return result; @@ -159,7 +178,7 @@ export function getSuppressedTrackIds( if (regions.length === 0) return result; candidates.forEach(({ id, shape }) => { - if (overlapFraction(shape, regions) >= SUPPRESSION_THRESHOLD) { + if (overlapFraction(shape, regions) >= threshold) { result.add(id); } });