lab-js/video-loader/analysis.jsjavascript
/**
* @module video-loader/analysis
* @description
* Pure numerical helpers and trigger policy for the seekable-video analyser.
* The module deliberately has no DOM or model dependencies so thresholds,
* baseline selection, and event decisions can be tested without loading the
* large vendored runtimes.
*/
/** Default face-api descriptor distance used by the vendored FaceMatcher. */
export const DEFAULT_FACE_DISTANCE_THRESHOLD = 0.6;
/**
* Computes Euclidean distance between equal-length vectors. The Video Loader
* uses this metric for face-api descriptors: `buildFaceBaseline()` uses it to
* choose the representative descriptor and measure baseline spread, while the
* controller uses it to compare later face-api checks with that baseline and
* the configured `0.6` identity-distance threshold.
*
* @param {ArrayLike<number>} left First vector.
* @param {ArrayLike<number>} right Second vector.
* @returns {number} L2 distance.
* @throws {Error} When vector lengths differ.
*/
export function euclideanDistance(left, right) {
if (left.length !== right.length) throw new Error('Vectors must have the same length.');
let sum = 0;
for (let index = 0; index < left.length; index += 1) {
const delta = left[index] - right[index];
sum += delta * delta;
}
return Math.sqrt(sum);
}
/**
* Computes cosine similarity between equal-length vectors. It is used only
* for MediaPipe ImageEmbedder vectors from aligned face crops. The baseline
* medoid and every later visual-drift sample use `1 - cosineSimilarity(...)`;
* this value is a generic appearance-change signal, not an identity score.
*
* @param {ArrayLike<number>} left First vector.
* @param {ArrayLike<number>} right Second vector.
* @returns {number} Similarity in the range -1..1, or zero for a zero vector.
* @throws {Error} When vector lengths differ.
*/
export function cosineSimilarity(left, right) {
if (left.length !== right.length) throw new Error('Vectors must have the same length.');
let dot = 0;
let leftNorm = 0;
let rightNorm = 0;
for (let index = 0; index < left.length; index += 1) {
dot += left[index] * right[index];
leftNorm += left[index] * left[index];
rightNorm += right[index] * right[index];
}
const denominator = Math.sqrt(leftNorm) * Math.sqrt(rightNorm);
return denominator === 0 ? 0 : dot / denominator;
}
/**
* Returns the median without mutating the caller's collection. Baseline
* construction uses it to summarise face-api descriptor spread and the centre
* of ImageEmbedder drift; `medianAbsoluteDeviation()` calls it again to derive
* the adaptive visual-change threshold from baseline noise.
*
* @param {number[]} values Numeric values.
* @returns {number} Median, or zero for an empty collection.
*/
export function median(values) {
if (!values.length) return 0;
const sorted = [...values].sort((left, right) => left - right);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
}
/**
* Computes median absolute deviation, a robust estimate of baseline noise.
*
* @param {number[]} values Numeric values.
* @returns {number} Median absolute deviation.
*/
export function medianAbsoluteDeviation(values) {
const centre = median(values);
return median(values.map((value) => Math.abs(value - centre)));
}
/**
* Picks the real vector with the smallest total distance to its peers. Both
* baseline builders use this helper: cosine distance selects a representative
* ImageEmbedder vector, and Euclidean distance selects a representative
* face-api descriptor. Keeping an observed vector, instead of an arithmetic
* mean, also lets the controller associate the visual baseline with a real
* sampled timestamp.
*
* @param {Array<ArrayLike<number>>} vectors Candidate vectors.
* @param {function(ArrayLike<number>, ArrayLike<number>): number} metric Distance metric.
* @returns {{vector: number[], index: number, distances: number[]}} Medoid and distances to it.
* @throws {Error} When no vector is provided.
*/
export function vectorMedoid(vectors, metric) {
if (!vectors.length) throw new Error('vectorMedoid() requires at least one vector.');
let bestIndex = 0;
let bestTotal = Infinity;
for (let left = 0; left < vectors.length; left += 1) {
let total = 0;
for (let right = 0; right < vectors.length; right += 1) {
if (left !== right) total += metric(vectors[left], vectors[right]);
}
if (total < bestTotal) {
bestTotal = total;
bestIndex = left;
}
}
const vector = Array.from(vectors[bestIndex]);
return {
vector,
index: bestIndex,
distances: vectors.map((candidate) => metric(candidate, vector)),
};
}
/**
* Builds a robust ImageEmbedder baseline and its adaptive drift threshold.
*
* ImageEmbedder is a generic visual model, not a biometric matcher. The
* threshold therefore comes from the current video's stable baseline rather
* than a universal identity claim.
*
* @param {Array<ArrayLike<number>>} embeddings Stable aligned-face embeddings.
* @param {object} [options] Threshold tuning.
* @param {number} [options.minimumDelta] Minimum drift above baseline noise.
* @param {number} [options.madMultiplier] Robust-noise multiplier.
* @returns {{embedding: number[], medoidIndex: number, medianDrift: number, mad: number, triggerThreshold: number, drifts: number[]}} Baseline model.
*/
export function buildEmbeddingBaseline(embeddings, options = {}) {
const minimumDelta = options.minimumDelta ?? 0.025;
const madMultiplier = options.madMultiplier ?? 6;
const result = vectorMedoid(embeddings, (left, right) => 1 - cosineSimilarity(left, right));
const medianDrift = median(result.distances);
const mad = medianAbsoluteDeviation(result.distances);
return {
embedding: result.vector,
medoidIndex: result.index,
medianDrift,
mad,
triggerThreshold: medianDrift + Math.max(minimumDelta, mad * madMultiplier),
drifts: result.distances,
};
}
/**
* Builds the face-api descriptor baseline as a medoid and reports its spread.
*
* @param {Array<ArrayLike<number>>} descriptors Stable face-api descriptors.
* @returns {{descriptor: number[], medoidIndex: number, spread: number}} Baseline descriptor.
*/
export function buildFaceBaseline(descriptors) {
const result = vectorMedoid(descriptors, euclideanDistance);
return {
descriptor: result.vector,
medoidIndex: result.index,
spread: median(result.distances),
};
}
/**
* Uses the human-selected ImageEmbedder vector as the actual baseline while
* nearby vectors estimate normal visual noise. The Video Loader uses this
* variant only for an explicitly anchored baseline, ensuring that later
* visual drift is computed from the frame the person selected rather than a
* medoid several seconds later.
*
* @param {Array<ArrayLike<number>>} embeddings Nearby stable embeddings.
* @param {number} referenceIndex Index of the explicitly selected frame.
* @param {object} [options] Adaptive-trigger tuning.
* @param {number} [options.minimumDelta] Minimum drift above baseline noise.
* @param {number} [options.madMultiplier] Robust-noise multiplier.
* @returns {{embedding: number[], referenceIndex: number, medianDrift: number, mad: number, triggerThreshold: number, drifts: number[]}} Anchored baseline model.
*/
export function buildAnchoredEmbeddingBaseline(embeddings, referenceIndex, options = {}) {
if (!embeddings[referenceIndex]) throw new Error('Anchored embedding baseline requires a valid reference index.');
const minimumDelta = options.minimumDelta ?? 0.025;
const madMultiplier = options.madMultiplier ?? 6;
const embedding = Array.from(embeddings[referenceIndex]);
const drifts = embeddings.map((candidate) => visualDrift(candidate, embedding));
const medianDrift = median(drifts);
const mad = medianAbsoluteDeviation(drifts);
return {
embedding,
referenceIndex,
medianDrift,
mad,
triggerThreshold: medianDrift + Math.max(minimumDelta, mad * madMultiplier),
drifts,
};
}
/**
* Uses the face-api descriptor from the human-selected frame as the identity
* baseline and measures nearby descriptor spread around it. The Video Loader
* uses this for explicit baseline selection so every later L2 distance truly
* originates from the visible baseline card.
*
* @param {Array<ArrayLike<number>>} descriptors Nearby stable descriptors.
* @param {number} referenceIndex Index of the selected frame descriptor.
* @returns {{descriptor: number[], referenceIndex: number, spread: number}} Anchored face-api baseline.
*/
export function buildAnchoredFaceBaseline(descriptors, referenceIndex) {
if (!descriptors[referenceIndex]) throw new Error('Anchored face baseline requires a valid reference index.');
const descriptor = Array.from(descriptors[referenceIndex]);
const distances = descriptors.map((candidate) => euclideanDistance(candidate, descriptor));
return {
descriptor,
referenceIndex,
spread: median(distances),
};
}
/**
* Computes ImageEmbedder visual drift from a baseline embedding.
*
* @param {ArrayLike<number>} embedding Current aligned-face embedding.
* @param {ArrayLike<number>} baseline Baseline aligned-face embedding.
* @returns {number} Cosine distance where zero means identical direction.
*/
export function visualDrift(embedding, baseline) {
return 1 - cosineSimilarity(embedding, baseline);
}
/**
* Creates the stateful policy that decides when expensive face-api work is
* justified. The policy retains numeric samples only; it never retains frames.
*
* @param {object} [options] Trigger policy overrides.
* @param {number} [options.heartbeatSeconds=4] Maximum gap between identity checks.
* @param {number} [options.minimumFaceApiGapSeconds=0.75] Cooldown between expensive checks.
* @param {number} [options.jumpDelta=0.035] Single-sample visual-drift jump.
* @param {number} [options.requiredVotes=2] Above-threshold samples required.
* @param {number} [options.voteWindow=3] Recent samples considered for votes.
* @returns {{evaluate: Function, markFaceApi: Function, reset: Function}} Trigger detector.
*/
export function createTriggerDetector(options = {}) {
const heartbeatSeconds = options.heartbeatSeconds ?? 4;
const minimumFaceApiGapSeconds = options.minimumFaceApiGapSeconds ?? 0.75;
const jumpDelta = options.jumpDelta ?? 0.035;
const requiredVotes = options.requiredVotes ?? 2;
const voteWindow = options.voteWindow ?? 3;
let previousPresence = null;
let previousDrift = null;
let lastFaceApiAt = -Infinity;
/** @type {boolean[]} */
let recentVotes = [];
return {
/**
* Evaluates one lightweight sample.
*
* @param {{mediaTime: number, facePresent: boolean, comparable: boolean, visualDrift: number|null, visualThreshold: number|null}} sample Current sample.
* @returns {{shouldVerify: boolean, reasons: string[], observations: string[]}} Trigger decision.
*/
evaluate(sample) {
const reasons = [];
const observations = [];
if (previousPresence !== null && sample.facePresent !== previousPresence) {
observations.push(sample.facePresent ? 'mediapipe-face-returned' : 'mediapipe-face-lost');
if (sample.facePresent) reasons.push('mediapipe-face-returned');
}
if (sample.comparable && sample.visualDrift !== null && sample.visualThreshold !== null) {
const aboveThreshold = sample.visualDrift > sample.visualThreshold;
recentVotes.push(aboveThreshold);
recentVotes = recentVotes.slice(-voteWindow);
const votes = recentVotes.filter(Boolean).length;
if (recentVotes.length >= requiredVotes && votes >= requiredVotes) reasons.push('visual-drift-sustained');
if (previousDrift !== null && sample.visualDrift - previousDrift > jumpDelta) {
reasons.push('visual-drift-jump');
}
previousDrift = sample.visualDrift;
} else {
recentVotes = [];
}
if (sample.comparable && sample.mediaTime - lastFaceApiAt >= heartbeatSeconds) {
reasons.push('identity-heartbeat');
}
previousPresence = sample.facePresent;
const outsideCooldown = sample.mediaTime - lastFaceApiAt >= minimumFaceApiGapSeconds;
return {
shouldVerify: sample.facePresent && sample.comparable && reasons.length > 0 && outsideCooldown,
reasons: [...new Set(reasons)],
observations,
};
},
/**
* Records completion of a face-api pass.
*
* @param {number} mediaTime Video timestamp in seconds.
* @returns {void}
*/
markFaceApi(mediaTime) {
lastFaceApiAt = mediaTime;
},
/**
* Clears temporal state before analysing another source.
*
* @returns {void}
*/
reset() {
previousPresence = null;
previousDrift = null;
lastFaceApiAt = -Infinity;
recentVotes = [];
},
};
}