Add motion detector unit tests

This commit is contained in:
2026-03-08 18:00:00 +00:00
parent f6849f425c
commit ab35b00a91
2 changed files with 249 additions and 82 deletions

View File

@@ -5,12 +5,12 @@ const DEFAULT_FRAME_HEIGHT = 90;
const PIXEL_DELTA_THRESHOLD = 18; const PIXEL_DELTA_THRESHOLD = 18;
const DEFAULT_SMOOTHING_FACTOR = 0.35; const DEFAULT_SMOOTHING_FACTOR = 0.35;
const clampScore = (value) => { export const clampScore = (value) => {
if (!Number.isFinite(value)) return 0; if (!Number.isFinite(value)) return 0;
return Math.min(1, Math.max(0, value)); return Math.min(1, Math.max(0, value));
}; };
const computeLuma = (imageData) => { export const computeLuma = (imageData) => {
const luminance = new Float32Array(imageData.data.length / 4); const luminance = new Float32Array(imageData.data.length / 4);
for (let offset = 0, index = 0; offset < imageData.data.length; offset += 4, index += 1) { for (let offset = 0, index = 0; offset < imageData.data.length; offset += 4, index += 1) {
luminance[index] = luminance[index] =
@@ -19,6 +19,160 @@ const computeLuma = (imageData) => {
return luminance; 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 = ({ export const createMotionDetector = ({
getSourceElement, getSourceElement,
getConfig, getConfig,
@@ -36,10 +190,7 @@ export const createMotionDetector = ({
let timerId = null; let timerId = null;
let previousFrame = null; let previousFrame = null;
let smoothedScore = 0; let smoothedScore = 0;
let consecutiveHighFrames = 0; let machineSnapshot = createMotionMachineSnapshot();
let activeMotion = false;
let quietSince = null;
let activeSince = null;
let currentState = 'idle'; let currentState = 'idle';
const emitUpdate = (updates = {}) => { const emitUpdate = (updates = {}) => {
@@ -48,9 +199,9 @@ export const createMotionDetector = ({
onUpdate?.({ onUpdate?.({
state: currentState, state: currentState,
score, score,
activeMotion, activeMotion: machineSnapshot.activeMotion,
activeSince, activeSince: machineSnapshot.activeSince,
quietSince quietSince: machineSnapshot.quietSince
}); });
}; };
@@ -65,10 +216,7 @@ export const createMotionDetector = ({
clearTimer(); clearTimer();
previousFrame = null; previousFrame = null;
smoothedScore = 0; smoothedScore = 0;
consecutiveHighFrames = 0; machineSnapshot = createMotionMachineSnapshot();
activeMotion = false;
quietSince = null;
activeSince = null;
emitUpdate({ state, score: 0 }); emitUpdate({ state, score: 0 });
}; };
@@ -103,14 +251,7 @@ export const createMotionDetector = ({
return { state: 'warming_up', score: 0 }; return { state: 'warming_up', score: 0 };
} }
let changedPixels = 0; const rawScore = computeChangedPixelRatio(previousFrame, currentFrame, PIXEL_DELTA_THRESHOLD);
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;
previousFrame = currentFrame; previousFrame = currentFrame;
smoothedScore = clampScore(smoothedScore * (1 - smoothingFactor) + rawScore * smoothingFactor); 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 () => { const tick = async () => {
if (!active) return; if (!active) return;
const config = getConfig?.() ?? {}; const config = getConfig?.() ?? {};
const analysis = analyzeFrame(); const analysis = analyzeFrame();
const next = applyStateMachine(analysis, config); const next = applyMotionStateMachine(machineSnapshot, analysis, config, Date.now());
emitUpdate(next); 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); scheduleNextTick(activeInterval ? config.burstIntervalMs ?? 300 : config.sampleIntervalMs ?? 1000);
}; };
@@ -208,7 +296,7 @@ export const createMotionDetector = ({
active, active,
state: currentState, state: currentState,
score: clampScore(smoothedScore), score: clampScore(smoothedScore),
activeMotion activeMotion: machineSnapshot.activeMotion
}; };
} }
}; };

View File

@@ -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();
});
});