/** * Comparison with ground truth. Matching ignores labels: candidate pairs * within the tolerance are taken greedily by |dt|, so each truth event and * each detection is used at most once and the closest pairs win. A matched * pair with the right label is correct, with the wrong label a * misclassification; unmatched truth is a miss, an unmatched detection a * false positive. The confusion matrix carries all four kinds: an extra * "missed" column and a false-positive row. */ import type { Detection, GroundTruthEvent, Score, ScoredEvent } from './types.ts'; export function score( truth: GroundTruthEvent[], detections: Detection[], nTypes: number, toleranceS: number, ): Score { const pairs: { ti: number; di: number; dt: number }[] = []; // truth is sorted by t; walk a window rather than the full cross product. const dets = [...detections].sort((a, b) => a.t - b.t); let lo = 0; for (let di = 0; di < dets.length; di++) { while (lo < truth.length && truth[lo].t < dets[di].t - toleranceS) lo++; for (let ti = lo; ti < truth.length && truth[ti].t <= dets[di].t + toleranceS; ti++) { pairs.push({ ti, di, dt: Math.abs(truth[ti].t - dets[di].t) }); } } pairs.sort((a, b) => a.dt - b.dt); const truthMatch = new Array(truth.length).fill(-1); const detMatch = new Array(dets.length).fill(-1); for (const p of pairs) { if (truthMatch[p.ti] >= 0 || detMatch[p.di] >= 0) continue; truthMatch[p.ti] = p.di; detMatch[p.di] = p.ti; } // confusion: (nTypes + 1) x (nTypes + 1); row nTypes = false positives, // column nTypes = missed. const confusion = Array.from({ length: nTypes + 1 }, () => new Array(nTypes + 1).fill(0)); const perType = Array.from({ length: nTypes }, () => ({ correct: 0, misclassified: 0, missed: 0, falsePositives: 0, nTruth: 0, })); const events: ScoredEvent[] = []; let correct = 0, misclassified = 0, missed = 0, falsePositives = 0; for (let ti = 0; ti < truth.length; ti++) { const tr = truth[ti]; perType[tr.typeIndex].nTruth++; const di = truthMatch[ti]; if (di < 0) { missed++; perType[tr.typeIndex].missed++; confusion[tr.typeIndex][nTypes]++; events.push({ kind: 'missed', truth: tr }); } else { const d = dets[di]; confusion[tr.typeIndex][d.typeIndex]++; if (d.typeIndex === tr.typeIndex) { correct++; perType[tr.typeIndex].correct++; events.push({ kind: 'correct', truth: tr, det: d }); } else { misclassified++; perType[tr.typeIndex].misclassified++; events.push({ kind: 'misclassified', truth: tr, det: d }); } } } for (let di = 0; di < dets.length; di++) { if (detMatch[di] >= 0) continue; falsePositives++; const d = dets[di]; if (d.typeIndex >= 0 && d.typeIndex < nTypes) { perType[d.typeIndex].falsePositives++; confusion[nTypes][d.typeIndex]++; } events.push({ kind: 'falsePositive', det: d }); } const denom = correct + misclassified + missed + falsePositives; return { nTruth: truth.length, nDetected: dets.length, correct, misclassified, missed, falsePositives, accuracy: denom > 0 ? correct / denom : 1, confusion, events, perType, }; }