Add motion detector unit tests
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
79
WebApp/src/lib/app/motion-detector.spec.js
Normal file
79
WebApp/src/lib/app/motion-detector.spec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user