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 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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
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