From a77adff415a40aed7a4af9366dff0edb8215677e Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Wed, 15 Jul 2026 16:24:55 -0400 Subject: [PATCH 1/4] Add suppression regions: hide and un-count detections beneath them Add a "Suppression Region Type" setting (typeSettings.suppressionType, default 'Suppressed', empty disables) selectable in the type-settings panel. Any detection whose geometry lies >=50% under an annotation of that type on a frame is hidden from every annotation layer and excluded from the per-frame type counts and the frame-filtered track list. Overlap uses each side's polygon when it has one and its bounding box otherwise, estimated by point sampling so the concave warped field-of-view polygons the sea-lion suppressor emits are handled without a polygon-clipping dependency. --- .../components/TypeSettingsPanel.vue | 34 +++++ client/dive-common/store/settings.ts | 4 + client/src/components/FilterList.vue | 9 ++ client/src/components/LayerManager.vue | 11 ++ client/src/components/Tracks/TrackList.vue | 11 ++ client/src/use/suppression.ts | 134 ++++++++++++++++++ 6 files changed, 203 insertions(+) create mode 100644 client/src/use/suppression.ts diff --git a/client/dive-common/components/TypeSettingsPanel.vue b/client/dive-common/components/TypeSettingsPanel.vue index 79b3e6244..eff48c6c4 100644 --- a/client/dive-common/components/TypeSettingsPanel.vue +++ b/client/dive-common/components/TypeSettingsPanel.vue @@ -25,6 +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.', }); const importInstructions = ref('Please provide a list of types (separated by a new line) that you would like to import'); const importDialog = ref(false); @@ -279,6 +280,39 @@ export default defineComponent({ + + + + + + + + {{ help.suppressionType }} + + + diff --git a/client/dive-common/store/settings.ts b/client/dive-common/store/settings.ts index 47adc672e..badebebb1 100644 --- a/client/dive-common/store/settings.ts +++ b/client/dive-common/store/settings.ts @@ -22,6 +22,9 @@ 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. + suppressionType?: string; }; trackSettings: { newTrackSettings: { @@ -127,6 +130,7 @@ const defaultSettings: AnnotationSettings = { lockTypes: false, preventCascadeTypes: false, maxCountButton: false, + suppressionType: 'Suppressed', }, rowsPerPage: 20, annotationFPS: 10, diff --git a/client/src/components/FilterList.vue b/client/src/components/FilterList.vue index de3aa24aa..c0b91f92d 100644 --- a/client/src/components/FilterList.vue +++ b/client/src/components/FilterList.vue @@ -17,6 +17,7 @@ import BaseFilterControls from '../BaseFilterControls'; import Track from '../track'; import Group from '../Group'; import StyleManager from '../StyleManager'; +import { getSuppressedTrackIds } from '../use/suppression'; interface VirtualTypeItem { type: string; @@ -150,7 +151,15 @@ export default defineComponent({ const trackIdsForFrame = trackStore?.intervalTree .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. + const suppressedIds = trackStore + ? getSuppressedTrackIds(trackStore, frame.value, clientSettings.typeSettings.suppressionType) + : 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]; return !!keyframe?.keyframe; }); diff --git a/client/src/components/LayerManager.vue b/client/src/components/LayerManager.vue index 83010be8e..12e7db7a0 100644 --- a/client/src/components/LayerManager.vue +++ b/client/src/components/LayerManager.vue @@ -21,6 +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 { VisibleAnnotationTypes } from '../layers'; import UILayer from '../layers/UILayers/UILayer'; import ToolTipWidget from '../layers/UILayers/ToolTipWidget.vue'; @@ -344,6 +345,11 @@ 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). + const suppressedIds = trackStore + ? getSuppressedTrackIds(trackStore, frame, clientSettings.typeSettings.suppressionType) + : new Set(); currentFrameIds.forEach( (trackId: AnnotationId) => { const track = trackStore?.getPossible(trackId); @@ -352,6 +358,9 @@ export default defineComponent({ // TODO: Find a better way to represent tracks outside of cameras return; } + if (suppressedIds.has(trackId)) { + return; + } const enabledIndex = enabledTracks.findIndex( (trackWithContext) => trackWithContext.annotation.id === trackId, @@ -607,6 +616,8 @@ export default defineComponent({ toRef(props, 'colorBy'), selectedCamera, selectedKeyRef, + // re-render when the suppression-region type is changed + () => clientSettings.typeSettings.suppressionType, ], () => { refreshLayers(); diff --git a/client/src/components/Tracks/TrackList.vue b/client/src/components/Tracks/TrackList.vue index cbb7d89fd..e2d974701 100644 --- a/client/src/components/Tracks/TrackList.vue +++ b/client/src/components/Tracks/TrackList.vue @@ -18,8 +18,10 @@ import { useTrackStyleManager, useMultiSelectList, useCameraStore, + useSelectedCamera, } from '../../provides'; import useVirtualScrollTo from '../../use/useVirtualScrollTo'; +import { getSuppressedTrackIds } from '../../use/suppression'; import SideBarTrackListView from './sidebar/SideBarTrackListView.vue'; import BottomBarTrackListView from './bottombar/BottomBarTrackListView.vue'; @@ -81,6 +83,7 @@ export default defineComponent({ const editingModeRef = useEditingMode(); const selectedTrackIdRef = useSelectedTrackId(); const cameraStore = useCameraStore(); + const selectedCamera = useSelectedCamera(); const filteredTracksRef = trackFilters.filteredAnnotations; const typeStylingRef = useTrackStyleManager().typeStyling; const { frame: frameRef, isPlaying } = useTime(); @@ -109,7 +112,15 @@ export default defineComponent({ const finalFilteredTracks = computed(() => { let tracks = filteredTracksRef.value; if (filterDetectionsByFrame.value && !isPlaying.value) { + const suppressCamStore = cameraStore.camMap.value.get(selectedCamera.value)?.trackStore; + const suppType = clientSettings.typeSettings.suppressionType; + const suppressedIds = suppressCamStore + ? getSuppressedTrackIds(suppressCamStore, frameRef.value, suppType) + : new Set(); tracks = tracks.filter((track) => { + if (suppressedIds.has(track.annotation.id)) { + return false; + } const possibleTrack = cameraStore.getAnyPossibleTrack(track.annotation.id); if (possibleTrack) { const [feature] = possibleTrack.getFeature(frameRef.value); diff --git a/client/src/use/suppression.ts b/client/src/use/suppression.ts new file mode 100644 index 000000000..a67871269 --- /dev/null +++ b/client/src/use/suppression.ts @@ -0,0 +1,134 @@ +/** + * Suppression regions. + * + * 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. + * + * 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 + * fraction (area of the detection covered by the union of regions, divided by + * the detection's own area) is estimated by point sampling, which handles the + * concave, warped field-of-view polygons the sea-lion suppressor emits without + * a polygon-clipping dependency. + */ +import type { AnnotationId } from 'vue-media-annotator/BaseAnnotation'; +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; + +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. +const GRID = 16; + +function bboxOfPoly(poly: Pt[]): Rect { + let x1 = Infinity; let y1 = Infinity; let x2 = -Infinity; let y2 = -Infinity; + poly.forEach(([x, y]) => { + x1 = Math.min(x1, x); y1 = Math.min(y1, y); + x2 = Math.max(x2, x); y2 = Math.max(y2, y); + }); + return [x1, y1, x2, y2]; +} + +/** Geometry of a detection/region on a frame: its polygon if present, else its box. */ +function featureShape(feature: Feature | null): Shape | null { + if (!feature) return null; + const polyFeat = feature.geometry?.features?.find( + (f) => f.geometry?.type === 'Polygon', + ); + if (polyFeat) { + const ring = (polyFeat.geometry as GeoJSON.Polygon).coordinates[0]; + if (ring && ring.length >= 3) { + const poly = ring.map((c) => [c[0], c[1]] as Pt); + return { poly, bbox: bboxOfPoly(poly) }; + } + } + if (feature.bounds) return { bbox: feature.bounds as Rect }; + return null; +} + +function pointInPoly(px: number, py: number, poly: Pt[]): boolean { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) { + const [xi, yi] = poly[i]; + const [xj, yj] = poly[j]; + if (((yi > py) !== (yj > py)) + && (px < ((xj - xi) * (py - yi)) / (yj - yi) + xi)) { + inside = !inside; + } + } + return inside; +} + +function pointInShape(px: number, py: number, shape: Shape): boolean { + if (shape.poly) return pointInPoly(px, py, shape.poly); + const [x1, y1, x2, y2] = shape.bbox; + return px >= x1 && px <= x2 && py >= y1 && py <= y2; +} + +/** Fraction of the detection's area covered by the union of the regions. */ +function overlapFraction(det: Shape, regions: Shape[]): number { + const [x1, y1, x2, y2] = det.bbox; + const w = x2 - x1; + const h = y2 - y1; + if (w <= 0 || h <= 0) return 0; + let inDet = 0; + let covered = 0; + for (let iy = 0; iy < GRID; iy += 1) { + const py = y1 + ((iy + 0.5) / GRID) * h; + for (let ix = 0; ix < GRID; ix += 1) { + const px = x1 + ((ix + 0.5) / GRID) * w; + if (pointInShape(px, py, det)) { + inDet += 1; + if (regions.some((r) => pointInShape(px, py, r))) covered += 1; + } + } + } + return inDet === 0 ? 0 : covered / inDet; +} + +/** + * 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. + */ +export function getSuppressedTrackIds( + trackStore: BaseAnnotationStore, + frame: number, + suppressionType: string | undefined, +): Set { + const result = new Set(); + if (!suppressionType) return result; + const ids = trackStore.intervalTree.search([frame, frame]) + .map((s: string) => parseInt(s, 10)); + if (ids.length === 0) return result; + + const regions: Shape[] = []; + const candidates: { id: AnnotationId; shape: Shape }[] = []; + ids.forEach((id) => { + const track = trackStore.getPossible(id); + if (!track) return; + const shape = featureShape(track.getFeature(frame)[0]); + if (!shape) return; + if (track.confidencePairs.some(([t]) => t === suppressionType)) { + regions.push(shape); + } else { + candidates.push({ id, shape }); + } + }); + if (regions.length === 0) return result; + + candidates.forEach(({ id, shape }) => { + if (overlapFraction(shape, regions) >= SUPPRESSION_THRESHOLD) { + result.add(id); + } + }); + return result; +} From 55c4e7170791fadf8a873dea71672ade58b625a7 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Wed, 15 Jul 2026 18:03:14 -0400 Subject: [PATCH 2/4] Suppression regions: keep type counts in sync with region edits Exclude fully-suppressed detections (every keyframe covered by a region, across all cameras) from the dataset-wide per-type totals, and make both the totals and the per-frame counts re-evaluate when a region is moved by depending on the edit counter - a track's geometry is not itself a reactive dependency, so without this the counts went stale while the canvas correctly hid detections. --- client/src/components/FilterList.vue | 69 +++++++++++++++++++--- client/src/components/Tracks/TrackList.vue | 7 ++- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/client/src/components/FilterList.vue b/client/src/components/FilterList.vue index c0b91f92d..1739bbbcd 100644 --- a/client/src/components/FilterList.vue +++ b/client/src/components/FilterList.vue @@ -9,6 +9,7 @@ import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; import { clientSettings } from 'dive-common/store/settings'; import { useCameraStore, useHandler, useReadOnlyMode, useSelectedCamera, useTime, + usePendingSaveCount, } from '../provides'; import TooltipBtn from './TooltipButton.vue'; import TypeEditor from './TypeEditor.vue'; @@ -69,6 +70,10 @@ export default defineComponent({ const cameraStore = useCameraStore(); const selectedCamera = useSelectedCamera(); const { frame } = useTime(); + // Bumped on every annotation edit (add/move/delete); read in the frame + // counts so they re-evaluate suppression when a region is moved, since a + // track's geometry is not itself a reactive dependency. + const pendingSaveCount = usePendingSaveCount(); const trackStore = cameraStore.camMap.value.get(selectedCamera.value)?.trackStore; // Ordering of these lists should match const sortingMethods: ('a-z' | 'count' | 'frame count')[] = ['a-z', 'count', 'frame count']; @@ -138,22 +143,70 @@ export default defineComponent({ } } - const typeCounts = computed(() => filteredTracksRef.value.reduce((acc, filteredTrack) => { - const confidencePair = filteredTrack.annotation - .getType(filteredTrack.context.confidencePairIndex); - const trackType = confidencePair; - acc.set(trackType, (acc.get(trackType) || 0) + 1); + /** + * 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. + */ + const fullySuppressedIds = computed(() => { + const editRevision = pendingSaveCount.value; + const suppType = clientSettings.typeSettings.suppressionType; + const excluded = new Set(); + if (!suppType || editRevision < 0) { + return excluded; + } + cameraStore.camMap.value.forEach(({ trackStore: store }) => { + const perFrame = new Map>(); + const suppressedAt = (f: number) => { + let s = perFrame.get(f); + if (s === undefined) { + s = getSuppressedTrackIds(store, f, suppType); + perFrame.set(f, s); + } + return s; + }; + store.annotationMap.forEach((annotation) => { + const track = annotation as Track; + if (typeof track.getFeature !== 'function') { + return; + } + const keyframes = track.features.filter((f) => f && f.keyframe); + if (keyframes.length > 0 + && keyframes.every((f) => suppressedAt(f.frame).has(track.id))) { + excluded.add(track.id); + } + }); + }); + return excluded; + }); - return acc; - }, new Map())); + const typeCounts = computed(() => { + const excluded = fullySuppressedIds.value; + return filteredTracksRef.value.reduce((acc, filteredTrack) => { + if (excluded.has(filteredTrack.annotation.id)) { + return acc; + } + const confidencePair = filteredTrack.annotation + .getType(filteredTrack.context.confidencePairIndex); + const trackType = confidencePair; + acc.set(trackType, (acc.get(trackType) || 0) + 1); + + return acc; + }, new Map()); + }); const filteredTracksForFrame = computed(() => { + // Depend on the edit counter so moving/resizing a suppression region + // (which mutates geometry, not the reactive track set) re-runs the count. + // It is always >= 0, so this reads the dependency without changing logic. + const editRevision = pendingSaveCount.value; const trackIdsForFrame = trackStore?.intervalTree .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. - const suppressedIds = trackStore + const suppressedIds = (trackStore && editRevision >= 0) ? getSuppressedTrackIds(trackStore, frame.value, clientSettings.typeSettings.suppressionType) : new Set(); const filteredKeyFrameTracks = filteredTracksRef.value.filter((track) => { diff --git a/client/src/components/Tracks/TrackList.vue b/client/src/components/Tracks/TrackList.vue index e2d974701..49e755d87 100644 --- a/client/src/components/Tracks/TrackList.vue +++ b/client/src/components/Tracks/TrackList.vue @@ -19,6 +19,7 @@ import { useMultiSelectList, useCameraStore, useSelectedCamera, + usePendingSaveCount, } from '../../provides'; import useVirtualScrollTo from '../../use/useVirtualScrollTo'; import { getSuppressedTrackIds } from '../../use/suppression'; @@ -84,6 +85,7 @@ export default defineComponent({ const selectedTrackIdRef = useSelectedTrackId(); const cameraStore = useCameraStore(); const selectedCamera = useSelectedCamera(); + const pendingSaveCount = usePendingSaveCount(); const filteredTracksRef = trackFilters.filteredAnnotations; const typeStylingRef = useTrackStyleManager().typeStyling; const { frame: frameRef, isPlaying } = useTime(); @@ -112,9 +114,12 @@ export default defineComponent({ const finalFilteredTracks = computed(() => { let tracks = filteredTracksRef.value; if (filterDetectionsByFrame.value && !isPlaying.value) { + // Depend on the edit counter so moving a suppression region re-runs the + // filter (geometry mutations are not reactive track-set changes). + const editRevision = pendingSaveCount.value; const suppressCamStore = cameraStore.camMap.value.get(selectedCamera.value)?.trackStore; const suppType = clientSettings.typeSettings.suppressionType; - const suppressedIds = suppressCamStore + const suppressedIds = (suppressCamStore && editRevision >= 0) ? getSuppressedTrackIds(suppressCamStore, frameRef.value, suppType) : new Set(); tracks = tracks.filter((track) => { From 0585cafd38193a2ed4df686173f9f321c906a701 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Thu, 16 Jul 2026 10:21:36 -0400 Subject: [PATCH 3/4] style adjustments, add icon to indicate suppressed type in the list --- client/dive-common/components/BottomPanel.vue | 1 + .../components/TypeSettingsPanel.vue | 2 +- client/src/components/FilterList.vue | 14 ++++++--- client/src/components/TypeItem.vue | 31 +++++++++++++++++++ 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/client/dive-common/components/BottomPanel.vue b/client/dive-common/components/BottomPanel.vue index a8588edd2..1a5a6c0d1 100644 --- a/client/dive-common/components/BottomPanel.vue +++ b/client/dive-common/components/BottomPanel.vue @@ -154,6 +154,7 @@ export default defineComponent({ :show-empty-types="clientSettings.typeSettings.showEmptyTypes" :height="130" :width="300" + :header-height="50" :style-manager="trackStyleManager" :filter-controls="trackFilters" :disabled="disableAnnotationFilters" diff --git a/client/dive-common/components/TypeSettingsPanel.vue b/client/dive-common/components/TypeSettingsPanel.vue index eff48c6c4..49db05311 100644 --- a/client/dive-common/components/TypeSettingsPanel.vue +++ b/client/dive-common/components/TypeSettingsPanel.vue @@ -280,7 +280,7 @@ export default defineComponent({ - + >, required: true, @@ -268,12 +271,14 @@ export default defineComponent({ if (filterTypesByFrame.value) { filteredTypeList = filteredTypeList.filter((item) => frameTrackTypesDeRef.get(item)); } + const suppressionType = clientSettings.typeSettings.suppressionType; return filteredTypeList.map((item) => ({ type: item, confidenceFilterNum: confidenceFiltersDeRef[item] || 0, displayText: `${typeCountsDeRef.get(item) || 0}:${frameTrackTypesDeRef.get(item) || 0} ${item}`, color: typeStylingDeRef.color(item), checked: checkedTypesDeRef.includes(item), + isSuppressionType: !!suppressionType && item === suppressionType, })); }); const headCheckState = computed(() => { @@ -311,7 +316,7 @@ export default defineComponent({ } } - const virtualHeight = computed(() => props.height - TypeListHeaderHeight); + const virtualHeight = computed(() => props.height - props.headerHeight); const goToPeakTrackFrame = (trackType: string) => { const frameCounts = new Map(); @@ -487,6 +492,7 @@ export default defineComponent({ :width="width" :display-max-button="showMaxFrameButton" :disabled="disableAnnotationFilters" + :is-suppression-type="item.isSuppressionType" @setCheckedTypes="updateCheckedType($event, item.type)" @goToMaxFrame="goToPeakTrackFrame($event)" @clickEdit="clickEdit" diff --git a/client/src/components/TypeItem.vue b/client/src/components/TypeItem.vue index 5281955d7..fadd8701f 100644 --- a/client/src/components/TypeItem.vue +++ b/client/src/components/TypeItem.vue @@ -40,6 +40,10 @@ export default defineComponent({ type: Boolean, default: false, }, + isSuppressionType: { + type: Boolean, + default: false, + }, }, setup(props, { emit }) { /* Horizontal padding is the width of checkbox, scrollbar, and edit button */ @@ -111,6 +115,28 @@ export default defineComponent({ Type has threshold set individually + + + + This type is used for suppression. + Detections lying 50% or more under its regions are hidden and excluded from counts. + + @@ -190,4 +216,9 @@ export default defineComponent({ padding: 0 5px; font-size: 12px; } + +.suppression-icon { + flex-shrink: 0; + align-self: center; +} From 1cce37968c6f7a73a932a5949f379a150dfd087d Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Thu, 16 Jul 2026 10:24:17 -0400 Subject: [PATCH 4/4] linting --- client/src/components/FilterList.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/FilterList.vue b/client/src/components/FilterList.vue index 7f71e011f..311f2cc94 100644 --- a/client/src/components/FilterList.vue +++ b/client/src/components/FilterList.vue @@ -271,7 +271,7 @@ export default defineComponent({ if (filterTypesByFrame.value) { filteredTypeList = filteredTypeList.filter((item) => frameTrackTypesDeRef.get(item)); } - const suppressionType = clientSettings.typeSettings.suppressionType; + const { suppressionType } = clientSettings.typeSettings; return filteredTypeList.map((item) => ({ type: item, confidenceFilterNum: confidenceFiltersDeRef[item] || 0,