@camstack/addon-post-analysis 1.2.101 → 1.2.103
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 +676 -62
- package/dist/pipeline-analytics/index.mjs +676 -62
- 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";
|
|
@@ -8295,6 +8295,10 @@ var en_default = {
|
|
|
8295
8295
|
"one": "plate",
|
|
8296
8296
|
"other": "plates"
|
|
8297
8297
|
},
|
|
8298
|
+
"class.audio": {
|
|
8299
|
+
"one": "sound",
|
|
8300
|
+
"other": "sounds"
|
|
8301
|
+
},
|
|
8298
8302
|
"class.other": "{{name}}",
|
|
8299
8303
|
"group.others.person": {
|
|
8300
8304
|
"one": "{{count}} other person",
|
|
@@ -8352,8 +8356,11 @@ var en_default = {
|
|
|
8352
8356
|
"one": "{{count}} addon update available",
|
|
8353
8357
|
"other": "{{count}} addon updates available"
|
|
8354
8358
|
},
|
|
8359
|
+
"system.addon-update-available.title.onNode": {
|
|
8360
|
+
"one": "{{count}} addon update available on {{nodeId}}",
|
|
8361
|
+
"other": "{{count}} addon updates available on {{nodeId}}"
|
|
8362
|
+
},
|
|
8355
8363
|
"system.addon-update-available.body": "{{list}}",
|
|
8356
|
-
"system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on node {{nodeId}}",
|
|
8357
8364
|
"system.server-update-available.title": {
|
|
8358
8365
|
"one": "Server update available on {{count}} node",
|
|
8359
8366
|
"other": "Server update available on {{count}} nodes"
|
|
@@ -8368,6 +8375,7 @@ var en_default = {
|
|
|
8368
8375
|
"system.addon-updated.body": "{{list}}",
|
|
8369
8376
|
"system.server-updated.title": "Server updated",
|
|
8370
8377
|
"system.server-updated.body": "{{packageName}} on {{nodeId}} is now {{toVersion}} (was {{fromVersion}})",
|
|
8378
|
+
"system.server-updated.body.toOnly": "{{packageName}} on {{nodeId}} is now {{toVersion}}",
|
|
8371
8379
|
"system.server-updated.body.plain": "{{packageName}} on {{nodeId}} was updated",
|
|
8372
8380
|
"system.export-completed.title": "Export ready",
|
|
8373
8381
|
"system.export-completed.body": "The footage export is ready",
|
|
@@ -8509,6 +8517,10 @@ var it_default = {
|
|
|
8509
8517
|
"one": "targa",
|
|
8510
8518
|
"other": "targhe"
|
|
8511
8519
|
},
|
|
8520
|
+
"class.audio": {
|
|
8521
|
+
"one": "suono",
|
|
8522
|
+
"other": "suoni"
|
|
8523
|
+
},
|
|
8512
8524
|
"class.other": "{{name}}",
|
|
8513
8525
|
"group.others.person": {
|
|
8514
8526
|
"one": "{{count}} altra persona",
|
|
@@ -8566,8 +8578,11 @@ var it_default = {
|
|
|
8566
8578
|
"one": "{{count}} aggiornamento addon disponibile",
|
|
8567
8579
|
"other": "{{count}} aggiornamenti addon disponibili"
|
|
8568
8580
|
},
|
|
8581
|
+
"system.addon-update-available.title.onNode": {
|
|
8582
|
+
"one": "{{count}} aggiornamento addon disponibile su {{nodeId}}",
|
|
8583
|
+
"other": "{{count}} aggiornamenti addon disponibili su {{nodeId}}"
|
|
8584
|
+
},
|
|
8569
8585
|
"system.addon-update-available.body": "{{list}}",
|
|
8570
|
-
"system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) sul nodo {{nodeId}}",
|
|
8571
8586
|
"system.server-update-available.title": {
|
|
8572
8587
|
"one": "Aggiornamento server disponibile su {{count}} nodo",
|
|
8573
8588
|
"other": "Aggiornamento server disponibile su {{count}} nodi"
|
|
@@ -8582,6 +8597,7 @@ var it_default = {
|
|
|
8582
8597
|
"system.addon-updated.body": "{{list}}",
|
|
8583
8598
|
"system.server-updated.title": "Server aggiornato",
|
|
8584
8599
|
"system.server-updated.body": "{{packageName}} sul nodo {{nodeId}} è ora {{toVersion}} (era {{fromVersion}})",
|
|
8600
|
+
"system.server-updated.body.toOnly": "{{packageName}} sul nodo {{nodeId}} è ora {{toVersion}}",
|
|
8585
8601
|
"system.server-updated.body.plain": "{{packageName}} sul nodo {{nodeId}} è stato aggiornato",
|
|
8586
8602
|
"system.export-completed.title": "Export pronto",
|
|
8587
8603
|
"system.export-completed.body": "L'export del filmato è pronto",
|
|
@@ -10404,15 +10420,15 @@ function incomingFromUpdateAvailable(facts, data) {
|
|
|
10404
10420
|
const nodeIds = unique(packages.map((pkg) => pkg.nodeId).filter((id) => id !== void 0));
|
|
10405
10421
|
const packageNames = unique(packages.map((pkg) => pkg.packageName));
|
|
10406
10422
|
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
10423
|
const head = packages[0];
|
|
10410
10424
|
const oneNode = nodeIds.length === 1 ? nodeIds[0] : void 0;
|
|
10425
|
+
const list = packages.map((pkg) => formatAvailableLine(pkg, oneNode !== void 0)).join("\n");
|
|
10426
|
+
const nodes = nodeIds.join(", ");
|
|
10411
10427
|
return systemSubject({
|
|
10412
10428
|
facts,
|
|
10413
10429
|
kind,
|
|
10414
10430
|
subject: addon ? `packages:addon:${signature}` : `packages:server:${signature}`,
|
|
10415
|
-
titleKey: `system.${kind}.title`,
|
|
10431
|
+
titleKey: addon && oneNode !== void 0 ? `system.${kind}.title.onNode` : `system.${kind}.title`,
|
|
10416
10432
|
bodyKey: !addon && oneNode !== void 0 ? `system.${kind}.body.onNode` : !addon && nodeIds.length > 1 ? `system.${kind}.body.onNodes` : `system.${kind}.body`,
|
|
10417
10433
|
textParams: {
|
|
10418
10434
|
list,
|
|
@@ -10471,12 +10487,12 @@ function incomingFromAddonUpdates(facts, updates) {
|
|
|
10471
10487
|
function incomingFromServerUpdated(facts, data) {
|
|
10472
10488
|
const kind = stringField(data, "kind");
|
|
10473
10489
|
const requestedBy = stringField(data, "requestedBy");
|
|
10474
|
-
if (!(kind === "framework-update" || kind === "framework-bulk-update" || requestedBy !== void 0 && requestedBy.startsWith("server-update:"))) return null;
|
|
10475
10490
|
const packageName = stringField(data, "packageName") ?? "@camstack/server";
|
|
10476
10491
|
const fromVersion = stringField(data, "fromVersion");
|
|
10477
10492
|
const toVersion = stringField(data, "toVersion") ?? versionFromRequestedBy(requestedBy);
|
|
10493
|
+
if (!(kind === "framework-update" || kind === "framework-bulk-update" || requestedBy !== void 0 && requestedBy.startsWith("server-update:") && toVersion !== void 0)) return null;
|
|
10478
10494
|
const nodeId = stringField(data, "nodeId") ?? "hub";
|
|
10479
|
-
const bodyKey =
|
|
10495
|
+
const bodyKey = toVersion === void 0 ? "system.server-updated.body.plain" : fromVersion === void 0 ? "system.server-updated.body.toOnly" : "system.server-updated.body";
|
|
10480
10496
|
return systemSubject({
|
|
10481
10497
|
facts,
|
|
10482
10498
|
kind: "server-updated",
|
|
@@ -10557,9 +10573,15 @@ function updatePackages(data) {
|
|
|
10557
10573
|
...nodeId !== void 0 ? { nodeId } : {}
|
|
10558
10574
|
}];
|
|
10559
10575
|
}
|
|
10560
|
-
|
|
10561
|
-
|
|
10562
|
-
|
|
10576
|
+
/**
|
|
10577
|
+
* One line of an availability list — the SAME shape the completed-update list
|
|
10578
|
+
* uses (`incomingFromAddonUpdates`), and deliberately word-free: the list is a
|
|
10579
|
+
* frozen param, so any English in it ("current") is delivered verbatim to an
|
|
10580
|
+
* Italian household. An arrow is not a language.
|
|
10581
|
+
*/
|
|
10582
|
+
function formatAvailableLine(pkg, nodeNamedInTitle) {
|
|
10583
|
+
const line = `${pkg.packageName} ${pkg.currentVersion} → ${pkg.latestVersion}`;
|
|
10584
|
+
return pkg.nodeId !== void 0 && !nodeNamedInTitle ? `${line} (${pkg.nodeId})` : line;
|
|
10563
10585
|
}
|
|
10564
10586
|
function versionFromRequestedBy(requestedBy) {
|
|
10565
10587
|
if (requestedBy === void 0) return void 0;
|
|
@@ -25162,7 +25184,16 @@ function clamp01$1(x) {
|
|
|
25162
25184
|
if (x > 1) return 1;
|
|
25163
25185
|
return x;
|
|
25164
25186
|
}
|
|
25165
|
-
/**
|
|
25187
|
+
/**
|
|
25188
|
+
* Rank a class name into [0,1] by how event-worthy it is. Case-insensitive.
|
|
25189
|
+
*
|
|
25190
|
+
* `audio` falls through to {@link CLASS_RANK_DEFAULT} on purpose — a sound is
|
|
25191
|
+
* not a subject in frame. Before D189 an audio episode carried the HEARD class
|
|
25192
|
+
* here, so `dog`/`cat`/`animal` landed in {@link ANIMAL_CLASSES} and a bark
|
|
25193
|
+
* ranked as if an animal had walked past, while `speech` (500 of the 576 audio
|
|
25194
|
+
* rows measured live) already ranked default. That boost was an accident of the
|
|
25195
|
+
* class collision, not a decision. Pinned by `key-event-scoring.spec.ts`.
|
|
25196
|
+
*/
|
|
25166
25197
|
function classRank(className) {
|
|
25167
25198
|
const c = className.toLowerCase();
|
|
25168
25199
|
if (PERSON_CLASSES.has(c)) return 1;
|
|
@@ -28789,19 +28820,29 @@ var BEST_FRAME_POLICY = {
|
|
|
28789
28820
|
sizeAware: true
|
|
28790
28821
|
};
|
|
28791
28822
|
/**
|
|
28792
|
-
* Best FACE (face-recognizer hold buffer) —
|
|
28793
|
-
*
|
|
28794
|
-
*
|
|
28795
|
-
*
|
|
28796
|
-
*
|
|
28797
|
-
*
|
|
28798
|
-
*
|
|
28823
|
+
* Best FACE (face-recognizer hold buffer) — SIZE-AWARE, no hysteresis, no rate
|
|
28824
|
+
* limit.
|
|
28825
|
+
*
|
|
28826
|
+
* No hysteresis / no gap, unchanged: accepting a new best only swaps an
|
|
28827
|
+
* already-decoded crop buffer in memory (cheap; no capture is triggered), and a
|
|
28828
|
+
* track may live well under the best-frame rate-limit window, so a gap would
|
|
28829
|
+
* silently discard the real peak for nothing.
|
|
28830
|
+
*
|
|
28831
|
+
* `sizeAware` since 2026-08-19. It used to be confidence-ONLY, and that was the
|
|
28832
|
+
* wrong ranking: SCRFD saturates its confidence on surveillance footage, so a
|
|
28833
|
+
* 12 px face and a 40 px face both answer ~0.9 and the track simply kept
|
|
28834
|
+
* whichever arrived first. The size that matters is the face's DETECTION-FRAME
|
|
28835
|
+
* short side, because that bounds the landmark precision the alignment warp
|
|
28836
|
+
* consumes — `face-size-score.ts` carries the measurement that set the
|
|
28837
|
+
* reference. Because `minGapMs` is 0 here, enabling this does NOT switch on the
|
|
28838
|
+
* decisive-improvement rate-limit bypass (there is no rate limit to bypass); it
|
|
28839
|
+
* only adds the size factor to the ranking.
|
|
28799
28840
|
*/
|
|
28800
28841
|
var BEST_FACE_POLICY = {
|
|
28801
28842
|
hysteresis: 0,
|
|
28802
28843
|
minGapMs: 0,
|
|
28803
28844
|
edgeAware: false,
|
|
28804
|
-
sizeAware:
|
|
28845
|
+
sizeAware: true
|
|
28805
28846
|
};
|
|
28806
28847
|
/**
|
|
28807
28848
|
* Best OBJECT (CLIP) embedding + its tight crop — confidence-only in-memory
|
|
@@ -32188,7 +32229,11 @@ var AudioDetectionSettingsSchema = object({
|
|
|
32188
32229
|
* thresholds, is what bounds the worst case — a mis-tuned threshold can
|
|
32189
32230
|
* only ever cost the operator this many markers a day. */
|
|
32190
32231
|
audioMarkerMaxPerDay: number().int().positive().default(12),
|
|
32191
|
-
/** Materialize one event + one synthetic track per classified episode.
|
|
32232
|
+
/** Materialize one event + one synthetic track per classified episode.
|
|
32233
|
+
* OFF switches off the GROUPING only — classified audio still persists an
|
|
32234
|
+
* event (class change, then one per 5 s heartbeat) and still reaches the
|
|
32235
|
+
* Notification Center; it just opens no synthetic track. A knob that names
|
|
32236
|
+
* the grouping must never silence the path (D62). */
|
|
32192
32237
|
audioEpisodeEnabled: boolean().default(true),
|
|
32193
32238
|
/** Class must be heard continuously this long before the episode opens. */
|
|
32194
32239
|
audioEpisodeOpenHoldSec: number().nonnegative().default(1.5),
|
|
@@ -34408,11 +34453,44 @@ async function resolveDefaultEventMedia(deps) {
|
|
|
34408
34453
|
return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
|
|
34409
34454
|
}
|
|
34410
34455
|
//#endregion
|
|
34456
|
+
//#region src/pipeline-analytics/pipeline/embedding-magnitude-gate.ts
|
|
34457
|
+
/**
|
|
34458
|
+
* `null` = accept. A verdict = refuse, carrying both numbers so the caller can
|
|
34459
|
+
* name them in a log.
|
|
34460
|
+
*
|
|
34461
|
+
* Accepts unconditionally when the bound is 0 (disabled) or when the magnitude
|
|
34462
|
+
* is absent / non-finite — an older runner that does not report it must keep
|
|
34463
|
+
* working exactly as before, never be silently gated to nothing. Pure.
|
|
34464
|
+
*/
|
|
34465
|
+
function embeddingMagnitudeVerdict(magnitude, bound) {
|
|
34466
|
+
if (!(bound > 0)) return null;
|
|
34467
|
+
if (magnitude === void 0 || !Number.isFinite(magnitude)) return null;
|
|
34468
|
+
if (magnitude <= bound) return null;
|
|
34469
|
+
return {
|
|
34470
|
+
magnitude,
|
|
34471
|
+
bound
|
|
34472
|
+
};
|
|
34473
|
+
}
|
|
34474
|
+
/**
|
|
34475
|
+
* Map a detection-frame face short side to the 0..1 `sizeScore` the
|
|
34476
|
+
* {@link ./best-detection-tracker.BestDetectionTracker} multiplies into its
|
|
34477
|
+
* effective score. Linear up to {@link FACE_SIZE_SCORE_REFERENCE_PX}, saturated
|
|
34478
|
+
* above it, 0 for a missing/degenerate measurement (so an unmeasurable face can
|
|
34479
|
+
* never outrank a measured one). Pure.
|
|
34480
|
+
*/
|
|
34481
|
+
function faceSizeScore(detectionFrameShortSidePx) {
|
|
34482
|
+
if (!Number.isFinite(detectionFrameShortSidePx) || detectionFrameShortSidePx <= 0) return 0;
|
|
34483
|
+
if (detectionFrameShortSidePx >= 32) return 1;
|
|
34484
|
+
return detectionFrameShortSidePx / 32;
|
|
34485
|
+
}
|
|
34486
|
+
//#endregion
|
|
34411
34487
|
//#region src/pipeline-analytics/face-recognizer.ts
|
|
34412
34488
|
/** Attribution of an identity resolved by the in-process ArcFace matcher. */
|
|
34413
34489
|
var FACE_RECOGNIZER_STEP_ID = "face-recognizer";
|
|
34414
34490
|
/** At most one "dropping imageless track" log per this interval, per recognizer. */
|
|
34415
34491
|
var FACE_IMAGELESS_LOG_THROTTLE_MS = 6e4;
|
|
34492
|
+
/** At most one 'embedding magnitude' sample line per device per this interval. */
|
|
34493
|
+
var FACE_MAGNITUDE_LOG_THROTTLE_MS = 6e4;
|
|
34416
34494
|
/**
|
|
34417
34495
|
* arcface model id stamped on a detail-plane face candidate when the gallery is
|
|
34418
34496
|
* empty (collect-only). The detail-subtree result carries no `embeddingModelId`
|
|
@@ -34427,6 +34505,8 @@ var FaceRecognizer = class {
|
|
|
34427
34505
|
names = /* @__PURE__ */ new Map();
|
|
34428
34506
|
aggregates = /* @__PURE__ */ new Map();
|
|
34429
34507
|
bestFace = /* @__PURE__ */ new Map();
|
|
34508
|
+
/** Per-device throttle clock for the magnitude sample line. */
|
|
34509
|
+
lastMagnitudeLogAt = /* @__PURE__ */ new Map();
|
|
34430
34510
|
/**
|
|
34431
34511
|
* Peak identity-MATCH cosine per track for the currently-assigned identity
|
|
34432
34512
|
* (NOT the detection confidence held in {@link bestFace}). Reset on an
|
|
@@ -34476,6 +34556,21 @@ var FaceRecognizer = class {
|
|
|
34476
34556
|
* input source moves from the per-frame plane to this per-track call.
|
|
34477
34557
|
*/
|
|
34478
34558
|
async ingestFaceDetail(input) {
|
|
34559
|
+
const verdict = embeddingMagnitudeVerdict(input.embeddingMagnitude, input.settings.maxEmbeddingMagnitude);
|
|
34560
|
+
if (verdict !== null) {
|
|
34561
|
+
this.deps.logger.info("face: dropping detail — raw embedding magnitude over bound", {
|
|
34562
|
+
tags: { deviceId: input.deviceId },
|
|
34563
|
+
meta: {
|
|
34564
|
+
trackId: input.trackId,
|
|
34565
|
+
magnitude: Number(verdict.magnitude.toFixed(3)),
|
|
34566
|
+
bound: verdict.bound,
|
|
34567
|
+
score: input.score,
|
|
34568
|
+
...input.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: input.nativeFaceShortSidePx } : {}
|
|
34569
|
+
}
|
|
34570
|
+
});
|
|
34571
|
+
return;
|
|
34572
|
+
}
|
|
34573
|
+
this.logEmbeddingMagnitude(input);
|
|
34479
34574
|
const modelId = this.gallery[0]?.modelId ?? FALLBACK_FACE_MODEL_ID;
|
|
34480
34575
|
const candidate = {
|
|
34481
34576
|
trackId: input.trackId,
|
|
@@ -34502,6 +34597,30 @@ var FaceRecognizer = class {
|
|
|
34502
34597
|
...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
|
|
34503
34598
|
});
|
|
34504
34599
|
}
|
|
34600
|
+
/**
|
|
34601
|
+
* At most one magnitude line per device per
|
|
34602
|
+
* {@link FACE_MAGNITUDE_LOG_THROTTLE_MS}. This is the ONLY way the operator
|
|
34603
|
+
* can see where this cluster's usable and degenerate face populations sit, so
|
|
34604
|
+
* it must exist before `maxEmbeddingMagnitude` can honestly be turned on.
|
|
34605
|
+
*/
|
|
34606
|
+
logEmbeddingMagnitude(input) {
|
|
34607
|
+
const magnitude = input.embeddingMagnitude;
|
|
34608
|
+
if (magnitude === void 0 || !Number.isFinite(magnitude)) return;
|
|
34609
|
+
const now = Date.now();
|
|
34610
|
+
if (now - (this.lastMagnitudeLogAt.get(input.deviceId) ?? 0) < FACE_MAGNITUDE_LOG_THROTTLE_MS) return;
|
|
34611
|
+
this.lastMagnitudeLogAt.set(input.deviceId, now);
|
|
34612
|
+
this.deps.logger.info("face: embedding magnitude", {
|
|
34613
|
+
tags: { deviceId: input.deviceId },
|
|
34614
|
+
meta: {
|
|
34615
|
+
trackId: input.trackId,
|
|
34616
|
+
magnitude: Number(magnitude.toFixed(3)),
|
|
34617
|
+
bound: input.settings.maxEmbeddingMagnitude,
|
|
34618
|
+
score: input.score,
|
|
34619
|
+
detectionFrameShortSidePx: input.faceBbox !== void 0 ? Math.round(Math.min(input.faceBbox.w, input.faceBbox.h)) : 0,
|
|
34620
|
+
...input.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: Math.round(input.nativeFaceShortSidePx) } : {}
|
|
34621
|
+
}
|
|
34622
|
+
});
|
|
34623
|
+
}
|
|
34505
34624
|
async processFrame(input) {
|
|
34506
34625
|
const { settings } = input;
|
|
34507
34626
|
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 +34682,8 @@ var FaceRecognizer = class {
|
|
|
34563
34682
|
}
|
|
34564
34683
|
const recognizedIdentityId = state.assignedIdentityId ?? void 0;
|
|
34565
34684
|
const held = this.bestFace.get(c.trackId);
|
|
34566
|
-
const
|
|
34685
|
+
const detectionFrameShortSidePx = c.faceBbox !== void 0 ? Math.min(c.faceBbox.w, c.faceBbox.h) : 0;
|
|
34686
|
+
const isNewBest = this.bestTracker.observe(c.trackId, c.confidence, input.timestamp, void 0, void 0, void 0, faceSizeScore(detectionFrameShortSidePx));
|
|
34567
34687
|
const needsCrop = held !== void 0 && held.crop === void 0;
|
|
34568
34688
|
if (isNewBest || needsCrop) {
|
|
34569
34689
|
const cropBbox = c.faceBbox ?? c.bbox;
|
|
@@ -34823,14 +34943,38 @@ var FaceSettingsSchema = object({
|
|
|
34823
34943
|
* stripped from the per-device schema).
|
|
34824
34944
|
*/
|
|
34825
34945
|
enabled: boolean().default(true),
|
|
34826
|
-
/**
|
|
34827
|
-
*
|
|
34828
|
-
*
|
|
34829
|
-
*
|
|
34830
|
-
|
|
34831
|
-
|
|
34832
|
-
*
|
|
34833
|
-
|
|
34946
|
+
/**
|
|
34947
|
+
* Cosine similarity (on L2-normalized arcface vectors) required to match.
|
|
34948
|
+
*
|
|
34949
|
+
* History: 0.55→0.62 on 2026-07-23, then back to 0.55 on 2026-08-18 once the
|
|
34950
|
+
* dead `assignUniquePerFrame` margin and the poisoned gallery were fixed —
|
|
34951
|
+
* with both repaired, genuine probes scored median 0.38 / p90 0.51, so 0.62
|
|
34952
|
+
* left almost nothing recognisable. That retune lived only as a RUNTIME
|
|
34953
|
+
* override until 2026-08-19; it is the schema default now, because a default
|
|
34954
|
+
* nobody runs is a decision nobody made.
|
|
34955
|
+
*
|
|
34956
|
+
* ⚠ NOT the end state. 0.55 is the least-bad value for the CURRENT crop
|
|
34957
|
+
* quality, where most of what reaches ArcFace is not a correctly aligned
|
|
34958
|
+
* face (measured 2026-08-19 over 60 live crops pulled from the hub: 20%
|
|
34959
|
+
* aligned, 18% marginal, 62% misaligned or containing no detectable face).
|
|
34960
|
+
* On correctly aligned crops the SAME models separate at a far lower
|
|
34961
|
+
* operating point — 300 LFW pairs gave genuine median 0.605 / impostor p90
|
|
34962
|
+
* 0.175, best accuracy 98.3% at threshold 0.28.
|
|
34963
|
+
*
|
|
34964
|
+
* Recalibration plan, in this order:
|
|
34965
|
+
* 1. land the landmark-precision gate (the alignment fix),
|
|
34966
|
+
* 2. re-enrol the gallery from crops produced AFTER it,
|
|
34967
|
+
* 3. re-measure genuine/impostor over a week of post-fix traffic,
|
|
34968
|
+
* 4. expect to lower this toward ~0.30, and `margin` with it.
|
|
34969
|
+
* Lowering it BEFORE step 1 would be actively harmful: a misaligned crop
|
|
34970
|
+
* scores ~0.9 against everything, so the degenerate population sits ABOVE
|
|
34971
|
+
* any threshold you could pick — the knob cannot reach it.
|
|
34972
|
+
*/
|
|
34973
|
+
similarityThreshold: number().min(0).max(1).default(.55),
|
|
34974
|
+
/** Reject ambiguous matches: require best − secondBest ≥ margin. 0.10→0.15
|
|
34975
|
+
* (2026-07-23), then →0.12 (2026-08-18) alongside the threshold retune —
|
|
34976
|
+
* same story and the same recalibration plan as {@link similarityThreshold}. */
|
|
34977
|
+
margin: number().min(0).max(1).default(.12),
|
|
34834
34978
|
/** Minimum face-detection confidence for a face to be considered. */
|
|
34835
34979
|
minFaceConfidence: number().min(0).max(1).default(.5),
|
|
34836
34980
|
/**
|
|
@@ -34874,6 +35018,33 @@ var FaceSettingsSchema = object({
|
|
|
34874
35018
|
* 1 (0 confirmations would assign on a single noisy frame — nonsensical;
|
|
34875
35019
|
* such a value falls back to the default). */
|
|
34876
35020
|
confirmFrames: number().int().min(1).default(3),
|
|
35021
|
+
/**
|
|
35022
|
+
* Upper bound on the RAW ArcFace feature magnitude a face may have and still
|
|
35023
|
+
* be embedded into the buffer / matched / enrolled. `0` (the default) DISABLES
|
|
35024
|
+
* the gate.
|
|
35025
|
+
*
|
|
35026
|
+
* This exists because a degenerate crop does not score LOW, it scores HIGH:
|
|
35027
|
+
* measured on this cluster 2026-08-18/19, 28.8% of all pairs of DIFFERENT
|
|
35028
|
+
* faces exceeded cosine 0.90 and the worst 40% of the face population had
|
|
35029
|
+
* collapsed onto effectively one vector (mean pairwise cosine 0.976). No
|
|
35030
|
+
* similarity threshold can reach that — it sits above every threshold — so the
|
|
35031
|
+
* only defence is to refuse the embedding before it is stored or compared.
|
|
35032
|
+
*
|
|
35033
|
+
* Direction: for this model the DEGENERATE crops carry the LARGER norm (the
|
|
35034
|
+
* inverse of the MagFace convention), hence a maximum. Over 140 LFW faces
|
|
35035
|
+
* through this exact model and preprocessing, usable crops sat at ≈4.4 (p95
|
|
35036
|
+
* 5.25) regardless of size, grey level or blur, while information-free crops
|
|
35037
|
+
* jumped to 10–16; a bound of 6.0 kept 97.6% of usable crops and rejected
|
|
35038
|
+
* 93.2% of degenerate ones.
|
|
35039
|
+
*
|
|
35040
|
+
* Default 0 ON PURPOSE. Those numbers are LFW's, not this cluster's, and the
|
|
35041
|
+
* magnitude has never been observable here — `result-assembler` discarded it
|
|
35042
|
+
* one line after computing it. The field and its logging ship first; set a
|
|
35043
|
+
* real bound once a week of live `face: embedding magnitude` lines says where
|
|
35044
|
+
* this cluster's populations actually sit. Enabling a gate against a number
|
|
35045
|
+
* nobody has measured in production is how recognition silently stops.
|
|
35046
|
+
*/
|
|
35047
|
+
maxEmbeddingMagnitude: number().min(0).default(0),
|
|
34877
35048
|
/** Max buffered faces kept per device. */
|
|
34878
35049
|
bufferMaxPerDevice: number().int().min(0).default(50)
|
|
34879
35050
|
});
|
|
@@ -34892,6 +35063,7 @@ function resolveFaceSettings(raw) {
|
|
|
34892
35063
|
recognitionMinFacePx: pick("recognitionMinFacePx"),
|
|
34893
35064
|
suggestionMinCosine: pick("suggestionMinCosine"),
|
|
34894
35065
|
minIdentitySamples: pick("minIdentitySamples"),
|
|
35066
|
+
maxEmbeddingMagnitude: pick("maxEmbeddingMagnitude"),
|
|
34895
35067
|
confirmFrames: pick("confirmFrames"),
|
|
34896
35068
|
bufferMaxPerDevice: pick("bufferMaxPerDevice")
|
|
34897
35069
|
};
|
|
@@ -39363,6 +39535,147 @@ async function applyTrackFlags(deps, input) {
|
|
|
39363
39535
|
};
|
|
39364
39536
|
}
|
|
39365
39537
|
//#endregion
|
|
39538
|
+
//#region src/pipeline-analytics/pipeline/plate-vote.ts
|
|
39539
|
+
/**
|
|
39540
|
+
* Per-character VOTE over a track's plate reads.
|
|
39541
|
+
*
|
|
39542
|
+
* A track produces up to `plate-ocr`'s `cadence.maxPerTrack` reads of the SAME
|
|
39543
|
+
* physical plate, from different frames. The recognizer used to keep the single
|
|
39544
|
+
* read with the highest OCR score (`holdBest`, argmax) — and OCR score does not
|
|
39545
|
+
* correlate with correctness. In the 2026-08 audit the top-scoring read of a
|
|
39546
|
+
* track was routinely the wrong one while two lower-scoring reads agreed on the
|
|
39547
|
+
* right glyph: score ranks how confident the recognizer was about the pixels it
|
|
39548
|
+
* saw, not how much of the plate those pixels were.
|
|
39549
|
+
*
|
|
39550
|
+
* So the reads vote. Each read is normalized (upper, alnum only), aligned to a
|
|
39551
|
+
* reference read, and contributes its OCR score to whichever glyph it saw at
|
|
39552
|
+
* each reference position; the winner per position is the output. Two reads
|
|
39553
|
+
* that agree beat one confident read that disagrees, which is the whole point.
|
|
39554
|
+
*
|
|
39555
|
+
* ALIGNMENT — reads differ in length (4–9 glyphs live: a dropped leading
|
|
39556
|
+
* district letter, a hallucinated trailing glyph). A positional vote on raw
|
|
39557
|
+
* indices would let one missing character shift every later column and destroy
|
|
39558
|
+
* the agreement it was supposed to measure. Each read is therefore aligned to
|
|
39559
|
+
* the reference by Levenshtein traceback: a match or substitution votes at the
|
|
39560
|
+
* reference position it aligned to, an insertion votes nowhere, and a deletion
|
|
39561
|
+
* simply abstains at that position.
|
|
39562
|
+
*
|
|
39563
|
+
* REFERENCE — the length carrying the most SCORE MASS wins (a hallucinated
|
|
39564
|
+
* 9-glyph read does not get to set the output length just by being longest);
|
|
39565
|
+
* ties inside that length go to the highest-scoring read. With ≤5 reads this is
|
|
39566
|
+
* the honest cheap heuristic: there is no third source of truth to consult.
|
|
39567
|
+
*/
|
|
39568
|
+
/**
|
|
39569
|
+
* Align `candidate` to `reference` by Levenshtein traceback. Returns one entry
|
|
39570
|
+
* per REFERENCE position: the candidate glyph that aligned there, or `null`
|
|
39571
|
+
* when the candidate had nothing at that position (a deletion). Candidate
|
|
39572
|
+
* insertions are dropped — they have no reference column to vote in.
|
|
39573
|
+
*/
|
|
39574
|
+
function alignToReference(reference, candidate) {
|
|
39575
|
+
const n = reference.length;
|
|
39576
|
+
const m = candidate.length;
|
|
39577
|
+
const dist = [];
|
|
39578
|
+
for (let i = 0; i <= n; i++) {
|
|
39579
|
+
const row = new Array(m + 1).fill(0);
|
|
39580
|
+
row[0] = i;
|
|
39581
|
+
dist.push(row);
|
|
39582
|
+
}
|
|
39583
|
+
const first = dist[0];
|
|
39584
|
+
if (first === void 0) return [];
|
|
39585
|
+
for (let j = 0; j <= m; j++) first[j] = j;
|
|
39586
|
+
for (let i = 1; i <= n; i++) {
|
|
39587
|
+
const prev = dist[i - 1];
|
|
39588
|
+
const cur = dist[i];
|
|
39589
|
+
if (prev === void 0 || cur === void 0) continue;
|
|
39590
|
+
for (let j = 1; j <= m; j++) {
|
|
39591
|
+
const cost = reference[i - 1] === candidate[j - 1] ? 0 : 1;
|
|
39592
|
+
cur[j] = Math.min((prev[j] ?? 0) + 1, (cur[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
|
|
39593
|
+
}
|
|
39594
|
+
}
|
|
39595
|
+
const aligned = new Array(n).fill(null);
|
|
39596
|
+
let i = n;
|
|
39597
|
+
let j = m;
|
|
39598
|
+
while (i > 0 && j > 0) {
|
|
39599
|
+
const cur = dist[i];
|
|
39600
|
+
const prev = dist[i - 1];
|
|
39601
|
+
if (cur === void 0 || prev === void 0) break;
|
|
39602
|
+
const cost = reference[i - 1] === candidate[j - 1] ? 0 : 1;
|
|
39603
|
+
if (cur[j] === (prev[j - 1] ?? 0) + cost) {
|
|
39604
|
+
aligned[i - 1] = candidate[j - 1] ?? null;
|
|
39605
|
+
i--;
|
|
39606
|
+
j--;
|
|
39607
|
+
} else if (cur[j] === (prev[j] ?? 0) + 1) i--;
|
|
39608
|
+
else j--;
|
|
39609
|
+
}
|
|
39610
|
+
return aligned;
|
|
39611
|
+
}
|
|
39612
|
+
/** The read whose LENGTH carries the most score mass; ties → highest score. */
|
|
39613
|
+
function pickReference(reads) {
|
|
39614
|
+
const massByLength = /* @__PURE__ */ new Map();
|
|
39615
|
+
for (const r of reads) massByLength.set(r.text.length, (massByLength.get(r.text.length) ?? 0) + r.score);
|
|
39616
|
+
let bestLength = 0;
|
|
39617
|
+
let bestMass = -1;
|
|
39618
|
+
for (const [length, mass] of massByLength) if (mass > bestMass || mass === bestMass && length < bestLength) {
|
|
39619
|
+
bestMass = mass;
|
|
39620
|
+
bestLength = length;
|
|
39621
|
+
}
|
|
39622
|
+
let reference = "";
|
|
39623
|
+
let referenceScore = -1;
|
|
39624
|
+
for (const r of reads) if (r.text.length === bestLength && r.score > referenceScore) {
|
|
39625
|
+
referenceScore = r.score;
|
|
39626
|
+
reference = r.text;
|
|
39627
|
+
}
|
|
39628
|
+
return reference;
|
|
39629
|
+
}
|
|
39630
|
+
/**
|
|
39631
|
+
* Confidence-weighted per-character vote over a track's plate reads. Returns
|
|
39632
|
+
* the normalized winning text (`''` when there is nothing to vote on).
|
|
39633
|
+
*/
|
|
39634
|
+
function votePlateText(reads) {
|
|
39635
|
+
const normalized = [];
|
|
39636
|
+
for (const r of reads) {
|
|
39637
|
+
const text = normalizePlate(r.text);
|
|
39638
|
+
if (text.length > 0) normalized.push({
|
|
39639
|
+
text,
|
|
39640
|
+
score: r.score
|
|
39641
|
+
});
|
|
39642
|
+
}
|
|
39643
|
+
if (normalized.length === 0) return "";
|
|
39644
|
+
const only = normalized[0];
|
|
39645
|
+
if (normalized.length === 1 && only !== void 0) return only.text;
|
|
39646
|
+
const reference = pickReference(normalized);
|
|
39647
|
+
if (reference.length === 0) return "";
|
|
39648
|
+
const columns = [];
|
|
39649
|
+
for (let i = 0; i < reference.length; i++) columns.push(/* @__PURE__ */ new Map());
|
|
39650
|
+
for (const r of normalized) {
|
|
39651
|
+
const aligned = r.text === reference ? [...r.text] : alignToReference(reference, r.text);
|
|
39652
|
+
for (let i = 0; i < reference.length; i++) {
|
|
39653
|
+
const glyph = aligned[i];
|
|
39654
|
+
if (glyph === null || glyph === void 0) continue;
|
|
39655
|
+
const column = columns[i];
|
|
39656
|
+
if (column === void 0) continue;
|
|
39657
|
+
column.set(glyph, (column.get(glyph) ?? 0) + r.score);
|
|
39658
|
+
}
|
|
39659
|
+
}
|
|
39660
|
+
let out = "";
|
|
39661
|
+
for (let i = 0; i < reference.length; i++) {
|
|
39662
|
+
const column = columns[i];
|
|
39663
|
+
const referenceGlyph = reference[i] ?? "";
|
|
39664
|
+
if (column === void 0 || column.size === 0) {
|
|
39665
|
+
out += referenceGlyph;
|
|
39666
|
+
continue;
|
|
39667
|
+
}
|
|
39668
|
+
let bestGlyph = referenceGlyph;
|
|
39669
|
+
let bestMass = column.get(referenceGlyph) ?? -1;
|
|
39670
|
+
for (const [glyph, mass] of column) if (mass > bestMass) {
|
|
39671
|
+
bestMass = mass;
|
|
39672
|
+
bestGlyph = glyph;
|
|
39673
|
+
}
|
|
39674
|
+
out += bestGlyph;
|
|
39675
|
+
}
|
|
39676
|
+
return out;
|
|
39677
|
+
}
|
|
39678
|
+
//#endregion
|
|
39366
39679
|
//#region src/pipeline-analytics/pipeline/plate-matcher.ts
|
|
39367
39680
|
/**
|
|
39368
39681
|
* plate-matcher — name a plate read against the vehicle gallery by text vicinity.
|
|
@@ -39411,11 +39724,11 @@ function matchPlateText(probe, gallery, opts) {
|
|
|
39411
39724
|
//#endregion
|
|
39412
39725
|
//#region src/pipeline-analytics/plate-recognizer.ts
|
|
39413
39726
|
/**
|
|
39414
|
-
* PlateRecognizer —
|
|
39415
|
-
*
|
|
39416
|
-
*
|
|
39417
|
-
*
|
|
39418
|
-
*
|
|
39727
|
+
* PlateRecognizer — collects EVERY license-plate OCR read a vehicle track
|
|
39728
|
+
* produces and stores their per-character VOTE (`plate-vote.ts`) at close, plus
|
|
39729
|
+
* the max-score read's crop for the gallery tile. Unlike faces there is NO
|
|
39730
|
+
* gallery matching: the OCR text IS the label and already rides on the event via
|
|
39731
|
+
* the detection pipeline. Mirrors FaceRecognizer's hold→onTrackEnd lifecycle.
|
|
39419
39732
|
*/
|
|
39420
39733
|
var PlateRecognizer = class {
|
|
39421
39734
|
deps;
|
|
@@ -39478,7 +39791,7 @@ var PlateRecognizer = class {
|
|
|
39478
39791
|
const minConfidence = input.minConfidence ?? 0;
|
|
39479
39792
|
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
39793
|
if (candidates.length === 0) return;
|
|
39481
|
-
for (const c of candidates) await this.
|
|
39794
|
+
for (const c of candidates) await this.observeRead({
|
|
39482
39795
|
deviceId: input.deviceId,
|
|
39483
39796
|
trackId: c.trackId,
|
|
39484
39797
|
text: c.plateText,
|
|
@@ -39501,15 +39814,50 @@ var PlateRecognizer = class {
|
|
|
39501
39814
|
* gallery stays empty (0 plateCrop) — the observed live gap.
|
|
39502
39815
|
*/
|
|
39503
39816
|
async observePlateRead(input) {
|
|
39504
|
-
|
|
39505
|
-
|
|
39506
|
-
|
|
39817
|
+
const minConfidence = input.minConfidence ?? 0;
|
|
39818
|
+
const reason = !isPlausiblePlateRead(input.text, input.score) ? "implausible" : input.score < minConfidence ? "below-min-confidence" : null;
|
|
39819
|
+
if (reason !== null) {
|
|
39820
|
+
this.deps.logger.info("plate: read discarded", {
|
|
39821
|
+
tags: {
|
|
39822
|
+
deviceId: input.deviceId,
|
|
39823
|
+
trackId: input.trackId
|
|
39824
|
+
},
|
|
39825
|
+
meta: {
|
|
39826
|
+
text: input.text,
|
|
39827
|
+
score: input.score,
|
|
39828
|
+
minConfidence,
|
|
39829
|
+
reason
|
|
39830
|
+
}
|
|
39831
|
+
});
|
|
39832
|
+
return;
|
|
39833
|
+
}
|
|
39834
|
+
await this.observeRead(input);
|
|
39507
39835
|
}
|
|
39508
|
-
/**
|
|
39509
|
-
*
|
|
39510
|
-
|
|
39836
|
+
/**
|
|
39837
|
+
* Record one plate read for a track (shared by the per-frame + detail-plane
|
|
39838
|
+
* paths).
|
|
39839
|
+
*
|
|
39840
|
+
* EVERY read is kept for the close-time vote — a track's reads disagree, and
|
|
39841
|
+
* the disagreement is the signal. The crop + geometry still track the
|
|
39842
|
+
* MAX-SCORE read: the vote decides the text, but the picture the operator
|
|
39843
|
+
* reviews should be the sharpest frame the track produced.
|
|
39844
|
+
*
|
|
39845
|
+
* The read list is capped at {@link MAX_VOTING_READS} in ARRIVAL order, not
|
|
39846
|
+
* by score: evicting the lowest-scoring read would re-introduce the very
|
|
39847
|
+
* ranking this vote exists to stop trusting.
|
|
39848
|
+
*/
|
|
39849
|
+
async observeRead(input) {
|
|
39511
39850
|
const held = this.bestPlate.get(input.trackId);
|
|
39512
|
-
|
|
39851
|
+
const vote = {
|
|
39852
|
+
text: input.text,
|
|
39853
|
+
score: input.score
|
|
39854
|
+
};
|
|
39855
|
+
if (held !== void 0 && input.score <= held.score) {
|
|
39856
|
+
if (held.reads.length < 5) held.reads.push(vote);
|
|
39857
|
+
return;
|
|
39858
|
+
}
|
|
39859
|
+
const reads = held?.reads ?? [];
|
|
39860
|
+
if (reads.length < 5) reads.push(vote);
|
|
39513
39861
|
let crop;
|
|
39514
39862
|
if (input.frameHandle !== void 0) try {
|
|
39515
39863
|
crop = await this.deps.captureCrop(input.frameHandle, input.bbox, input.frameWidth, input.frameHeight, input.cropPadding, input.trackId, input.deviceId) ?? void 0;
|
|
@@ -39525,6 +39873,7 @@ var PlateRecognizer = class {
|
|
|
39525
39873
|
this.bestPlate.set(input.trackId, {
|
|
39526
39874
|
text: input.text,
|
|
39527
39875
|
score: input.score,
|
|
39876
|
+
reads,
|
|
39528
39877
|
bbox: input.bbox,
|
|
39529
39878
|
timestamp: input.timestamp,
|
|
39530
39879
|
frameWidth: input.frameWidth,
|
|
@@ -39601,14 +39950,16 @@ var PlateRecognizer = class {
|
|
|
39601
39950
|
}
|
|
39602
39951
|
});
|
|
39603
39952
|
}
|
|
39604
|
-
const
|
|
39953
|
+
const voted = votePlateText(held.reads);
|
|
39954
|
+
const text = voted.length > 0 && isPlausiblePlateRead(voted, held.score) ? voted : normalizePlate(held.text);
|
|
39955
|
+
const match = this.matchVehicle(text, held.score);
|
|
39605
39956
|
try {
|
|
39606
39957
|
await this.deps.plateStore.insert({
|
|
39607
39958
|
id: plateId,
|
|
39608
39959
|
deviceId,
|
|
39609
39960
|
trackId,
|
|
39610
39961
|
timestamp: held.timestamp,
|
|
39611
|
-
text
|
|
39962
|
+
text,
|
|
39612
39963
|
score: held.score,
|
|
39613
39964
|
...mediaKey !== void 0 ? { mediaKey } : {},
|
|
39614
39965
|
corrected: false,
|
|
@@ -39629,7 +39980,10 @@ var PlateRecognizer = class {
|
|
|
39629
39980
|
},
|
|
39630
39981
|
meta: {
|
|
39631
39982
|
plateId,
|
|
39632
|
-
text
|
|
39983
|
+
text,
|
|
39984
|
+
bestRead: normalizePlate(held.text),
|
|
39985
|
+
reads: held.reads.map((r) => `${normalizePlate(r.text)}@${r.score.toFixed(2)}`),
|
|
39986
|
+
votedFromReads: held.reads.length,
|
|
39633
39987
|
score: held.score,
|
|
39634
39988
|
hasCrop: mediaKey !== void 0,
|
|
39635
39989
|
recognizedVehicleId: match?.vehicleId ?? null
|
|
@@ -40147,6 +40501,24 @@ function buildRecordedStillPorts(input) {
|
|
|
40147
40501
|
var MIGRATION_MARKER_COLLECTION = "pipeline-analytics:migrations";
|
|
40148
40502
|
var MIGRATION_MARKER_KEY = "label-tier-4g";
|
|
40149
40503
|
/**
|
|
40504
|
+
* The owner declares its marker collection — like every other store in this
|
|
40505
|
+
* addon. KV shape (a single `data` column: `get`/`set` route the value as a
|
|
40506
|
+
* JSON blob; the engine adds the implicit `id` primary key). This declaration
|
|
40507
|
+
* never existed, so the engine refused every marker write, the catch below
|
|
40508
|
+
* warned, and every boot re-scanned the full analytics store — ~36k rows per
|
|
40509
|
+
* boot from the day the migration shipped.
|
|
40510
|
+
*/
|
|
40511
|
+
async function declareMigrationMarkers(store) {
|
|
40512
|
+
await store.declareCollection.mutate({
|
|
40513
|
+
collection: MIGRATION_MARKER_COLLECTION,
|
|
40514
|
+
columns: [{
|
|
40515
|
+
name: "data",
|
|
40516
|
+
type: "TEXT",
|
|
40517
|
+
notNull: true
|
|
40518
|
+
}]
|
|
40519
|
+
});
|
|
40520
|
+
}
|
|
40521
|
+
/**
|
|
40150
40522
|
* The decision for ONE row, exposed so the rule is testable without a store.
|
|
40151
40523
|
*
|
|
40152
40524
|
* `null` = leave the row alone.
|
|
@@ -43327,6 +43699,47 @@ function canRollbackSnapshot(mark, currentLastSnapshotAt) {
|
|
|
43327
43699
|
return mark.priorRollbacks < 3;
|
|
43328
43700
|
}
|
|
43329
43701
|
//#endregion
|
|
43702
|
+
//#region src/pipeline-analytics/store/audio-track-class.ts
|
|
43703
|
+
/** The taxonomy macro every audio-sourced track carries as its class. */
|
|
43704
|
+
var AUDIO_TRACK_CLASS = "audio";
|
|
43705
|
+
/**
|
|
43706
|
+
* True when this row predates the contract: audio-sourced, but carrying a
|
|
43707
|
+
* naked audio macro as its class.
|
|
43708
|
+
*
|
|
43709
|
+
* Deliberately NOT "className is in AUDIO_MACRO_LABELS" — an id added to the
|
|
43710
|
+
* vocabulary after a row was written would then read as modern forever. The
|
|
43711
|
+
* question is the simple one: is this an audio row that does not say `audio`?
|
|
43712
|
+
*/
|
|
43713
|
+
function isLegacyAudioTrackClass(track) {
|
|
43714
|
+
return track.source === "audio" && track.className !== "audio";
|
|
43715
|
+
}
|
|
43716
|
+
/**
|
|
43717
|
+
* Bring a hydrated row up to the contract. Returns the SAME reference when
|
|
43718
|
+
* there is nothing to do, so the hot path (every non-audio track, and every
|
|
43719
|
+
* audio track written after this shipped) allocates nothing.
|
|
43720
|
+
*
|
|
43721
|
+
* The naked class is preserved as an `audioLabels` entry when — and only when
|
|
43722
|
+
* — the row carries none. On the 576 legacy rows measured on the live hub that
|
|
43723
|
+
* branch is unreachable (all of them have labels); it exists so the rewrite
|
|
43724
|
+
* can never be the step that DESTROYS the only copy of what was heard.
|
|
43725
|
+
*/
|
|
43726
|
+
function normalizeAudioTrackClass(track) {
|
|
43727
|
+
if (!isLegacyAudioTrackClass(track)) return track;
|
|
43728
|
+
const heard = {
|
|
43729
|
+
label: track.className,
|
|
43730
|
+
peakScore: 0,
|
|
43731
|
+
count: 1,
|
|
43732
|
+
firstAt: track.firstSeen,
|
|
43733
|
+
lastAt: track.lastSeen
|
|
43734
|
+
};
|
|
43735
|
+
const audioLabels = track.audioLabels !== void 0 && track.audioLabels.length > 0 ? track.audioLabels : [heard];
|
|
43736
|
+
return {
|
|
43737
|
+
...track,
|
|
43738
|
+
className: AUDIO_TRACK_CLASS,
|
|
43739
|
+
audioLabels
|
|
43740
|
+
};
|
|
43741
|
+
}
|
|
43742
|
+
//#endregion
|
|
43330
43743
|
//#region src/pipeline-analytics/store/recent-cursor.ts
|
|
43331
43744
|
function encodeRecentCursor(cursor) {
|
|
43332
43745
|
return Buffer.from(JSON.stringify({
|
|
@@ -44974,7 +45387,7 @@ var TrackStore = class {
|
|
|
44974
45387
|
maxX: envMaxX,
|
|
44975
45388
|
maxY: envMaxY
|
|
44976
45389
|
} : null;
|
|
44977
|
-
return {
|
|
45390
|
+
return normalizeAudioTrackClass({
|
|
44978
45391
|
trackId: id,
|
|
44979
45392
|
deviceId: Number(data["deviceId"]),
|
|
44980
45393
|
className: String(data["className"]),
|
|
@@ -45005,7 +45418,7 @@ var TrackStore = class {
|
|
|
45005
45418
|
...closedReason !== void 0 ? { closedReason } : {},
|
|
45006
45419
|
observations,
|
|
45007
45420
|
...envelope !== null ? { envelope } : {}
|
|
45008
|
-
};
|
|
45421
|
+
});
|
|
45009
45422
|
}
|
|
45010
45423
|
};
|
|
45011
45424
|
//#endregion
|
|
@@ -47338,7 +47751,7 @@ var AudioMarkerProjector = class {
|
|
|
47338
47751
|
cameraId: deviceId,
|
|
47339
47752
|
sourceDeviceId: deviceId,
|
|
47340
47753
|
source: "audio",
|
|
47341
|
-
kind:
|
|
47754
|
+
kind: AUDIO_TRACK_CLASS,
|
|
47342
47755
|
timestamp: timestampMs,
|
|
47343
47756
|
...audioLabels !== void 0 ? { audioLabels } : {}
|
|
47344
47757
|
});
|
|
@@ -48546,6 +48959,23 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
48546
48959
|
* {@link isSpatialTrack}.
|
|
48547
48960
|
*/
|
|
48548
48961
|
var DEFAULT_DEBOUNCE_MS = 1e4;
|
|
48962
|
+
/**
|
|
48963
|
+
* What the debounce treats as "the same event happening again".
|
|
48964
|
+
*
|
|
48965
|
+
* `(camera, kind)` — plus, for an audio row, the class HEARD. Since D189 every
|
|
48966
|
+
* audio row carries `kind: 'audio'` and puts the class on `audioLabels`, so
|
|
48967
|
+
* keying on `kind` alone would collapse this to one bucket per camera. The
|
|
48968
|
+
* episode hysteresis cools down PER CLASS, so a bark three seconds after speech
|
|
48969
|
+
* is a legitimate second episode — and a per-camera bucket would drop it.
|
|
48970
|
+
*
|
|
48971
|
+
* Only the FIRST label is used: a materialize carries the one class the episode
|
|
48972
|
+
* opened on. Joining all of them would make `[speech]` and `[speech, dog]`
|
|
48973
|
+
* different buckets, which is the opposite of what a debounce is for.
|
|
48974
|
+
*/
|
|
48975
|
+
function debounceKey(input) {
|
|
48976
|
+
const heard = input.audioLabels?.[0]?.label;
|
|
48977
|
+
return heard === void 0 ? `${input.cameraId}:${input.kind}` : `${input.cameraId}:${input.kind}:${heard}`;
|
|
48978
|
+
}
|
|
48549
48979
|
/** A hanging snapshot cap must not stall the sensor state-change path. */
|
|
48550
48980
|
var DEFAULT_SNAPSHOT_TIMEOUT_MS = 1e4;
|
|
48551
48981
|
/** Distinguishes a snapshot-cap timeout from a fetch failure for reporting. */
|
|
@@ -48588,7 +49018,7 @@ var SyntheticTrackMaterializer = class {
|
|
|
48588
49018
|
snapshotTimeoutMs;
|
|
48589
49019
|
makeId;
|
|
48590
49020
|
now;
|
|
48591
|
-
/** Last snapshot time per
|
|
49021
|
+
/** Last snapshot time per {@link debounceKey}. */
|
|
48592
49022
|
lastAt = /* @__PURE__ */ new Map();
|
|
48593
49023
|
constructor(deps) {
|
|
48594
49024
|
this.deps = deps;
|
|
@@ -48627,7 +49057,7 @@ var SyntheticTrackMaterializer = class {
|
|
|
48627
49057
|
* still lands a track (with no media) — it never blocks the durable record.
|
|
48628
49058
|
*/
|
|
48629
49059
|
async materialize(input) {
|
|
48630
|
-
const key =
|
|
49060
|
+
const key = debounceKey(input);
|
|
48631
49061
|
const last = this.lastAt.get(key);
|
|
48632
49062
|
if (last !== void 0 && this.now() - last < this.debounceMs) return null;
|
|
48633
49063
|
this.lastAt.set(key, this.now());
|
|
@@ -49982,7 +50412,7 @@ function buildGlobalSettingsSchema() {
|
|
|
49982
50412
|
type: "boolean",
|
|
49983
50413
|
key: "audioEpisodeEnabled",
|
|
49984
50414
|
label: "Create audio episodes",
|
|
49985
|
-
description: "
|
|
50415
|
+
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
50416
|
default: AUDIO_DETECTION_DEFAULTS.audioEpisodeEnabled
|
|
49987
50417
|
},
|
|
49988
50418
|
{
|
|
@@ -50108,6 +50538,15 @@ function buildGlobalSettingsSchema() {
|
|
|
50108
50538
|
step: 1,
|
|
50109
50539
|
default: FACE_DEFAULTS.confirmFrames
|
|
50110
50540
|
},
|
|
50541
|
+
{
|
|
50542
|
+
type: "number",
|
|
50543
|
+
key: "maxEmbeddingMagnitude",
|
|
50544
|
+
label: "Max embedding magnitude (0 = off)",
|
|
50545
|
+
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.",
|
|
50546
|
+
min: 0,
|
|
50547
|
+
step: .5,
|
|
50548
|
+
default: FACE_DEFAULTS.maxEmbeddingMagnitude
|
|
50549
|
+
},
|
|
50111
50550
|
{
|
|
50112
50551
|
type: "number",
|
|
50113
50552
|
key: "bufferMaxPerDevice",
|
|
@@ -50185,6 +50624,125 @@ function evaluatePeriodicSnapshot(input) {
|
|
|
50185
50624
|
movedFraction
|
|
50186
50625
|
};
|
|
50187
50626
|
}
|
|
50627
|
+
/** Where the completion marker lives — the collection `runLabelTierMigration`
|
|
50628
|
+
* already established for exactly this. */
|
|
50629
|
+
var AUDIO_CLASS_MIGRATION_MARKER_COLLECTION = "pipeline-analytics:migrations";
|
|
50630
|
+
var AUDIO_CLASS_MIGRATION_MARKER_KEY = "audio-track-class-d189";
|
|
50631
|
+
/**
|
|
50632
|
+
* The decision for ONE stored row, exposed so the rule is testable without a
|
|
50633
|
+
* store. `null` = leave the row alone.
|
|
50634
|
+
*
|
|
50635
|
+
* Reads the RAW row rather than a hydrated `Track` on purpose: this is the
|
|
50636
|
+
* column the pass rewrites, and hydrating through `rowToTrack` would apply the
|
|
50637
|
+
* read-side normalization and hide the very rows it is looking for.
|
|
50638
|
+
*/
|
|
50639
|
+
function decideAudioClassRowMigration(data) {
|
|
50640
|
+
if (data["source"] !== "audio") return null;
|
|
50641
|
+
const className = data["className"];
|
|
50642
|
+
if (typeof className !== "string" || className.length === 0) return null;
|
|
50643
|
+
if (className === "audio") return null;
|
|
50644
|
+
const stored = data["audioLabels"];
|
|
50645
|
+
if (Array.isArray(stored) && stored.length > 0) return { className: AUDIO_TRACK_CLASS };
|
|
50646
|
+
const firstSeen = typeof data["firstSeen"] === "number" ? data["firstSeen"] : 0;
|
|
50647
|
+
return {
|
|
50648
|
+
className: AUDIO_TRACK_CLASS,
|
|
50649
|
+
audioLabels: [{
|
|
50650
|
+
label: className,
|
|
50651
|
+
peakScore: 0,
|
|
50652
|
+
count: 1,
|
|
50653
|
+
firstAt: firstSeen,
|
|
50654
|
+
lastAt: typeof data["lastSeen"] === "number" ? data["lastSeen"] : firstSeen
|
|
50655
|
+
}]
|
|
50656
|
+
};
|
|
50657
|
+
}
|
|
50658
|
+
/**
|
|
50659
|
+
* Run the pass over the tracks collection, once.
|
|
50660
|
+
*
|
|
50661
|
+
* The marker short-circuits a completed migration; it is written only when the
|
|
50662
|
+
* pass did NOT truncate, so an interrupted run resumes on the next boot instead
|
|
50663
|
+
* of declaring itself done.
|
|
50664
|
+
*/
|
|
50665
|
+
async function runAudioTrackClassMigration(deps, collection) {
|
|
50666
|
+
try {
|
|
50667
|
+
const marker = await deps.store.get.query({
|
|
50668
|
+
collection: AUDIO_CLASS_MIGRATION_MARKER_COLLECTION,
|
|
50669
|
+
key: AUDIO_CLASS_MIGRATION_MARKER_KEY
|
|
50670
|
+
});
|
|
50671
|
+
if (marker !== null && marker !== void 0) return null;
|
|
50672
|
+
} catch {}
|
|
50673
|
+
let scanned = 0;
|
|
50674
|
+
let rewritten = 0;
|
|
50675
|
+
let labelsRecovered = 0;
|
|
50676
|
+
let skipped = 0;
|
|
50677
|
+
let offset = 0;
|
|
50678
|
+
let truncated = false;
|
|
50679
|
+
for (;;) {
|
|
50680
|
+
if (scanned >= 2e5) {
|
|
50681
|
+
truncated = true;
|
|
50682
|
+
break;
|
|
50683
|
+
}
|
|
50684
|
+
const rows = await deps.store.query.query({
|
|
50685
|
+
collection,
|
|
50686
|
+
filter: {
|
|
50687
|
+
limit: 500,
|
|
50688
|
+
offset
|
|
50689
|
+
}
|
|
50690
|
+
});
|
|
50691
|
+
if (rows.length === 0) break;
|
|
50692
|
+
for (const row of rows) {
|
|
50693
|
+
scanned++;
|
|
50694
|
+
const patch = decideAudioClassRowMigration(row.data);
|
|
50695
|
+
if (patch === null) {
|
|
50696
|
+
skipped++;
|
|
50697
|
+
continue;
|
|
50698
|
+
}
|
|
50699
|
+
const deviceId = typeof row.data["deviceId"] === "number" ? row.data["deviceId"] : void 0;
|
|
50700
|
+
try {
|
|
50701
|
+
await deps.store.update.mutate({
|
|
50702
|
+
collection,
|
|
50703
|
+
id: row.id,
|
|
50704
|
+
data: patch
|
|
50705
|
+
});
|
|
50706
|
+
rewritten++;
|
|
50707
|
+
if (patch["audioLabels"] !== void 0) labelsRecovered++;
|
|
50708
|
+
} catch (err) {
|
|
50709
|
+
deps.logger.warn("audio track class migration row failed", {
|
|
50710
|
+
...deviceId !== void 0 ? { tags: { deviceId } } : {},
|
|
50711
|
+
meta: {
|
|
50712
|
+
collection,
|
|
50713
|
+
id: row.id,
|
|
50714
|
+
error: err instanceof Error ? err.message : String(err)
|
|
50715
|
+
}
|
|
50716
|
+
});
|
|
50717
|
+
skipped++;
|
|
50718
|
+
}
|
|
50719
|
+
}
|
|
50720
|
+
if (rows.length < 500) break;
|
|
50721
|
+
offset += rows.length;
|
|
50722
|
+
}
|
|
50723
|
+
if (!truncated) try {
|
|
50724
|
+
await deps.store.set.mutate({
|
|
50725
|
+
collection: AUDIO_CLASS_MIGRATION_MARKER_COLLECTION,
|
|
50726
|
+
key: AUDIO_CLASS_MIGRATION_MARKER_KEY,
|
|
50727
|
+
value: {
|
|
50728
|
+
completedAt: deps.now(),
|
|
50729
|
+
rewritten
|
|
50730
|
+
}
|
|
50731
|
+
});
|
|
50732
|
+
} catch (err) {
|
|
50733
|
+
deps.logger.warn("audio track class migration marker failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
50734
|
+
}
|
|
50735
|
+
const stats = {
|
|
50736
|
+
collection,
|
|
50737
|
+
scanned,
|
|
50738
|
+
rewritten,
|
|
50739
|
+
labelsRecovered,
|
|
50740
|
+
skipped,
|
|
50741
|
+
truncated
|
|
50742
|
+
};
|
|
50743
|
+
deps.logger.info("audio track class migration", { meta: { ...stats } });
|
|
50744
|
+
return stats;
|
|
50745
|
+
}
|
|
50188
50746
|
//#endregion
|
|
50189
50747
|
//#region src/pipeline-analytics/store/object-embedding-store.ts
|
|
50190
50748
|
/**
|
|
@@ -51488,6 +52046,16 @@ var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
|
|
|
51488
52046
|
var AUDIO_EPISODE_VISUAL_QUIET_MS = 2e3;
|
|
51489
52047
|
/** Persist lastSeen on an open synthetic audio track at most this often. */
|
|
51490
52048
|
var AUDIO_EPISODE_PERSIST_MS = 2e3;
|
|
52049
|
+
/**
|
|
52050
|
+
* Coalescing window for classified audio inserts when episodes are OFF.
|
|
52051
|
+
*
|
|
52052
|
+
* With `audioEpisodeEnabled: false` there is no hysteresis to collapse a sound
|
|
52053
|
+
* into one row, but the classifier still fires at ~30 Hz — so the pre-episode
|
|
52054
|
+
* rule applies again: while the SAME class persists, one row per heartbeat;
|
|
52055
|
+
* a class change inserts immediately. The knob turns off the grouping, never
|
|
52056
|
+
* the persistence (D62).
|
|
52057
|
+
*/
|
|
52058
|
+
var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
|
|
51491
52059
|
var MOTION_EVENT_HEARTBEAT_MS = 5e3;
|
|
51492
52060
|
/**
|
|
51493
52061
|
* Stored media kinds that carry NO drawn bounding box, in fallback preference
|
|
@@ -52593,7 +53161,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
52593
53161
|
AnalyticsLts.declare(api.settingsStore),
|
|
52594
53162
|
SceneStore.declare(api.settingsStore),
|
|
52595
53163
|
NotificationCenter.declare(api.settingsStore),
|
|
52596
|
-
ViewerSettingsSnapshotStore.declare(api.settingsStore)
|
|
53164
|
+
ViewerSettingsSnapshotStore.declare(api.settingsStore),
|
|
53165
|
+
declareMigrationMarkers(api.settingsStore)
|
|
52597
53166
|
]);
|
|
52598
53167
|
this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
|
|
52599
53168
|
try {
|
|
@@ -52611,6 +53180,13 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
52611
53180
|
}, [TRACKS_COLLECTION, OBJECT_EVENTS_COLLECTION]).catch((err) => {
|
|
52612
53181
|
this.ctx.logger.warn("label tier migration failed — will retry next boot", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
52613
53182
|
});
|
|
53183
|
+
runAudioTrackClassMigration({
|
|
53184
|
+
store: api.settingsStore,
|
|
53185
|
+
logger: this.ctx.logger,
|
|
53186
|
+
now: () => Date.now()
|
|
53187
|
+
}, TRACKS_COLLECTION).catch((err) => {
|
|
53188
|
+
this.ctx.logger.warn("audio track class migration failed — will retry next boot", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
53189
|
+
});
|
|
52614
53190
|
}
|
|
52615
53191
|
/** Event-media storage. By default it uses `ctx.kernel.storage`, rooted at
|
|
52616
53192
|
* the node data dir (appData) → heavy thumbnail/track-frame churn hammers
|
|
@@ -55343,6 +55919,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
55343
55919
|
frameHeight: frame.frameHeight,
|
|
55344
55920
|
score: detail.score,
|
|
55345
55921
|
embedding: decodeEmbeddingBase64(detail.embedding),
|
|
55922
|
+
...detail.embeddingMagnitude !== void 0 ? { embeddingMagnitude: detail.embeddingMagnitude } : {},
|
|
55346
55923
|
parentBbox: { ...frame.bbox },
|
|
55347
55924
|
...detail.bbox !== void 0 ? { faceBbox: { ...detail.bbox } } : {},
|
|
55348
55925
|
...detail.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: detail.nativeFaceShortSidePx } : {},
|
|
@@ -55810,7 +56387,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
55810
56387
|
return;
|
|
55811
56388
|
}
|
|
55812
56389
|
}
|
|
55813
|
-
|
|
56390
|
+
/**
|
|
56391
|
+
* The ONE writer for a classified audio observation: the durable row, the
|
|
56392
|
+
* Notification Center hook fired in the same persist moment (D-2), the
|
|
56393
|
+
* track-level `audioLabels` stamp, and the telemetry event.
|
|
56394
|
+
*
|
|
56395
|
+
* Shared by both routes on purpose. `audioEpisodeEnabled` selects WHEN this
|
|
56396
|
+
* runs (once per episode, or once per heartbeat), never WHETHER — a knob
|
|
56397
|
+
* that names the grouping must not be able to silence the classified path
|
|
56398
|
+
* (D62). This body was inlined in `openAudioEpisode` and the knob-off camera
|
|
56399
|
+
* therefore persisted nothing at all.
|
|
56400
|
+
*/
|
|
56401
|
+
async persistClassifiedAudio(input) {
|
|
55814
56402
|
const ev = {
|
|
55815
56403
|
id: randomUUID(),
|
|
55816
56404
|
deviceId: input.deviceId,
|
|
@@ -55819,18 +56407,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
55819
56407
|
rms: input.rms,
|
|
55820
56408
|
dbfs: input.dbfs,
|
|
55821
56409
|
classification: {
|
|
55822
|
-
className:
|
|
56410
|
+
className: input.className,
|
|
55823
56411
|
...input.originalClass !== void 0 ? { originalClass: input.originalClass } : {},
|
|
55824
56412
|
score: input.score
|
|
55825
56413
|
}
|
|
55826
56414
|
};
|
|
55827
56415
|
this.lastAudioInsertByDevice.set(input.deviceId, {
|
|
55828
|
-
className:
|
|
56416
|
+
className: input.className,
|
|
55829
56417
|
atMs: input.timestamp
|
|
55830
56418
|
});
|
|
55831
56419
|
await this.eventStore.insertAudio(ev);
|
|
55832
56420
|
this.notificationCenter?.onAudioEventPersisted(ev);
|
|
55833
|
-
this.trackStore?.addAudioLabelEpisode(input.deviceId,
|
|
56421
|
+
this.trackStore?.addAudioLabelEpisode(input.deviceId, input.className, input.score, input.timestamp);
|
|
55834
56422
|
this.ctx.eventBus.emit({
|
|
55835
56423
|
id: `pa-${ev.id}`,
|
|
55836
56424
|
timestamp: new Date(ev.timestamp),
|
|
@@ -55847,6 +56435,25 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
55847
56435
|
timestamp: ev.timestamp
|
|
55848
56436
|
}
|
|
55849
56437
|
});
|
|
56438
|
+
}
|
|
56439
|
+
/**
|
|
56440
|
+
* Classified audio with episodes OFF: persist on class change or heartbeat,
|
|
56441
|
+
* and open NO synthetic track. This is the whole of what the knob switches —
|
|
56442
|
+
* the timeline track and the episode coalescing — and it is what the form
|
|
56443
|
+
* promises.
|
|
56444
|
+
*/
|
|
56445
|
+
async persistClassifiedAudioUngrouped(input) {
|
|
56446
|
+
const last = this.lastAudioInsertByDevice.get(input.deviceId);
|
|
56447
|
+
const sameClass = last?.className === input.className;
|
|
56448
|
+
const heartbeatDue = last === void 0 || input.timestamp - last.atMs >= AUDIO_EVENT_HEARTBEAT_MS;
|
|
56449
|
+
if (sameClass && !heartbeatDue) return;
|
|
56450
|
+
await this.persistClassifiedAudio(input);
|
|
56451
|
+
}
|
|
56452
|
+
async openAudioEpisode(input, ref) {
|
|
56453
|
+
await this.persistClassifiedAudio({
|
|
56454
|
+
...input,
|
|
56455
|
+
className: ref.className
|
|
56456
|
+
});
|
|
55850
56457
|
if (this.hasLiveVisualTrack(input.deviceId, input.timestamp)) return;
|
|
55851
56458
|
const materializer = this.syntheticTracks;
|
|
55852
56459
|
if (materializer === null) return;
|
|
@@ -55855,7 +56462,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
55855
56462
|
cameraId: input.deviceId,
|
|
55856
56463
|
sourceDeviceId: input.deviceId,
|
|
55857
56464
|
source: "audio",
|
|
55858
|
-
kind:
|
|
56465
|
+
kind: AUDIO_TRACK_CLASS,
|
|
55859
56466
|
timestamp: input.timestamp,
|
|
55860
56467
|
audioLabels: [{
|
|
55861
56468
|
label: ref.className,
|
|
@@ -55958,19 +56565,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
55958
56565
|
});
|
|
55959
56566
|
return;
|
|
55960
56567
|
}
|
|
55961
|
-
|
|
56568
|
+
const classified = {
|
|
55962
56569
|
deviceId,
|
|
55963
56570
|
timestamp,
|
|
55964
56571
|
className: route.className,
|
|
55965
56572
|
score: topClassification.score,
|
|
55966
|
-
originalClass: topClassification.debug
|
|
56573
|
+
...topClassification.debug?.originalClass !== void 0 ? { originalClass: topClassification.debug.originalClass } : {},
|
|
55967
56574
|
rms: level?.rms ?? 0,
|
|
55968
|
-
dbfs: level?.dbfs ?? 0
|
|
56575
|
+
dbfs: level?.dbfs ?? 0
|
|
56576
|
+
};
|
|
56577
|
+
if (!settings.audioEpisodeEnabled) {
|
|
56578
|
+
await this.persistClassifiedAudioUngrouped(classified);
|
|
56579
|
+
return;
|
|
56580
|
+
}
|
|
56581
|
+
await this.driveAudioEpisode({
|
|
56582
|
+
...classified,
|
|
55969
56583
|
knobs: this.audioEpisodeKnobs(settings)
|
|
55970
56584
|
});
|
|
55971
56585
|
return;
|
|
55972
56586
|
}
|
|
55973
|
-
await this.driveAudioEpisode({
|
|
56587
|
+
if (settings.audioEpisodeEnabled) await this.driveAudioEpisode({
|
|
55974
56588
|
deviceId,
|
|
55975
56589
|
timestamp,
|
|
55976
56590
|
className: null,
|