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({ + + + + + + + + {{ 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, 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; @@ -126,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); } });