diff --git a/src/presence/coordinator.js b/src/presence/coordinator.js index d85e768..5fee487 100644 --- a/src/presence/coordinator.js +++ b/src/presence/coordinator.js @@ -3,7 +3,7 @@ */ import { loadSettings, saveSettings, DEFAULTS, ALERT_PRESETS } from './settings.js'; -import { PresenceState, trackCountsAsPerson } from './state.js'; +import { PresenceState, meetsPersonThreshold, trackCountsAsPerson } from './state.js'; import { FacePresenceState } from './face-state.js'; import { ALERT_FACE, @@ -116,9 +116,11 @@ export class PresenceCoordinator { frames?.addEventListener('change', () => { persist({ alertMode: 'custom', consecutiveFrames: Number(frames.value) }); }); - minPersons?.addEventListener('change', () => { + const persistMinPersons = () => { persist({ alertMode: 'custom', minPersonCount: Number(minPersons.value) }); - }); + }; + minPersons?.addEventListener('change', persistMinPersons); + minPersons?.addEventListener('input', persistMinPersons); interval?.addEventListener('change', () => { persist({ alertMode: 'custom', repeatIntervalSec: Number(interval.value) }); }); @@ -190,17 +192,29 @@ export class PresenceCoordinator { if (!this._running) return; const nowMs = performance.now(); + const personThresholdMet = meetsPersonThreshold(tracks, this.settings); const personResult = this.state.tick(tracks, this.settings, nowMs); const qualifyingTrackIds = new Set( tracks.filter((track) => trackCountsAsPerson(track, this.settings)).map((track) => track.id), ); - const faceResult = this.faceState.tick(faces, qualifyingTrackIds, this.settings, nowMs); + const faceResult = this.faceState.tick( + faces, + qualifyingTrackIds, + this.settings, + nowMs, + personThresholdMet, + ); const actualLevel = faceResult.present ? ALERT_FACE : personResult.present ? ALERT_PERSON : ALERT_NONE; - const hasRecentFaceSample = this.faceState.hasRecentSamples(qualifyingTrackIds, this.settings, nowMs); + const hasRecentFaceSample = this.faceState.hasRecentSamples( + qualifyingTrackIds, + this.settings, + nowMs, + personThresholdMet, + ); const firingLevel = shouldSuppressPersonAlert( this.alertState.level, actualLevel, diff --git a/src/presence/face-state.js b/src/presence/face-state.js index 0cdd5dc..64af6c5 100644 --- a/src/presence/face-state.js +++ b/src/presence/face-state.js @@ -17,9 +17,10 @@ export class FacePresenceState { * @param {Set} qualifyingTrackIds * @param {{ faceWindowMs: number, faceHits: number }} settings * @param {number} nowMs + * @param {boolean} [personThresholdMet=true] when false, keep pruning but do not record or qualify face alerts * @returns {{ present: boolean }} */ - tick(faces, qualifyingTrackIds, settings, nowMs) { + tick(faces, qualifyingTrackIds, settings, nowMs, personThresholdMet = true) { const windowStart = nowMs - settings.faceWindowMs; for (const [trackId, samples] of this.samplesByTrackId) { @@ -31,21 +32,23 @@ export class FacePresenceState { } } - const recordedTrackIds = new Set(); - for (const face of faces) { - if (!face.fresh) continue; - if (!qualifyingTrackIds.has(face.trackId)) continue; - if (recordedTrackIds.has(face.trackId)) continue; - recordedTrackIds.add(face.trackId); - const samples = this.samplesByTrackId.get(face.trackId) ?? []; - samples.push(nowMs); - this.samplesByTrackId.set(face.trackId, samples); - } + if (personThresholdMet) { + const recordedTrackIds = new Set(); + for (const face of faces) { + if (!face.fresh) continue; + if (!qualifyingTrackIds.has(face.trackId)) continue; + if (recordedTrackIds.has(face.trackId)) continue; + recordedTrackIds.add(face.trackId); + const samples = this.samplesByTrackId.get(face.trackId) ?? []; + samples.push(nowMs); + this.samplesByTrackId.set(face.trackId, samples); + } - for (const trackId of qualifyingTrackIds) { - const samples = this.samplesByTrackId.get(trackId) ?? []; - if (samples.length >= settings.faceHits) { - return { present: true }; + for (const trackId of qualifyingTrackIds) { + const samples = this.samplesByTrackId.get(trackId) ?? []; + if (samples.length >= settings.faceHits) { + return { present: true }; + } } } @@ -57,7 +60,8 @@ export class FacePresenceState { * @param {{ faceWindowMs: number }} settings * @param {number} nowMs */ - hasRecentSamples(qualifyingTrackIds, settings, nowMs) { + hasRecentSamples(qualifyingTrackIds, settings, nowMs, personThresholdMet = true) { + if (!personThresholdMet) return false; const windowStart = nowMs - settings.faceWindowMs; for (const trackId of qualifyingTrackIds) { const samples = this.samplesByTrackId.get(trackId) ?? []; diff --git a/tests/face-presence-state.test.js b/tests/face-presence-state.test.js index e907348..104e1d6 100644 --- a/tests/face-presence-state.test.js +++ b/tests/face-presence-state.test.js @@ -91,3 +91,17 @@ test('reset clears stored samples', () => { assert.equal(result.present, false); }); + +test('does not qualify face alerts until the person count threshold is met', () => { + const state = new FacePresenceState(); + const qualifying = new Set([7]); + const faces = [{ trackId: 7, fresh: true }]; + + state.tick(faces, qualifying, settings, 1000, false); + const belowThreshold = state.tick(faces, qualifying, settings, 1600, false); + assert.equal(belowThreshold.present, false); + assert.equal(state.hasRecentSamples(qualifying, settings, 1600, false), false); + + assert.equal(state.tick(faces, qualifying, settings, 2000, true).present, false); + assert.equal(state.tick(faces, qualifying, settings, 2600, true).present, true); +}); diff --git a/tests/presence-threshold.test.js b/tests/presence-threshold.test.js new file mode 100644 index 0000000..44f9925 --- /dev/null +++ b/tests/presence-threshold.test.js @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + countMatchingPersons, + meetsPersonThreshold, + trackCountsAsPerson, +} from '../src/presence/state.js'; + +const settings = { + minPersonCount: 2, + minScore: 0.35, + useConfirmedOnly: false, +}; + +function track(id, score = 0.9) { + return { id, missed: 0, confirmed: true, score }; +} + +test('meetsPersonThreshold requires enough qualifying tracks', () => { + assert.equal(meetsPersonThreshold([track(1)], settings), false); + assert.equal(meetsPersonThreshold([track(1), track(2)], settings), true); +}); + +test('countMatchingPersons ignores missed or low-score tracks', () => { + const tracks = [track(1), { ...track(2), missed: 1 }, { ...track(3), score: 0.1 }]; + assert.equal(countMatchingPersons(tracks, settings), 1); + assert.equal(trackCountsAsPerson(tracks[0], settings), true); + assert.equal(trackCountsAsPerson(tracks[1], settings), false); +});