@camstack/addon-post-analysis 1.2.101 → 1.2.102
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{dist-BQsMSX2A.js → dist-BwxXdV9d.js} +31 -51
- package/dist/{dist-ecY_-nDx.mjs → dist-CZm8slPB.mjs} +31 -51
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-D8HLGYbH.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-MP5xue-m.mjs} +2 -2
- package/dist/pipeline-analytics/{hostInit-Br0v_V7W.mjs → hostInit-CkptLue6.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +448 -54
- package/dist/pipeline-analytics/index.mjs +448 -54
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as encodeVectorBase64, A as RetrainStatusSchema, At as literal, B as TrackSourceSchema, C as NcSnoozeSchema, Ct as isDeviceScopedCap, D as OpsLogEntrySchema, Dt as array, E as NcTaxonomySchema, Et as _enum, F as SceneMonitorSchema, Ft as string, G as audioMetricsCapability, H as addonWidgetsSourceCapability, I as TIMELAPSE_DENSE_FLOOR_SEC, It as unknown, J as cosineSimilarity$1, K as audioModeOf, L as TimelapseRuleInputSchema, Lt as EventCategory, M as SCENE_DEFAULT_ANCHOR_THRESHOLD, Mt as object, N as SCENE_DEFAULT_UNCOVERED_POLICY, Nt as partialRecord, Ot as boolean, P as SCENE_DIVERGED, Pt as record, R as TimelapseRulePatchSchema, S as NcSnoozeInputSchema, St as hydrateSchema, T as NcSystemEventKindSchema, Tt as sleep, U as alarmPanelCapability, V as VISIT_MERGE_GAP_MS, W as assertTimelapseCadences, X as defineCustomActions, Y as customAction, Z as deriveRecordingMode, _ as NcRuleInputSchema, _t as zoneAnalyticsCapability, at as notificationRulesCapability, b as NcRuleTargetSchema, bt as DeviceType, c as EVENT_PAD_MS, ct as plateGalleryCapability, d as NC_ALARM_SYSTEM_EVENT_KINDS, et as faceGalleryCapability, f as NC_CONDITION_CATALOG, ft as sceneMonitorCapability, g as NcConditionDescriptorSchema, gt as videoclipsCapability, h as NC_TAXONOMY, ht as vectorDimFromBase64, i as DETECTION_MACRO_CLASSES, it as kebabToCamel, jt as number, k as RECORDING_EXPORT_MAX_READ_BYTES, kt as discriminatedUnion, l as LabelAttributionSchema, lt as readDeviceStateFrom, mt as systemEventFilterApplies, n as DEFAULT_EVENT_COLOR, nt as isDetectionMacroClass, o as DeclaredDevices, p as NC_DEFAULT_SNOOZE_MINUTES, pt as subKindsOf, q as buildEventKindDescriptor, rt as isScheduleActive, s as EVENT_KIND_BY_CAP, st as pipelineAnalyticsCapability, t as BaseDevice, u as MACRO_LABELS, ut as readTimelapseGeneratedAt, v as NcRulePatchSchema, vt as errMsg, w as NcSnoozeSuppressedSchema, wt as nodePin, x as NcScheduleSchema, xt as createEvent, y as NcRuleSchema, yt as BaseAddon, z as TimelapseRuleSchema } from "../dist-
|
|
1
|
+
import { $ as encodeVectorBase64, A as RetrainStatusSchema, At as literal, B as TrackSourceSchema, C as NcSnoozeSchema, Ct as isDeviceScopedCap, D as OpsLogEntrySchema, Dt as array, E as NcTaxonomySchema, Et as _enum, F as SceneMonitorSchema, Ft as string, G as audioMetricsCapability, H as addonWidgetsSourceCapability, I as TIMELAPSE_DENSE_FLOOR_SEC, It as unknown, J as cosineSimilarity$1, K as audioModeOf, L as TimelapseRuleInputSchema, Lt as EventCategory, M as SCENE_DEFAULT_ANCHOR_THRESHOLD, Mt as object, N as SCENE_DEFAULT_UNCOVERED_POLICY, Nt as partialRecord, Ot as boolean, P as SCENE_DIVERGED, Pt as record, R as TimelapseRulePatchSchema, S as NcSnoozeInputSchema, St as hydrateSchema, T as NcSystemEventKindSchema, Tt as sleep, U as alarmPanelCapability, V as VISIT_MERGE_GAP_MS, W as assertTimelapseCadences, X as defineCustomActions, Y as customAction, Z as deriveRecordingMode, _ as NcRuleInputSchema, _t as zoneAnalyticsCapability, at as notificationRulesCapability, b as NcRuleTargetSchema, bt as DeviceType, c as EVENT_PAD_MS, ct as plateGalleryCapability, d as NC_ALARM_SYSTEM_EVENT_KINDS, et as faceGalleryCapability, f as NC_CONDITION_CATALOG, ft as sceneMonitorCapability, g as NcConditionDescriptorSchema, gt as videoclipsCapability, h as NC_TAXONOMY, ht as vectorDimFromBase64, i as DETECTION_MACRO_CLASSES, it as kebabToCamel, jt as number, k as RECORDING_EXPORT_MAX_READ_BYTES, kt as discriminatedUnion, l as LabelAttributionSchema, lt as readDeviceStateFrom, mt as systemEventFilterApplies, n as DEFAULT_EVENT_COLOR, nt as isDetectionMacroClass, o as DeclaredDevices, p as NC_DEFAULT_SNOOZE_MINUTES, pt as subKindsOf, q as buildEventKindDescriptor, rt as isScheduleActive, s as EVENT_KIND_BY_CAP, st as pipelineAnalyticsCapability, t as BaseDevice, u as MACRO_LABELS, ut as readTimelapseGeneratedAt, v as NcRulePatchSchema, vt as errMsg, w as NcSnoozeSuppressedSchema, wt as nodePin, x as NcScheduleSchema, xt as createEvent, y as NcRuleSchema, yt as BaseAddon, z as TimelapseRuleSchema } from "../dist-CZm8slPB.mjs";
|
|
2
2
|
import { t as __exportAll } from "../embedding-encoder/index.mjs";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import { promises } from "node:fs";
|
|
@@ -8352,8 +8352,11 @@ var en_default = {
|
|
|
8352
8352
|
"one": "{{count}} addon update available",
|
|
8353
8353
|
"other": "{{count}} addon updates available"
|
|
8354
8354
|
},
|
|
8355
|
+
"system.addon-update-available.title.onNode": {
|
|
8356
|
+
"one": "{{count}} addon update available on {{nodeId}}",
|
|
8357
|
+
"other": "{{count}} addon updates available on {{nodeId}}"
|
|
8358
|
+
},
|
|
8355
8359
|
"system.addon-update-available.body": "{{list}}",
|
|
8356
|
-
"system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on node {{nodeId}}",
|
|
8357
8360
|
"system.server-update-available.title": {
|
|
8358
8361
|
"one": "Server update available on {{count}} node",
|
|
8359
8362
|
"other": "Server update available on {{count}} nodes"
|
|
@@ -8368,6 +8371,7 @@ var en_default = {
|
|
|
8368
8371
|
"system.addon-updated.body": "{{list}}",
|
|
8369
8372
|
"system.server-updated.title": "Server updated",
|
|
8370
8373
|
"system.server-updated.body": "{{packageName}} on {{nodeId}} is now {{toVersion}} (was {{fromVersion}})",
|
|
8374
|
+
"system.server-updated.body.toOnly": "{{packageName}} on {{nodeId}} is now {{toVersion}}",
|
|
8371
8375
|
"system.server-updated.body.plain": "{{packageName}} on {{nodeId}} was updated",
|
|
8372
8376
|
"system.export-completed.title": "Export ready",
|
|
8373
8377
|
"system.export-completed.body": "The footage export is ready",
|
|
@@ -8566,8 +8570,11 @@ var it_default = {
|
|
|
8566
8570
|
"one": "{{count}} aggiornamento addon disponibile",
|
|
8567
8571
|
"other": "{{count}} aggiornamenti addon disponibili"
|
|
8568
8572
|
},
|
|
8573
|
+
"system.addon-update-available.title.onNode": {
|
|
8574
|
+
"one": "{{count}} aggiornamento addon disponibile su {{nodeId}}",
|
|
8575
|
+
"other": "{{count}} aggiornamenti addon disponibili su {{nodeId}}"
|
|
8576
|
+
},
|
|
8569
8577
|
"system.addon-update-available.body": "{{list}}",
|
|
8570
|
-
"system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) sul nodo {{nodeId}}",
|
|
8571
8578
|
"system.server-update-available.title": {
|
|
8572
8579
|
"one": "Aggiornamento server disponibile su {{count}} nodo",
|
|
8573
8580
|
"other": "Aggiornamento server disponibile su {{count}} nodi"
|
|
@@ -8582,6 +8589,7 @@ var it_default = {
|
|
|
8582
8589
|
"system.addon-updated.body": "{{list}}",
|
|
8583
8590
|
"system.server-updated.title": "Server aggiornato",
|
|
8584
8591
|
"system.server-updated.body": "{{packageName}} sul nodo {{nodeId}} è ora {{toVersion}} (era {{fromVersion}})",
|
|
8592
|
+
"system.server-updated.body.toOnly": "{{packageName}} sul nodo {{nodeId}} è ora {{toVersion}}",
|
|
8585
8593
|
"system.server-updated.body.plain": "{{packageName}} sul nodo {{nodeId}} è stato aggiornato",
|
|
8586
8594
|
"system.export-completed.title": "Export pronto",
|
|
8587
8595
|
"system.export-completed.body": "L'export del filmato è pronto",
|
|
@@ -10404,15 +10412,15 @@ function incomingFromUpdateAvailable(facts, data) {
|
|
|
10404
10412
|
const nodeIds = unique(packages.map((pkg) => pkg.nodeId).filter((id) => id !== void 0));
|
|
10405
10413
|
const packageNames = unique(packages.map((pkg) => pkg.packageName));
|
|
10406
10414
|
const signature = packages.map((pkg) => `${pkg.nodeId ?? ""}:${pkg.packageName}@${pkg.latestVersion}`).toSorted().join(",");
|
|
10407
|
-
const list = packages.map(formatAvailableLine).join("\n");
|
|
10408
|
-
const nodes = nodeIds.join(", ");
|
|
10409
10415
|
const head = packages[0];
|
|
10410
10416
|
const oneNode = nodeIds.length === 1 ? nodeIds[0] : void 0;
|
|
10417
|
+
const list = packages.map((pkg) => formatAvailableLine(pkg, oneNode !== void 0)).join("\n");
|
|
10418
|
+
const nodes = nodeIds.join(", ");
|
|
10411
10419
|
return systemSubject({
|
|
10412
10420
|
facts,
|
|
10413
10421
|
kind,
|
|
10414
10422
|
subject: addon ? `packages:addon:${signature}` : `packages:server:${signature}`,
|
|
10415
|
-
titleKey: `system.${kind}.title`,
|
|
10423
|
+
titleKey: addon && oneNode !== void 0 ? `system.${kind}.title.onNode` : `system.${kind}.title`,
|
|
10416
10424
|
bodyKey: !addon && oneNode !== void 0 ? `system.${kind}.body.onNode` : !addon && nodeIds.length > 1 ? `system.${kind}.body.onNodes` : `system.${kind}.body`,
|
|
10417
10425
|
textParams: {
|
|
10418
10426
|
list,
|
|
@@ -10471,12 +10479,12 @@ function incomingFromAddonUpdates(facts, updates) {
|
|
|
10471
10479
|
function incomingFromServerUpdated(facts, data) {
|
|
10472
10480
|
const kind = stringField(data, "kind");
|
|
10473
10481
|
const requestedBy = stringField(data, "requestedBy");
|
|
10474
|
-
if (!(kind === "framework-update" || kind === "framework-bulk-update" || requestedBy !== void 0 && requestedBy.startsWith("server-update:"))) return null;
|
|
10475
10482
|
const packageName = stringField(data, "packageName") ?? "@camstack/server";
|
|
10476
10483
|
const fromVersion = stringField(data, "fromVersion");
|
|
10477
10484
|
const toVersion = stringField(data, "toVersion") ?? versionFromRequestedBy(requestedBy);
|
|
10485
|
+
if (!(kind === "framework-update" || kind === "framework-bulk-update" || requestedBy !== void 0 && requestedBy.startsWith("server-update:") && toVersion !== void 0)) return null;
|
|
10478
10486
|
const nodeId = stringField(data, "nodeId") ?? "hub";
|
|
10479
|
-
const bodyKey =
|
|
10487
|
+
const bodyKey = toVersion === void 0 ? "system.server-updated.body.plain" : fromVersion === void 0 ? "system.server-updated.body.toOnly" : "system.server-updated.body";
|
|
10480
10488
|
return systemSubject({
|
|
10481
10489
|
facts,
|
|
10482
10490
|
kind: "server-updated",
|
|
@@ -10557,9 +10565,15 @@ function updatePackages(data) {
|
|
|
10557
10565
|
...nodeId !== void 0 ? { nodeId } : {}
|
|
10558
10566
|
}];
|
|
10559
10567
|
}
|
|
10560
|
-
|
|
10561
|
-
|
|
10562
|
-
|
|
10568
|
+
/**
|
|
10569
|
+
* One line of an availability list — the SAME shape the completed-update list
|
|
10570
|
+
* uses (`incomingFromAddonUpdates`), and deliberately word-free: the list is a
|
|
10571
|
+
* frozen param, so any English in it ("current") is delivered verbatim to an
|
|
10572
|
+
* Italian household. An arrow is not a language.
|
|
10573
|
+
*/
|
|
10574
|
+
function formatAvailableLine(pkg, nodeNamedInTitle) {
|
|
10575
|
+
const line = `${pkg.packageName} ${pkg.currentVersion} → ${pkg.latestVersion}`;
|
|
10576
|
+
return pkg.nodeId !== void 0 && !nodeNamedInTitle ? `${line} (${pkg.nodeId})` : line;
|
|
10563
10577
|
}
|
|
10564
10578
|
function versionFromRequestedBy(requestedBy) {
|
|
10565
10579
|
if (requestedBy === void 0) return void 0;
|
|
@@ -28789,19 +28803,29 @@ var BEST_FRAME_POLICY = {
|
|
|
28789
28803
|
sizeAware: true
|
|
28790
28804
|
};
|
|
28791
28805
|
/**
|
|
28792
|
-
* Best FACE (face-recognizer hold buffer) —
|
|
28793
|
-
*
|
|
28794
|
-
*
|
|
28795
|
-
*
|
|
28796
|
-
*
|
|
28797
|
-
*
|
|
28798
|
-
*
|
|
28806
|
+
* Best FACE (face-recognizer hold buffer) — SIZE-AWARE, no hysteresis, no rate
|
|
28807
|
+
* limit.
|
|
28808
|
+
*
|
|
28809
|
+
* No hysteresis / no gap, unchanged: accepting a new best only swaps an
|
|
28810
|
+
* already-decoded crop buffer in memory (cheap; no capture is triggered), and a
|
|
28811
|
+
* track may live well under the best-frame rate-limit window, so a gap would
|
|
28812
|
+
* silently discard the real peak for nothing.
|
|
28813
|
+
*
|
|
28814
|
+
* `sizeAware` since 2026-08-19. It used to be confidence-ONLY, and that was the
|
|
28815
|
+
* wrong ranking: SCRFD saturates its confidence on surveillance footage, so a
|
|
28816
|
+
* 12 px face and a 40 px face both answer ~0.9 and the track simply kept
|
|
28817
|
+
* whichever arrived first. The size that matters is the face's DETECTION-FRAME
|
|
28818
|
+
* short side, because that bounds the landmark precision the alignment warp
|
|
28819
|
+
* consumes — `face-size-score.ts` carries the measurement that set the
|
|
28820
|
+
* reference. Because `minGapMs` is 0 here, enabling this does NOT switch on the
|
|
28821
|
+
* decisive-improvement rate-limit bypass (there is no rate limit to bypass); it
|
|
28822
|
+
* only adds the size factor to the ranking.
|
|
28799
28823
|
*/
|
|
28800
28824
|
var BEST_FACE_POLICY = {
|
|
28801
28825
|
hysteresis: 0,
|
|
28802
28826
|
minGapMs: 0,
|
|
28803
28827
|
edgeAware: false,
|
|
28804
|
-
sizeAware:
|
|
28828
|
+
sizeAware: true
|
|
28805
28829
|
};
|
|
28806
28830
|
/**
|
|
28807
28831
|
* Best OBJECT (CLIP) embedding + its tight crop — confidence-only in-memory
|
|
@@ -32188,7 +32212,11 @@ var AudioDetectionSettingsSchema = object({
|
|
|
32188
32212
|
* thresholds, is what bounds the worst case — a mis-tuned threshold can
|
|
32189
32213
|
* only ever cost the operator this many markers a day. */
|
|
32190
32214
|
audioMarkerMaxPerDay: number().int().positive().default(12),
|
|
32191
|
-
/** Materialize one event + one synthetic track per classified episode.
|
|
32215
|
+
/** Materialize one event + one synthetic track per classified episode.
|
|
32216
|
+
* OFF switches off the GROUPING only — classified audio still persists an
|
|
32217
|
+
* event (class change, then one per 5 s heartbeat) and still reaches the
|
|
32218
|
+
* Notification Center; it just opens no synthetic track. A knob that names
|
|
32219
|
+
* the grouping must never silence the path (D62). */
|
|
32192
32220
|
audioEpisodeEnabled: boolean().default(true),
|
|
32193
32221
|
/** Class must be heard continuously this long before the episode opens. */
|
|
32194
32222
|
audioEpisodeOpenHoldSec: number().nonnegative().default(1.5),
|
|
@@ -34408,11 +34436,44 @@ async function resolveDefaultEventMedia(deps) {
|
|
|
34408
34436
|
return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
|
|
34409
34437
|
}
|
|
34410
34438
|
//#endregion
|
|
34439
|
+
//#region src/pipeline-analytics/pipeline/embedding-magnitude-gate.ts
|
|
34440
|
+
/**
|
|
34441
|
+
* `null` = accept. A verdict = refuse, carrying both numbers so the caller can
|
|
34442
|
+
* name them in a log.
|
|
34443
|
+
*
|
|
34444
|
+
* Accepts unconditionally when the bound is 0 (disabled) or when the magnitude
|
|
34445
|
+
* is absent / non-finite — an older runner that does not report it must keep
|
|
34446
|
+
* working exactly as before, never be silently gated to nothing. Pure.
|
|
34447
|
+
*/
|
|
34448
|
+
function embeddingMagnitudeVerdict(magnitude, bound) {
|
|
34449
|
+
if (!(bound > 0)) return null;
|
|
34450
|
+
if (magnitude === void 0 || !Number.isFinite(magnitude)) return null;
|
|
34451
|
+
if (magnitude <= bound) return null;
|
|
34452
|
+
return {
|
|
34453
|
+
magnitude,
|
|
34454
|
+
bound
|
|
34455
|
+
};
|
|
34456
|
+
}
|
|
34457
|
+
/**
|
|
34458
|
+
* Map a detection-frame face short side to the 0..1 `sizeScore` the
|
|
34459
|
+
* {@link ./best-detection-tracker.BestDetectionTracker} multiplies into its
|
|
34460
|
+
* effective score. Linear up to {@link FACE_SIZE_SCORE_REFERENCE_PX}, saturated
|
|
34461
|
+
* above it, 0 for a missing/degenerate measurement (so an unmeasurable face can
|
|
34462
|
+
* never outrank a measured one). Pure.
|
|
34463
|
+
*/
|
|
34464
|
+
function faceSizeScore(detectionFrameShortSidePx) {
|
|
34465
|
+
if (!Number.isFinite(detectionFrameShortSidePx) || detectionFrameShortSidePx <= 0) return 0;
|
|
34466
|
+
if (detectionFrameShortSidePx >= 32) return 1;
|
|
34467
|
+
return detectionFrameShortSidePx / 32;
|
|
34468
|
+
}
|
|
34469
|
+
//#endregion
|
|
34411
34470
|
//#region src/pipeline-analytics/face-recognizer.ts
|
|
34412
34471
|
/** Attribution of an identity resolved by the in-process ArcFace matcher. */
|
|
34413
34472
|
var FACE_RECOGNIZER_STEP_ID = "face-recognizer";
|
|
34414
34473
|
/** At most one "dropping imageless track" log per this interval, per recognizer. */
|
|
34415
34474
|
var FACE_IMAGELESS_LOG_THROTTLE_MS = 6e4;
|
|
34475
|
+
/** At most one 'embedding magnitude' sample line per device per this interval. */
|
|
34476
|
+
var FACE_MAGNITUDE_LOG_THROTTLE_MS = 6e4;
|
|
34416
34477
|
/**
|
|
34417
34478
|
* arcface model id stamped on a detail-plane face candidate when the gallery is
|
|
34418
34479
|
* empty (collect-only). The detail-subtree result carries no `embeddingModelId`
|
|
@@ -34427,6 +34488,8 @@ var FaceRecognizer = class {
|
|
|
34427
34488
|
names = /* @__PURE__ */ new Map();
|
|
34428
34489
|
aggregates = /* @__PURE__ */ new Map();
|
|
34429
34490
|
bestFace = /* @__PURE__ */ new Map();
|
|
34491
|
+
/** Per-device throttle clock for the magnitude sample line. */
|
|
34492
|
+
lastMagnitudeLogAt = /* @__PURE__ */ new Map();
|
|
34430
34493
|
/**
|
|
34431
34494
|
* Peak identity-MATCH cosine per track for the currently-assigned identity
|
|
34432
34495
|
* (NOT the detection confidence held in {@link bestFace}). Reset on an
|
|
@@ -34476,6 +34539,21 @@ var FaceRecognizer = class {
|
|
|
34476
34539
|
* input source moves from the per-frame plane to this per-track call.
|
|
34477
34540
|
*/
|
|
34478
34541
|
async ingestFaceDetail(input) {
|
|
34542
|
+
const verdict = embeddingMagnitudeVerdict(input.embeddingMagnitude, input.settings.maxEmbeddingMagnitude);
|
|
34543
|
+
if (verdict !== null) {
|
|
34544
|
+
this.deps.logger.info("face: dropping detail — raw embedding magnitude over bound", {
|
|
34545
|
+
tags: { deviceId: input.deviceId },
|
|
34546
|
+
meta: {
|
|
34547
|
+
trackId: input.trackId,
|
|
34548
|
+
magnitude: Number(verdict.magnitude.toFixed(3)),
|
|
34549
|
+
bound: verdict.bound,
|
|
34550
|
+
score: input.score,
|
|
34551
|
+
...input.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: input.nativeFaceShortSidePx } : {}
|
|
34552
|
+
}
|
|
34553
|
+
});
|
|
34554
|
+
return;
|
|
34555
|
+
}
|
|
34556
|
+
this.logEmbeddingMagnitude(input);
|
|
34479
34557
|
const modelId = this.gallery[0]?.modelId ?? FALLBACK_FACE_MODEL_ID;
|
|
34480
34558
|
const candidate = {
|
|
34481
34559
|
trackId: input.trackId,
|
|
@@ -34502,6 +34580,30 @@ var FaceRecognizer = class {
|
|
|
34502
34580
|
...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
|
|
34503
34581
|
});
|
|
34504
34582
|
}
|
|
34583
|
+
/**
|
|
34584
|
+
* At most one magnitude line per device per
|
|
34585
|
+
* {@link FACE_MAGNITUDE_LOG_THROTTLE_MS}. This is the ONLY way the operator
|
|
34586
|
+
* can see where this cluster's usable and degenerate face populations sit, so
|
|
34587
|
+
* it must exist before `maxEmbeddingMagnitude` can honestly be turned on.
|
|
34588
|
+
*/
|
|
34589
|
+
logEmbeddingMagnitude(input) {
|
|
34590
|
+
const magnitude = input.embeddingMagnitude;
|
|
34591
|
+
if (magnitude === void 0 || !Number.isFinite(magnitude)) return;
|
|
34592
|
+
const now = Date.now();
|
|
34593
|
+
if (now - (this.lastMagnitudeLogAt.get(input.deviceId) ?? 0) < FACE_MAGNITUDE_LOG_THROTTLE_MS) return;
|
|
34594
|
+
this.lastMagnitudeLogAt.set(input.deviceId, now);
|
|
34595
|
+
this.deps.logger.info("face: embedding magnitude", {
|
|
34596
|
+
tags: { deviceId: input.deviceId },
|
|
34597
|
+
meta: {
|
|
34598
|
+
trackId: input.trackId,
|
|
34599
|
+
magnitude: Number(magnitude.toFixed(3)),
|
|
34600
|
+
bound: input.settings.maxEmbeddingMagnitude,
|
|
34601
|
+
score: input.score,
|
|
34602
|
+
detectionFrameShortSidePx: input.faceBbox !== void 0 ? Math.round(Math.min(input.faceBbox.w, input.faceBbox.h)) : 0,
|
|
34603
|
+
...input.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: Math.round(input.nativeFaceShortSidePx) } : {}
|
|
34604
|
+
}
|
|
34605
|
+
});
|
|
34606
|
+
}
|
|
34505
34607
|
async processFrame(input) {
|
|
34506
34608
|
const { settings } = input;
|
|
34507
34609
|
const candidates = input.tracked.filter((t) => Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.confidence >= settings.minFaceConfidence && (t.nativeFaceShortSidePx ?? (t.faceBbox ? Math.min(t.faceBbox.w, t.faceBbox.h) : void 0) ?? Number.POSITIVE_INFINITY) >= settings.minFacePx);
|
|
@@ -34563,7 +34665,8 @@ var FaceRecognizer = class {
|
|
|
34563
34665
|
}
|
|
34564
34666
|
const recognizedIdentityId = state.assignedIdentityId ?? void 0;
|
|
34565
34667
|
const held = this.bestFace.get(c.trackId);
|
|
34566
|
-
const
|
|
34668
|
+
const detectionFrameShortSidePx = c.faceBbox !== void 0 ? Math.min(c.faceBbox.w, c.faceBbox.h) : 0;
|
|
34669
|
+
const isNewBest = this.bestTracker.observe(c.trackId, c.confidence, input.timestamp, void 0, void 0, void 0, faceSizeScore(detectionFrameShortSidePx));
|
|
34567
34670
|
const needsCrop = held !== void 0 && held.crop === void 0;
|
|
34568
34671
|
if (isNewBest || needsCrop) {
|
|
34569
34672
|
const cropBbox = c.faceBbox ?? c.bbox;
|
|
@@ -34823,14 +34926,38 @@ var FaceSettingsSchema = object({
|
|
|
34823
34926
|
* stripped from the per-device schema).
|
|
34824
34927
|
*/
|
|
34825
34928
|
enabled: boolean().default(true),
|
|
34826
|
-
/**
|
|
34827
|
-
*
|
|
34828
|
-
*
|
|
34829
|
-
*
|
|
34830
|
-
|
|
34831
|
-
|
|
34832
|
-
*
|
|
34833
|
-
|
|
34929
|
+
/**
|
|
34930
|
+
* Cosine similarity (on L2-normalized arcface vectors) required to match.
|
|
34931
|
+
*
|
|
34932
|
+
* History: 0.55→0.62 on 2026-07-23, then back to 0.55 on 2026-08-18 once the
|
|
34933
|
+
* dead `assignUniquePerFrame` margin and the poisoned gallery were fixed —
|
|
34934
|
+
* with both repaired, genuine probes scored median 0.38 / p90 0.51, so 0.62
|
|
34935
|
+
* left almost nothing recognisable. That retune lived only as a RUNTIME
|
|
34936
|
+
* override until 2026-08-19; it is the schema default now, because a default
|
|
34937
|
+
* nobody runs is a decision nobody made.
|
|
34938
|
+
*
|
|
34939
|
+
* ⚠ NOT the end state. 0.55 is the least-bad value for the CURRENT crop
|
|
34940
|
+
* quality, where most of what reaches ArcFace is not a correctly aligned
|
|
34941
|
+
* face (measured 2026-08-19 over 60 live crops pulled from the hub: 20%
|
|
34942
|
+
* aligned, 18% marginal, 62% misaligned or containing no detectable face).
|
|
34943
|
+
* On correctly aligned crops the SAME models separate at a far lower
|
|
34944
|
+
* operating point — 300 LFW pairs gave genuine median 0.605 / impostor p90
|
|
34945
|
+
* 0.175, best accuracy 98.3% at threshold 0.28.
|
|
34946
|
+
*
|
|
34947
|
+
* Recalibration plan, in this order:
|
|
34948
|
+
* 1. land the landmark-precision gate (the alignment fix),
|
|
34949
|
+
* 2. re-enrol the gallery from crops produced AFTER it,
|
|
34950
|
+
* 3. re-measure genuine/impostor over a week of post-fix traffic,
|
|
34951
|
+
* 4. expect to lower this toward ~0.30, and `margin` with it.
|
|
34952
|
+
* Lowering it BEFORE step 1 would be actively harmful: a misaligned crop
|
|
34953
|
+
* scores ~0.9 against everything, so the degenerate population sits ABOVE
|
|
34954
|
+
* any threshold you could pick — the knob cannot reach it.
|
|
34955
|
+
*/
|
|
34956
|
+
similarityThreshold: number().min(0).max(1).default(.55),
|
|
34957
|
+
/** Reject ambiguous matches: require best − secondBest ≥ margin. 0.10→0.15
|
|
34958
|
+
* (2026-07-23), then →0.12 (2026-08-18) alongside the threshold retune —
|
|
34959
|
+
* same story and the same recalibration plan as {@link similarityThreshold}. */
|
|
34960
|
+
margin: number().min(0).max(1).default(.12),
|
|
34834
34961
|
/** Minimum face-detection confidence for a face to be considered. */
|
|
34835
34962
|
minFaceConfidence: number().min(0).max(1).default(.5),
|
|
34836
34963
|
/**
|
|
@@ -34874,6 +35001,33 @@ var FaceSettingsSchema = object({
|
|
|
34874
35001
|
* 1 (0 confirmations would assign on a single noisy frame — nonsensical;
|
|
34875
35002
|
* such a value falls back to the default). */
|
|
34876
35003
|
confirmFrames: number().int().min(1).default(3),
|
|
35004
|
+
/**
|
|
35005
|
+
* Upper bound on the RAW ArcFace feature magnitude a face may have and still
|
|
35006
|
+
* be embedded into the buffer / matched / enrolled. `0` (the default) DISABLES
|
|
35007
|
+
* the gate.
|
|
35008
|
+
*
|
|
35009
|
+
* This exists because a degenerate crop does not score LOW, it scores HIGH:
|
|
35010
|
+
* measured on this cluster 2026-08-18/19, 28.8% of all pairs of DIFFERENT
|
|
35011
|
+
* faces exceeded cosine 0.90 and the worst 40% of the face population had
|
|
35012
|
+
* collapsed onto effectively one vector (mean pairwise cosine 0.976). No
|
|
35013
|
+
* similarity threshold can reach that — it sits above every threshold — so the
|
|
35014
|
+
* only defence is to refuse the embedding before it is stored or compared.
|
|
35015
|
+
*
|
|
35016
|
+
* Direction: for this model the DEGENERATE crops carry the LARGER norm (the
|
|
35017
|
+
* inverse of the MagFace convention), hence a maximum. Over 140 LFW faces
|
|
35018
|
+
* through this exact model and preprocessing, usable crops sat at ≈4.4 (p95
|
|
35019
|
+
* 5.25) regardless of size, grey level or blur, while information-free crops
|
|
35020
|
+
* jumped to 10–16; a bound of 6.0 kept 97.6% of usable crops and rejected
|
|
35021
|
+
* 93.2% of degenerate ones.
|
|
35022
|
+
*
|
|
35023
|
+
* Default 0 ON PURPOSE. Those numbers are LFW's, not this cluster's, and the
|
|
35024
|
+
* magnitude has never been observable here — `result-assembler` discarded it
|
|
35025
|
+
* one line after computing it. The field and its logging ship first; set a
|
|
35026
|
+
* real bound once a week of live `face: embedding magnitude` lines says where
|
|
35027
|
+
* this cluster's populations actually sit. Enabling a gate against a number
|
|
35028
|
+
* nobody has measured in production is how recognition silently stops.
|
|
35029
|
+
*/
|
|
35030
|
+
maxEmbeddingMagnitude: number().min(0).default(0),
|
|
34877
35031
|
/** Max buffered faces kept per device. */
|
|
34878
35032
|
bufferMaxPerDevice: number().int().min(0).default(50)
|
|
34879
35033
|
});
|
|
@@ -34892,6 +35046,7 @@ function resolveFaceSettings(raw) {
|
|
|
34892
35046
|
recognitionMinFacePx: pick("recognitionMinFacePx"),
|
|
34893
35047
|
suggestionMinCosine: pick("suggestionMinCosine"),
|
|
34894
35048
|
minIdentitySamples: pick("minIdentitySamples"),
|
|
35049
|
+
maxEmbeddingMagnitude: pick("maxEmbeddingMagnitude"),
|
|
34895
35050
|
confirmFrames: pick("confirmFrames"),
|
|
34896
35051
|
bufferMaxPerDevice: pick("bufferMaxPerDevice")
|
|
34897
35052
|
};
|
|
@@ -39363,6 +39518,147 @@ async function applyTrackFlags(deps, input) {
|
|
|
39363
39518
|
};
|
|
39364
39519
|
}
|
|
39365
39520
|
//#endregion
|
|
39521
|
+
//#region src/pipeline-analytics/pipeline/plate-vote.ts
|
|
39522
|
+
/**
|
|
39523
|
+
* Per-character VOTE over a track's plate reads.
|
|
39524
|
+
*
|
|
39525
|
+
* A track produces up to `plate-ocr`'s `cadence.maxPerTrack` reads of the SAME
|
|
39526
|
+
* physical plate, from different frames. The recognizer used to keep the single
|
|
39527
|
+
* read with the highest OCR score (`holdBest`, argmax) — and OCR score does not
|
|
39528
|
+
* correlate with correctness. In the 2026-08 audit the top-scoring read of a
|
|
39529
|
+
* track was routinely the wrong one while two lower-scoring reads agreed on the
|
|
39530
|
+
* right glyph: score ranks how confident the recognizer was about the pixels it
|
|
39531
|
+
* saw, not how much of the plate those pixels were.
|
|
39532
|
+
*
|
|
39533
|
+
* So the reads vote. Each read is normalized (upper, alnum only), aligned to a
|
|
39534
|
+
* reference read, and contributes its OCR score to whichever glyph it saw at
|
|
39535
|
+
* each reference position; the winner per position is the output. Two reads
|
|
39536
|
+
* that agree beat one confident read that disagrees, which is the whole point.
|
|
39537
|
+
*
|
|
39538
|
+
* ALIGNMENT — reads differ in length (4–9 glyphs live: a dropped leading
|
|
39539
|
+
* district letter, a hallucinated trailing glyph). A positional vote on raw
|
|
39540
|
+
* indices would let one missing character shift every later column and destroy
|
|
39541
|
+
* the agreement it was supposed to measure. Each read is therefore aligned to
|
|
39542
|
+
* the reference by Levenshtein traceback: a match or substitution votes at the
|
|
39543
|
+
* reference position it aligned to, an insertion votes nowhere, and a deletion
|
|
39544
|
+
* simply abstains at that position.
|
|
39545
|
+
*
|
|
39546
|
+
* REFERENCE — the length carrying the most SCORE MASS wins (a hallucinated
|
|
39547
|
+
* 9-glyph read does not get to set the output length just by being longest);
|
|
39548
|
+
* ties inside that length go to the highest-scoring read. With ≤5 reads this is
|
|
39549
|
+
* the honest cheap heuristic: there is no third source of truth to consult.
|
|
39550
|
+
*/
|
|
39551
|
+
/**
|
|
39552
|
+
* Align `candidate` to `reference` by Levenshtein traceback. Returns one entry
|
|
39553
|
+
* per REFERENCE position: the candidate glyph that aligned there, or `null`
|
|
39554
|
+
* when the candidate had nothing at that position (a deletion). Candidate
|
|
39555
|
+
* insertions are dropped — they have no reference column to vote in.
|
|
39556
|
+
*/
|
|
39557
|
+
function alignToReference(reference, candidate) {
|
|
39558
|
+
const n = reference.length;
|
|
39559
|
+
const m = candidate.length;
|
|
39560
|
+
const dist = [];
|
|
39561
|
+
for (let i = 0; i <= n; i++) {
|
|
39562
|
+
const row = new Array(m + 1).fill(0);
|
|
39563
|
+
row[0] = i;
|
|
39564
|
+
dist.push(row);
|
|
39565
|
+
}
|
|
39566
|
+
const first = dist[0];
|
|
39567
|
+
if (first === void 0) return [];
|
|
39568
|
+
for (let j = 0; j <= m; j++) first[j] = j;
|
|
39569
|
+
for (let i = 1; i <= n; i++) {
|
|
39570
|
+
const prev = dist[i - 1];
|
|
39571
|
+
const cur = dist[i];
|
|
39572
|
+
if (prev === void 0 || cur === void 0) continue;
|
|
39573
|
+
for (let j = 1; j <= m; j++) {
|
|
39574
|
+
const cost = reference[i - 1] === candidate[j - 1] ? 0 : 1;
|
|
39575
|
+
cur[j] = Math.min((prev[j] ?? 0) + 1, (cur[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
|
|
39576
|
+
}
|
|
39577
|
+
}
|
|
39578
|
+
const aligned = new Array(n).fill(null);
|
|
39579
|
+
let i = n;
|
|
39580
|
+
let j = m;
|
|
39581
|
+
while (i > 0 && j > 0) {
|
|
39582
|
+
const cur = dist[i];
|
|
39583
|
+
const prev = dist[i - 1];
|
|
39584
|
+
if (cur === void 0 || prev === void 0) break;
|
|
39585
|
+
const cost = reference[i - 1] === candidate[j - 1] ? 0 : 1;
|
|
39586
|
+
if (cur[j] === (prev[j - 1] ?? 0) + cost) {
|
|
39587
|
+
aligned[i - 1] = candidate[j - 1] ?? null;
|
|
39588
|
+
i--;
|
|
39589
|
+
j--;
|
|
39590
|
+
} else if (cur[j] === (prev[j] ?? 0) + 1) i--;
|
|
39591
|
+
else j--;
|
|
39592
|
+
}
|
|
39593
|
+
return aligned;
|
|
39594
|
+
}
|
|
39595
|
+
/** The read whose LENGTH carries the most score mass; ties → highest score. */
|
|
39596
|
+
function pickReference(reads) {
|
|
39597
|
+
const massByLength = /* @__PURE__ */ new Map();
|
|
39598
|
+
for (const r of reads) massByLength.set(r.text.length, (massByLength.get(r.text.length) ?? 0) + r.score);
|
|
39599
|
+
let bestLength = 0;
|
|
39600
|
+
let bestMass = -1;
|
|
39601
|
+
for (const [length, mass] of massByLength) if (mass > bestMass || mass === bestMass && length < bestLength) {
|
|
39602
|
+
bestMass = mass;
|
|
39603
|
+
bestLength = length;
|
|
39604
|
+
}
|
|
39605
|
+
let reference = "";
|
|
39606
|
+
let referenceScore = -1;
|
|
39607
|
+
for (const r of reads) if (r.text.length === bestLength && r.score > referenceScore) {
|
|
39608
|
+
referenceScore = r.score;
|
|
39609
|
+
reference = r.text;
|
|
39610
|
+
}
|
|
39611
|
+
return reference;
|
|
39612
|
+
}
|
|
39613
|
+
/**
|
|
39614
|
+
* Confidence-weighted per-character vote over a track's plate reads. Returns
|
|
39615
|
+
* the normalized winning text (`''` when there is nothing to vote on).
|
|
39616
|
+
*/
|
|
39617
|
+
function votePlateText(reads) {
|
|
39618
|
+
const normalized = [];
|
|
39619
|
+
for (const r of reads) {
|
|
39620
|
+
const text = normalizePlate(r.text);
|
|
39621
|
+
if (text.length > 0) normalized.push({
|
|
39622
|
+
text,
|
|
39623
|
+
score: r.score
|
|
39624
|
+
});
|
|
39625
|
+
}
|
|
39626
|
+
if (normalized.length === 0) return "";
|
|
39627
|
+
const only = normalized[0];
|
|
39628
|
+
if (normalized.length === 1 && only !== void 0) return only.text;
|
|
39629
|
+
const reference = pickReference(normalized);
|
|
39630
|
+
if (reference.length === 0) return "";
|
|
39631
|
+
const columns = [];
|
|
39632
|
+
for (let i = 0; i < reference.length; i++) columns.push(/* @__PURE__ */ new Map());
|
|
39633
|
+
for (const r of normalized) {
|
|
39634
|
+
const aligned = r.text === reference ? [...r.text] : alignToReference(reference, r.text);
|
|
39635
|
+
for (let i = 0; i < reference.length; i++) {
|
|
39636
|
+
const glyph = aligned[i];
|
|
39637
|
+
if (glyph === null || glyph === void 0) continue;
|
|
39638
|
+
const column = columns[i];
|
|
39639
|
+
if (column === void 0) continue;
|
|
39640
|
+
column.set(glyph, (column.get(glyph) ?? 0) + r.score);
|
|
39641
|
+
}
|
|
39642
|
+
}
|
|
39643
|
+
let out = "";
|
|
39644
|
+
for (let i = 0; i < reference.length; i++) {
|
|
39645
|
+
const column = columns[i];
|
|
39646
|
+
const referenceGlyph = reference[i] ?? "";
|
|
39647
|
+
if (column === void 0 || column.size === 0) {
|
|
39648
|
+
out += referenceGlyph;
|
|
39649
|
+
continue;
|
|
39650
|
+
}
|
|
39651
|
+
let bestGlyph = referenceGlyph;
|
|
39652
|
+
let bestMass = column.get(referenceGlyph) ?? -1;
|
|
39653
|
+
for (const [glyph, mass] of column) if (mass > bestMass) {
|
|
39654
|
+
bestMass = mass;
|
|
39655
|
+
bestGlyph = glyph;
|
|
39656
|
+
}
|
|
39657
|
+
out += bestGlyph;
|
|
39658
|
+
}
|
|
39659
|
+
return out;
|
|
39660
|
+
}
|
|
39661
|
+
//#endregion
|
|
39366
39662
|
//#region src/pipeline-analytics/pipeline/plate-matcher.ts
|
|
39367
39663
|
/**
|
|
39368
39664
|
* plate-matcher — name a plate read against the vehicle gallery by text vicinity.
|
|
@@ -39411,11 +39707,11 @@ function matchPlateText(probe, gallery, opts) {
|
|
|
39411
39707
|
//#endregion
|
|
39412
39708
|
//#region src/pipeline-analytics/plate-recognizer.ts
|
|
39413
39709
|
/**
|
|
39414
|
-
* PlateRecognizer —
|
|
39415
|
-
*
|
|
39416
|
-
*
|
|
39417
|
-
*
|
|
39418
|
-
*
|
|
39710
|
+
* PlateRecognizer — collects EVERY license-plate OCR read a vehicle track
|
|
39711
|
+
* produces and stores their per-character VOTE (`plate-vote.ts`) at close, plus
|
|
39712
|
+
* the max-score read's crop for the gallery tile. Unlike faces there is NO
|
|
39713
|
+
* gallery matching: the OCR text IS the label and already rides on the event via
|
|
39714
|
+
* the detection pipeline. Mirrors FaceRecognizer's hold→onTrackEnd lifecycle.
|
|
39419
39715
|
*/
|
|
39420
39716
|
var PlateRecognizer = class {
|
|
39421
39717
|
deps;
|
|
@@ -39478,7 +39774,7 @@ var PlateRecognizer = class {
|
|
|
39478
39774
|
const minConfidence = input.minConfidence ?? 0;
|
|
39479
39775
|
const candidates = input.tracked.filter((t) => typeof t.plateText === "string" && t.plateText.length > 0 && typeof t.plateScore === "number" && t.plateScore >= minConfidence && t.plateBbox !== void 0);
|
|
39480
39776
|
if (candidates.length === 0) return;
|
|
39481
|
-
for (const c of candidates) await this.
|
|
39777
|
+
for (const c of candidates) await this.observeRead({
|
|
39482
39778
|
deviceId: input.deviceId,
|
|
39483
39779
|
trackId: c.trackId,
|
|
39484
39780
|
text: c.plateText,
|
|
@@ -39501,15 +39797,50 @@ var PlateRecognizer = class {
|
|
|
39501
39797
|
* gallery stays empty (0 plateCrop) — the observed live gap.
|
|
39502
39798
|
*/
|
|
39503
39799
|
async observePlateRead(input) {
|
|
39504
|
-
|
|
39505
|
-
|
|
39506
|
-
|
|
39800
|
+
const minConfidence = input.minConfidence ?? 0;
|
|
39801
|
+
const reason = !isPlausiblePlateRead(input.text, input.score) ? "implausible" : input.score < minConfidence ? "below-min-confidence" : null;
|
|
39802
|
+
if (reason !== null) {
|
|
39803
|
+
this.deps.logger.info("plate: read discarded", {
|
|
39804
|
+
tags: {
|
|
39805
|
+
deviceId: input.deviceId,
|
|
39806
|
+
trackId: input.trackId
|
|
39807
|
+
},
|
|
39808
|
+
meta: {
|
|
39809
|
+
text: input.text,
|
|
39810
|
+
score: input.score,
|
|
39811
|
+
minConfidence,
|
|
39812
|
+
reason
|
|
39813
|
+
}
|
|
39814
|
+
});
|
|
39815
|
+
return;
|
|
39816
|
+
}
|
|
39817
|
+
await this.observeRead(input);
|
|
39507
39818
|
}
|
|
39508
|
-
/**
|
|
39509
|
-
*
|
|
39510
|
-
|
|
39819
|
+
/**
|
|
39820
|
+
* Record one plate read for a track (shared by the per-frame + detail-plane
|
|
39821
|
+
* paths).
|
|
39822
|
+
*
|
|
39823
|
+
* EVERY read is kept for the close-time vote — a track's reads disagree, and
|
|
39824
|
+
* the disagreement is the signal. The crop + geometry still track the
|
|
39825
|
+
* MAX-SCORE read: the vote decides the text, but the picture the operator
|
|
39826
|
+
* reviews should be the sharpest frame the track produced.
|
|
39827
|
+
*
|
|
39828
|
+
* The read list is capped at {@link MAX_VOTING_READS} in ARRIVAL order, not
|
|
39829
|
+
* by score: evicting the lowest-scoring read would re-introduce the very
|
|
39830
|
+
* ranking this vote exists to stop trusting.
|
|
39831
|
+
*/
|
|
39832
|
+
async observeRead(input) {
|
|
39511
39833
|
const held = this.bestPlate.get(input.trackId);
|
|
39512
|
-
|
|
39834
|
+
const vote = {
|
|
39835
|
+
text: input.text,
|
|
39836
|
+
score: input.score
|
|
39837
|
+
};
|
|
39838
|
+
if (held !== void 0 && input.score <= held.score) {
|
|
39839
|
+
if (held.reads.length < 5) held.reads.push(vote);
|
|
39840
|
+
return;
|
|
39841
|
+
}
|
|
39842
|
+
const reads = held?.reads ?? [];
|
|
39843
|
+
if (reads.length < 5) reads.push(vote);
|
|
39513
39844
|
let crop;
|
|
39514
39845
|
if (input.frameHandle !== void 0) try {
|
|
39515
39846
|
crop = await this.deps.captureCrop(input.frameHandle, input.bbox, input.frameWidth, input.frameHeight, input.cropPadding, input.trackId, input.deviceId) ?? void 0;
|
|
@@ -39525,6 +39856,7 @@ var PlateRecognizer = class {
|
|
|
39525
39856
|
this.bestPlate.set(input.trackId, {
|
|
39526
39857
|
text: input.text,
|
|
39527
39858
|
score: input.score,
|
|
39859
|
+
reads,
|
|
39528
39860
|
bbox: input.bbox,
|
|
39529
39861
|
timestamp: input.timestamp,
|
|
39530
39862
|
frameWidth: input.frameWidth,
|
|
@@ -39601,14 +39933,16 @@ var PlateRecognizer = class {
|
|
|
39601
39933
|
}
|
|
39602
39934
|
});
|
|
39603
39935
|
}
|
|
39604
|
-
const
|
|
39936
|
+
const voted = votePlateText(held.reads);
|
|
39937
|
+
const text = voted.length > 0 && isPlausiblePlateRead(voted, held.score) ? voted : normalizePlate(held.text);
|
|
39938
|
+
const match = this.matchVehicle(text, held.score);
|
|
39605
39939
|
try {
|
|
39606
39940
|
await this.deps.plateStore.insert({
|
|
39607
39941
|
id: plateId,
|
|
39608
39942
|
deviceId,
|
|
39609
39943
|
trackId,
|
|
39610
39944
|
timestamp: held.timestamp,
|
|
39611
|
-
text
|
|
39945
|
+
text,
|
|
39612
39946
|
score: held.score,
|
|
39613
39947
|
...mediaKey !== void 0 ? { mediaKey } : {},
|
|
39614
39948
|
corrected: false,
|
|
@@ -39629,7 +39963,10 @@ var PlateRecognizer = class {
|
|
|
39629
39963
|
},
|
|
39630
39964
|
meta: {
|
|
39631
39965
|
plateId,
|
|
39632
|
-
text
|
|
39966
|
+
text,
|
|
39967
|
+
bestRead: normalizePlate(held.text),
|
|
39968
|
+
reads: held.reads.map((r) => `${normalizePlate(r.text)}@${r.score.toFixed(2)}`),
|
|
39969
|
+
votedFromReads: held.reads.length,
|
|
39633
39970
|
score: held.score,
|
|
39634
39971
|
hasCrop: mediaKey !== void 0,
|
|
39635
39972
|
recognizedVehicleId: match?.vehicleId ?? null
|
|
@@ -49982,7 +50319,7 @@ function buildGlobalSettingsSchema() {
|
|
|
49982
50319
|
type: "boolean",
|
|
49983
50320
|
key: "audioEpisodeEnabled",
|
|
49984
50321
|
label: "Create audio episodes",
|
|
49985
|
-
description: "
|
|
50322
|
+
description: "Off switches off the GROUPING only: classified audio still persists an event and still notifies, but no timeline track is opened and a long sound is no longer collapsed into one episode (one event per class change, then one every 5 s while it lasts).",
|
|
49986
50323
|
default: AUDIO_DETECTION_DEFAULTS.audioEpisodeEnabled
|
|
49987
50324
|
},
|
|
49988
50325
|
{
|
|
@@ -50108,6 +50445,15 @@ function buildGlobalSettingsSchema() {
|
|
|
50108
50445
|
step: 1,
|
|
50109
50446
|
default: FACE_DEFAULTS.confirmFrames
|
|
50110
50447
|
},
|
|
50448
|
+
{
|
|
50449
|
+
type: "number",
|
|
50450
|
+
key: "maxEmbeddingMagnitude",
|
|
50451
|
+
label: "Max embedding magnitude (0 = off)",
|
|
50452
|
+
description: "Refuse a face whose RAW ArcFace feature magnitude exceeds this, before it is buffered, matched or enrolled. A degenerate crop does not score LOW against the gallery — it scores HIGH against everything — so no similarity threshold can catch it; the raw magnitude can. On this model a usable face sits around 4.4 whatever its size, grey level or blur, while an information-free crop jumps to 10–16. Leave at 0 until the \"face: embedding magnitude\" log lines show where YOUR cameras sit; a bound guessed from someone else's data silently stops recognising everybody.",
|
|
50453
|
+
min: 0,
|
|
50454
|
+
step: .5,
|
|
50455
|
+
default: FACE_DEFAULTS.maxEmbeddingMagnitude
|
|
50456
|
+
},
|
|
50111
50457
|
{
|
|
50112
50458
|
type: "number",
|
|
50113
50459
|
key: "bufferMaxPerDevice",
|
|
@@ -51488,6 +51834,16 @@ var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
|
|
|
51488
51834
|
var AUDIO_EPISODE_VISUAL_QUIET_MS = 2e3;
|
|
51489
51835
|
/** Persist lastSeen on an open synthetic audio track at most this often. */
|
|
51490
51836
|
var AUDIO_EPISODE_PERSIST_MS = 2e3;
|
|
51837
|
+
/**
|
|
51838
|
+
* Coalescing window for classified audio inserts when episodes are OFF.
|
|
51839
|
+
*
|
|
51840
|
+
* With `audioEpisodeEnabled: false` there is no hysteresis to collapse a sound
|
|
51841
|
+
* into one row, but the classifier still fires at ~30 Hz — so the pre-episode
|
|
51842
|
+
* rule applies again: while the SAME class persists, one row per heartbeat;
|
|
51843
|
+
* a class change inserts immediately. The knob turns off the grouping, never
|
|
51844
|
+
* the persistence (D62).
|
|
51845
|
+
*/
|
|
51846
|
+
var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
|
|
51491
51847
|
var MOTION_EVENT_HEARTBEAT_MS = 5e3;
|
|
51492
51848
|
/**
|
|
51493
51849
|
* Stored media kinds that carry NO drawn bounding box, in fallback preference
|
|
@@ -55343,6 +55699,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
55343
55699
|
frameHeight: frame.frameHeight,
|
|
55344
55700
|
score: detail.score,
|
|
55345
55701
|
embedding: decodeEmbeddingBase64(detail.embedding),
|
|
55702
|
+
...detail.embeddingMagnitude !== void 0 ? { embeddingMagnitude: detail.embeddingMagnitude } : {},
|
|
55346
55703
|
parentBbox: { ...frame.bbox },
|
|
55347
55704
|
...detail.bbox !== void 0 ? { faceBbox: { ...detail.bbox } } : {},
|
|
55348
55705
|
...detail.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: detail.nativeFaceShortSidePx } : {},
|
|
@@ -55810,7 +56167,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
55810
56167
|
return;
|
|
55811
56168
|
}
|
|
55812
56169
|
}
|
|
55813
|
-
|
|
56170
|
+
/**
|
|
56171
|
+
* The ONE writer for a classified audio observation: the durable row, the
|
|
56172
|
+
* Notification Center hook fired in the same persist moment (D-2), the
|
|
56173
|
+
* track-level `audioLabels` stamp, and the telemetry event.
|
|
56174
|
+
*
|
|
56175
|
+
* Shared by both routes on purpose. `audioEpisodeEnabled` selects WHEN this
|
|
56176
|
+
* runs (once per episode, or once per heartbeat), never WHETHER — a knob
|
|
56177
|
+
* that names the grouping must not be able to silence the classified path
|
|
56178
|
+
* (D62). This body was inlined in `openAudioEpisode` and the knob-off camera
|
|
56179
|
+
* therefore persisted nothing at all.
|
|
56180
|
+
*/
|
|
56181
|
+
async persistClassifiedAudio(input) {
|
|
55814
56182
|
const ev = {
|
|
55815
56183
|
id: randomUUID(),
|
|
55816
56184
|
deviceId: input.deviceId,
|
|
@@ -55819,18 +56187,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
55819
56187
|
rms: input.rms,
|
|
55820
56188
|
dbfs: input.dbfs,
|
|
55821
56189
|
classification: {
|
|
55822
|
-
className:
|
|
56190
|
+
className: input.className,
|
|
55823
56191
|
...input.originalClass !== void 0 ? { originalClass: input.originalClass } : {},
|
|
55824
56192
|
score: input.score
|
|
55825
56193
|
}
|
|
55826
56194
|
};
|
|
55827
56195
|
this.lastAudioInsertByDevice.set(input.deviceId, {
|
|
55828
|
-
className:
|
|
56196
|
+
className: input.className,
|
|
55829
56197
|
atMs: input.timestamp
|
|
55830
56198
|
});
|
|
55831
56199
|
await this.eventStore.insertAudio(ev);
|
|
55832
56200
|
this.notificationCenter?.onAudioEventPersisted(ev);
|
|
55833
|
-
this.trackStore?.addAudioLabelEpisode(input.deviceId,
|
|
56201
|
+
this.trackStore?.addAudioLabelEpisode(input.deviceId, input.className, input.score, input.timestamp);
|
|
55834
56202
|
this.ctx.eventBus.emit({
|
|
55835
56203
|
id: `pa-${ev.id}`,
|
|
55836
56204
|
timestamp: new Date(ev.timestamp),
|
|
@@ -55847,6 +56215,25 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
55847
56215
|
timestamp: ev.timestamp
|
|
55848
56216
|
}
|
|
55849
56217
|
});
|
|
56218
|
+
}
|
|
56219
|
+
/**
|
|
56220
|
+
* Classified audio with episodes OFF: persist on class change or heartbeat,
|
|
56221
|
+
* and open NO synthetic track. This is the whole of what the knob switches —
|
|
56222
|
+
* the timeline track and the episode coalescing — and it is what the form
|
|
56223
|
+
* promises.
|
|
56224
|
+
*/
|
|
56225
|
+
async persistClassifiedAudioUngrouped(input) {
|
|
56226
|
+
const last = this.lastAudioInsertByDevice.get(input.deviceId);
|
|
56227
|
+
const sameClass = last?.className === input.className;
|
|
56228
|
+
const heartbeatDue = last === void 0 || input.timestamp - last.atMs >= AUDIO_EVENT_HEARTBEAT_MS;
|
|
56229
|
+
if (sameClass && !heartbeatDue) return;
|
|
56230
|
+
await this.persistClassifiedAudio(input);
|
|
56231
|
+
}
|
|
56232
|
+
async openAudioEpisode(input, ref) {
|
|
56233
|
+
await this.persistClassifiedAudio({
|
|
56234
|
+
...input,
|
|
56235
|
+
className: ref.className
|
|
56236
|
+
});
|
|
55850
56237
|
if (this.hasLiveVisualTrack(input.deviceId, input.timestamp)) return;
|
|
55851
56238
|
const materializer = this.syntheticTracks;
|
|
55852
56239
|
if (materializer === null) return;
|
|
@@ -55958,19 +56345,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
55958
56345
|
});
|
|
55959
56346
|
return;
|
|
55960
56347
|
}
|
|
55961
|
-
|
|
56348
|
+
const classified = {
|
|
55962
56349
|
deviceId,
|
|
55963
56350
|
timestamp,
|
|
55964
56351
|
className: route.className,
|
|
55965
56352
|
score: topClassification.score,
|
|
55966
|
-
originalClass: topClassification.debug
|
|
56353
|
+
...topClassification.debug?.originalClass !== void 0 ? { originalClass: topClassification.debug.originalClass } : {},
|
|
55967
56354
|
rms: level?.rms ?? 0,
|
|
55968
|
-
dbfs: level?.dbfs ?? 0
|
|
56355
|
+
dbfs: level?.dbfs ?? 0
|
|
56356
|
+
};
|
|
56357
|
+
if (!settings.audioEpisodeEnabled) {
|
|
56358
|
+
await this.persistClassifiedAudioUngrouped(classified);
|
|
56359
|
+
return;
|
|
56360
|
+
}
|
|
56361
|
+
await this.driveAudioEpisode({
|
|
56362
|
+
...classified,
|
|
55969
56363
|
knobs: this.audioEpisodeKnobs(settings)
|
|
55970
56364
|
});
|
|
55971
56365
|
return;
|
|
55972
56366
|
}
|
|
55973
|
-
await this.driveAudioEpisode({
|
|
56367
|
+
if (settings.audioEpisodeEnabled) await this.driveAudioEpisode({
|
|
55974
56368
|
deviceId,
|
|
55975
56369
|
timestamp,
|
|
55976
56370
|
className: null,
|