diff --git a/WebApp/src/lib/app/motion-detector.js b/WebApp/src/lib/app/motion-detector.js index 3beb1e6..58878f0 100644 --- a/WebApp/src/lib/app/motion-detector.js +++ b/WebApp/src/lib/app/motion-detector.js @@ -5,12 +5,12 @@ const DEFAULT_FRAME_HEIGHT = 90; const PIXEL_DELTA_THRESHOLD = 18; const DEFAULT_SMOOTHING_FACTOR = 0.35; -const clampScore = (value) => { +export const clampScore = (value) => { if (!Number.isFinite(value)) return 0; return Math.min(1, Math.max(0, value)); }; -const computeLuma = (imageData) => { +export const computeLuma = (imageData) => { const luminance = new Float32Array(imageData.data.length / 4); for (let offset = 0, index = 0; offset < imageData.data.length; offset += 4, index += 1) { luminance[index] = @@ -19,6 +19,160 @@ const computeLuma = (imageData) => { return luminance; }; +export const computeChangedPixelRatio = (previousFrame, currentFrame, pixelDeltaThreshold = PIXEL_DELTA_THRESHOLD) => { + if (!previousFrame || !currentFrame || previousFrame.length !== currentFrame.length || currentFrame.length === 0) { + return 0; + } + + let changedPixels = 0; + for (let index = 0; index < currentFrame.length; index += 1) { + if (Math.abs(currentFrame[index] - previousFrame[index]) >= pixelDeltaThreshold) { + changedPixels += 1; + } + } + + return clampScore(changedPixels / currentFrame.length); +}; + +export const createMotionMachineSnapshot = (overrides = {}) => ({ + consecutiveHighFrames: 0, + activeMotion: false, + quietSince: null, + activeSince: null, + ...overrides +}); + +export const applyMotionStateMachine = (snapshot, analysis, config = {}, now = Date.now()) => { + const nextSnapshot = createMotionMachineSnapshot(snapshot); + const score = clampScore(analysis?.score); + const analysisState = analysis?.state ?? 'monitoring'; + const triggerThreshold = config.triggerThreshold ?? 0.12; + const releaseThreshold = config.releaseThreshold ?? 0.04; + const consecutiveRequired = config.consecutiveTriggerFrames ?? 3; + const minimumEventMs = config.minimumEventMs ?? 8000; + const cooldownMs = config.cooldownMs ?? 9000; + + if (analysisState === 'warming_up') { + nextSnapshot.consecutiveHighFrames = 0; + if (!nextSnapshot.activeMotion) { + nextSnapshot.quietSince = null; + return { + snapshot: nextSnapshot, + output: { + state: 'warming_up', + score, + activeMotion: nextSnapshot.activeMotion, + activeSince: nextSnapshot.activeSince, + quietSince: nextSnapshot.quietSince + } + }; + } + } + + if (!nextSnapshot.activeMotion) { + if (score >= triggerThreshold) { + nextSnapshot.consecutiveHighFrames += 1; + if (nextSnapshot.consecutiveHighFrames >= consecutiveRequired) { + nextSnapshot.activeMotion = true; + nextSnapshot.activeSince = now; + nextSnapshot.quietSince = null; + return { + snapshot: nextSnapshot, + output: { + state: 'triggered', + score, + activeMotion: nextSnapshot.activeMotion, + activeSince: nextSnapshot.activeSince, + quietSince: nextSnapshot.quietSince + } + }; + } + + return { + snapshot: nextSnapshot, + output: { + state: 'candidate_motion', + score, + activeMotion: nextSnapshot.activeMotion, + activeSince: nextSnapshot.activeSince, + quietSince: nextSnapshot.quietSince + } + }; + } + + nextSnapshot.consecutiveHighFrames = 0; + return { + snapshot: nextSnapshot, + output: { + state: analysisState === 'warming_up' ? 'warming_up' : 'monitoring', + score, + activeMotion: nextSnapshot.activeMotion, + activeSince: nextSnapshot.activeSince, + quietSince: nextSnapshot.quietSince + } + }; + } + + if (score >= releaseThreshold) { + nextSnapshot.quietSince = null; + return { + snapshot: nextSnapshot, + output: { + state: 'triggered', + score, + activeMotion: nextSnapshot.activeMotion, + activeSince: nextSnapshot.activeSince, + quietSince: nextSnapshot.quietSince + } + }; + } + + if (!nextSnapshot.quietSince) { + nextSnapshot.quietSince = now; + } + + if (now - (nextSnapshot.activeSince ?? now) < minimumEventMs) { + return { + snapshot: nextSnapshot, + output: { + state: 'triggered', + score, + activeMotion: nextSnapshot.activeMotion, + activeSince: nextSnapshot.activeSince, + quietSince: nextSnapshot.quietSince + } + }; + } + + if (now - nextSnapshot.quietSince >= cooldownMs) { + nextSnapshot.activeMotion = false; + nextSnapshot.activeSince = null; + nextSnapshot.quietSince = null; + nextSnapshot.consecutiveHighFrames = 0; + return { + snapshot: nextSnapshot, + output: { + state: 'cooldown', + score, + activeMotion: nextSnapshot.activeMotion, + activeSince: nextSnapshot.activeSince, + quietSince: nextSnapshot.quietSince + } + }; + } + + return { + snapshot: nextSnapshot, + output: { + state: 'cooldown', + score, + activeMotion: nextSnapshot.activeMotion, + activeSince: nextSnapshot.activeSince, + quietSince: nextSnapshot.quietSince + } + }; +}; + export const createMotionDetector = ({ getSourceElement, getConfig, @@ -36,10 +190,7 @@ export const createMotionDetector = ({ let timerId = null; let previousFrame = null; let smoothedScore = 0; - let consecutiveHighFrames = 0; - let activeMotion = false; - let quietSince = null; - let activeSince = null; + let machineSnapshot = createMotionMachineSnapshot(); let currentState = 'idle'; const emitUpdate = (updates = {}) => { @@ -48,9 +199,9 @@ export const createMotionDetector = ({ onUpdate?.({ state: currentState, score, - activeMotion, - activeSince, - quietSince + activeMotion: machineSnapshot.activeMotion, + activeSince: machineSnapshot.activeSince, + quietSince: machineSnapshot.quietSince }); }; @@ -65,10 +216,7 @@ export const createMotionDetector = ({ clearTimer(); previousFrame = null; smoothedScore = 0; - consecutiveHighFrames = 0; - activeMotion = false; - quietSince = null; - activeSince = null; + machineSnapshot = createMotionMachineSnapshot(); emitUpdate({ state, score: 0 }); }; @@ -103,14 +251,7 @@ export const createMotionDetector = ({ return { state: 'warming_up', score: 0 }; } - let changedPixels = 0; - for (let index = 0; index < currentFrame.length; index += 1) { - if (Math.abs(currentFrame[index] - previousFrame[index]) >= PIXEL_DELTA_THRESHOLD) { - changedPixels += 1; - } - } - - const rawScore = changedPixels / currentFrame.length; + const rawScore = computeChangedPixelRatio(previousFrame, currentFrame, PIXEL_DELTA_THRESHOLD); previousFrame = currentFrame; smoothedScore = clampScore(smoothedScore * (1 - smoothingFactor) + rawScore * smoothingFactor); @@ -120,72 +261,19 @@ export const createMotionDetector = ({ }; }; - const applyStateMachine = (analysis, config) => { - const now = Date.now(); - const score = clampScore(analysis.score); - const triggerThreshold = config.triggerThreshold ?? 0.12; - const releaseThreshold = config.releaseThreshold ?? 0.04; - const consecutiveRequired = config.consecutiveTriggerFrames ?? 3; - const minimumEventMs = config.minimumEventMs ?? 8000; - const cooldownMs = config.cooldownMs ?? 9000; - - if (analysis.state === 'warming_up') { - consecutiveHighFrames = 0; - if (!activeMotion) { - quietSince = null; - return { state: 'warming_up', score }; - } - } - - if (!activeMotion) { - if (score >= triggerThreshold) { - consecutiveHighFrames += 1; - if (consecutiveHighFrames >= consecutiveRequired) { - activeMotion = true; - activeSince = now; - quietSince = null; - return { state: 'triggered', score }; - } - return { state: 'candidate_motion', score }; - } - - consecutiveHighFrames = 0; - return { state: analysis.state === 'warming_up' ? 'warming_up' : 'monitoring', score }; - } - - if (score >= releaseThreshold) { - quietSince = null; - return { state: 'triggered', score }; - } - - if (!quietSince) { - quietSince = now; - } - - if (now - (activeSince ?? now) < minimumEventMs) { - return { state: 'triggered', score }; - } - - if (now - quietSince >= cooldownMs) { - activeMotion = false; - activeSince = null; - quietSince = null; - consecutiveHighFrames = 0; - return { state: 'cooldown', score }; - } - - return { state: 'cooldown', score }; - }; - const tick = async () => { if (!active) return; const config = getConfig?.() ?? {}; const analysis = analyzeFrame(); - const next = applyStateMachine(analysis, config); - emitUpdate(next); + const next = applyMotionStateMachine(machineSnapshot, analysis, config, Date.now()); + machineSnapshot = next.snapshot; + emitUpdate(next.output); - const activeInterval = next.state === 'candidate_motion' || next.state === 'triggered' || next.state === 'cooldown'; + const activeInterval = + next.output.state === 'candidate_motion' || + next.output.state === 'triggered' || + next.output.state === 'cooldown'; scheduleNextTick(activeInterval ? config.burstIntervalMs ?? 300 : config.sampleIntervalMs ?? 1000); }; @@ -208,7 +296,7 @@ export const createMotionDetector = ({ active, state: currentState, score: clampScore(smoothedScore), - activeMotion + activeMotion: machineSnapshot.activeMotion }; } }; diff --git a/WebApp/src/lib/app/motion-detector.spec.js b/WebApp/src/lib/app/motion-detector.spec.js new file mode 100644 index 0000000..3083acf --- /dev/null +++ b/WebApp/src/lib/app/motion-detector.spec.js @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; + +import { + applyMotionStateMachine, + computeChangedPixelRatio, + computeLuma, + createMotionMachineSnapshot +} from './motion-detector'; + +describe('motion-detector helpers', () => { + it('computes grayscale luminance for RGBA image data', () => { + const luminance = computeLuma({ + data: new Uint8ClampedArray([ + 255, 0, 0, 255, + 0, 255, 0, 255 + ]) + }); + + expect(luminance).toHaveLength(2); + expect(luminance[0]).toBeCloseTo(76.245, 3); + expect(luminance[1]).toBeCloseTo(149.685, 3); + }); + + it('reports the ratio of changed pixels above the delta threshold', () => { + const previousFrame = new Float32Array([0, 10, 20, 30]); + const currentFrame = new Float32Array([0, 40, 20, 60]); + + expect(computeChangedPixelRatio(previousFrame, currentFrame, 18)).toBeCloseTo(0.5, 5); + expect(computeChangedPixelRatio(previousFrame, currentFrame, 40)).toBe(0); + }); +}); + +describe('motion-detector state machine', () => { + const config = { + triggerThreshold: 0.12, + releaseThreshold: 0.04, + consecutiveTriggerFrames: 3, + minimumEventMs: 8000, + cooldownMs: 2000 + }; + + it('requires consecutive high-motion frames before triggering', () => { + let snapshot = createMotionMachineSnapshot(); + + let result = applyMotionStateMachine(snapshot, { state: 'monitoring', score: 0.2 }, config, 1000); + snapshot = result.snapshot; + expect(result.output.state).toBe('candidate_motion'); + expect(result.output.activeMotion).toBe(false); + + result = applyMotionStateMachine(snapshot, { state: 'monitoring', score: 0.2 }, config, 1300); + snapshot = result.snapshot; + expect(result.output.state).toBe('candidate_motion'); + expect(result.output.activeMotion).toBe(false); + + result = applyMotionStateMachine(snapshot, { state: 'monitoring', score: 0.2 }, config, 1600); + expect(result.output.state).toBe('triggered'); + expect(result.output.activeMotion).toBe(true); + expect(result.snapshot.activeSince).toBe(1600); + }); + + it('holds motion until minimum duration and quiet cooldown complete', () => { + let snapshot = createMotionMachineSnapshot({ + activeMotion: true, + activeSince: 1000 + }); + + let result = applyMotionStateMachine(snapshot, { state: 'monitoring', score: 0.01 }, config, 4000); + snapshot = result.snapshot; + expect(result.output.state).toBe('triggered'); + expect(result.snapshot.quietSince).toBe(4000); + expect(result.output.activeMotion).toBe(true); + + result = applyMotionStateMachine(snapshot, { state: 'monitoring', score: 0.01 }, config, 9500); + expect(result.output.state).toBe('cooldown'); + expect(result.output.activeMotion).toBe(false); + expect(result.snapshot.activeSince).toBeNull(); + expect(result.snapshot.quietSince).toBeNull(); + }); +});