@camstack/addon-post-analysis 1.2.100 → 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-Caz0R4UZ.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-MP5xue-m.mjs} +2 -2
- package/dist/pipeline-analytics/{hostInit-By6c47y7.mjs → hostInit-CkptLue6.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +823 -189
- package/dist/pipeline-analytics/index.mjs +823 -189
- 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";
|
|
@@ -8297,23 +8297,23 @@ var en_default = {
|
|
|
8297
8297
|
},
|
|
8298
8298
|
"class.other": "{{name}}",
|
|
8299
8299
|
"group.others.person": {
|
|
8300
|
-
"one": "
|
|
8300
|
+
"one": "{{count}} other person",
|
|
8301
8301
|
"other": "{{count}} other people"
|
|
8302
8302
|
},
|
|
8303
8303
|
"group.others.vehicle": {
|
|
8304
|
-
"one": "
|
|
8304
|
+
"one": "{{count}} other vehicle",
|
|
8305
8305
|
"other": "{{count}} other vehicles"
|
|
8306
8306
|
},
|
|
8307
8307
|
"group.others.animal": {
|
|
8308
|
-
"one": "
|
|
8308
|
+
"one": "{{count}} other animal",
|
|
8309
8309
|
"other": "{{count}} other animals"
|
|
8310
8310
|
},
|
|
8311
8311
|
"group.others.package": {
|
|
8312
|
-
"one": "
|
|
8312
|
+
"one": "{{count}} other package",
|
|
8313
8313
|
"other": "{{count}} other packages"
|
|
8314
8314
|
},
|
|
8315
8315
|
"group.others.other": {
|
|
8316
|
-
"one": "
|
|
8316
|
+
"one": "{{count}} other {{classNoun}}",
|
|
8317
8317
|
"other": "{{count}} other {{classNoun}}"
|
|
8318
8318
|
},
|
|
8319
8319
|
"occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
|
|
@@ -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",
|
|
@@ -8511,23 +8515,23 @@ var it_default = {
|
|
|
8511
8515
|
},
|
|
8512
8516
|
"class.other": "{{name}}",
|
|
8513
8517
|
"group.others.person": {
|
|
8514
|
-
"one": "
|
|
8518
|
+
"one": "{{count}} altra persona",
|
|
8515
8519
|
"other": "{{count}} altre persone"
|
|
8516
8520
|
},
|
|
8517
8521
|
"group.others.vehicle": {
|
|
8518
|
-
"one": "
|
|
8522
|
+
"one": "{{count}} altro veicolo",
|
|
8519
8523
|
"other": "{{count}} altri veicoli"
|
|
8520
8524
|
},
|
|
8521
8525
|
"group.others.animal": {
|
|
8522
|
-
"one": "
|
|
8526
|
+
"one": "{{count}} altro animale",
|
|
8523
8527
|
"other": "{{count}} altri animali"
|
|
8524
8528
|
},
|
|
8525
8529
|
"group.others.package": {
|
|
8526
|
-
"one": "
|
|
8530
|
+
"one": "{{count}} altro pacco",
|
|
8527
8531
|
"other": "{{count}} altri pacchi"
|
|
8528
8532
|
},
|
|
8529
8533
|
"group.others.other": {
|
|
8530
|
-
"one": "
|
|
8534
|
+
"one": "{{count}} altro {{classNoun}}",
|
|
8531
8535
|
"other": "{{count}} altri {{classNoun}}"
|
|
8532
8536
|
},
|
|
8533
8537
|
"occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
|
|
@@ -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
|
|
@@ -30999,8 +31023,14 @@ function reportUnvouchedClips(deps, deviceId, clips) {
|
|
|
30999
31023
|
* @durable class=config owner=pipeline-analytics
|
|
31000
31024
|
* write="a signed-in viewer user saves a named snapshot of their local stores"
|
|
31001
31025
|
* retention="none — a row goes only when its owner deletes it"
|
|
31026
|
+
*
|
|
31027
|
+
* Renamed from the bare `viewer-settings-snapshots` on 2026-08-19: an addon
|
|
31028
|
+
* collection carries its owner's prefix, and the bare name sat in the same flat
|
|
31029
|
+
* namespace as the kernel's `users` / `system-settings`. Rows already written
|
|
31030
|
+
* under the old name are adopted at boot by
|
|
31031
|
+
* `viewer-settings-legacy-adoption.ts`.
|
|
31002
31032
|
*/
|
|
31003
|
-
var VIEWER_SETTINGS_SNAPSHOTS_COLLECTION = "viewer-settings-snapshots";
|
|
31033
|
+
var VIEWER_SETTINGS_SNAPSHOTS_COLLECTION = "pipeline-analytics:viewer-settings-snapshots";
|
|
31004
31034
|
var COLUMNS = [
|
|
31005
31035
|
{
|
|
31006
31036
|
name: "id",
|
|
@@ -31099,7 +31129,7 @@ var ViewerSettingsSnapshotStore = class {
|
|
|
31099
31129
|
collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
|
|
31100
31130
|
columns: [...COLUMNS],
|
|
31101
31131
|
indexes: [{
|
|
31102
|
-
name: "
|
|
31132
|
+
name: "idx_pa_viewer_settings_owner",
|
|
31103
31133
|
columns: ["ownerUserId"]
|
|
31104
31134
|
}]
|
|
31105
31135
|
});
|
|
@@ -31198,6 +31228,183 @@ function makeViewerSettingsActionHandlers(store) {
|
|
|
31198
31228
|
}
|
|
31199
31229
|
};
|
|
31200
31230
|
}
|
|
31231
|
+
//#endregion
|
|
31232
|
+
//#region src/pipeline-analytics/viewer-settings-legacy-adoption.ts
|
|
31233
|
+
/**
|
|
31234
|
+
* @durable class=config owner=pipeline-analytics
|
|
31235
|
+
* write="NOTHING writes it any more — it is the pre-2026-08-19 bare name of
|
|
31236
|
+
* `pipeline-analytics:viewer-settings-snapshots`, declared here only so its
|
|
31237
|
+
* surviving rows can be READ once and moved"
|
|
31238
|
+
* retention="drained, not pruned: a row goes as soon as the successor is
|
|
31239
|
+
* confirmed to hold the same id. The table is empty and inert once every
|
|
31240
|
+
* hub has booted this addon; it is then dropped through the kernel's
|
|
31241
|
+
* `RETIRED_COLLECTIONS` and this module deleted."
|
|
31242
|
+
*
|
|
31243
|
+
* Bare, and therefore a namespacing violation — hence the single
|
|
31244
|
+
* `ALLOWED_BARE` entry in `scripts/check-collection-namespacing.ts`, which
|
|
31245
|
+
* names this file and the condition for removing both.
|
|
31246
|
+
*/
|
|
31247
|
+
var LEGACY_VIEWER_SETTINGS_SNAPSHOTS_COLLECTION = "viewer-settings-snapshots";
|
|
31248
|
+
/**
|
|
31249
|
+
* The ORIGINAL column list, byte-for-byte.
|
|
31250
|
+
*
|
|
31251
|
+
* Do not "tidy" this to reference the successor's constant: they are allowed to
|
|
31252
|
+
* diverge, and the day they do, a shared constant would silently rebuild the
|
|
31253
|
+
* legacy table and destroy the rows.
|
|
31254
|
+
*/
|
|
31255
|
+
var LEGACY_COLUMNS = [
|
|
31256
|
+
{
|
|
31257
|
+
name: "id",
|
|
31258
|
+
type: "TEXT",
|
|
31259
|
+
primaryKey: true,
|
|
31260
|
+
notNull: true
|
|
31261
|
+
},
|
|
31262
|
+
{
|
|
31263
|
+
name: "name",
|
|
31264
|
+
type: "TEXT",
|
|
31265
|
+
notNull: true
|
|
31266
|
+
},
|
|
31267
|
+
{
|
|
31268
|
+
name: "ownerUserId",
|
|
31269
|
+
type: "TEXT",
|
|
31270
|
+
notNull: true
|
|
31271
|
+
},
|
|
31272
|
+
{
|
|
31273
|
+
name: "ownerUsername",
|
|
31274
|
+
type: "TEXT",
|
|
31275
|
+
notNull: true
|
|
31276
|
+
},
|
|
31277
|
+
{
|
|
31278
|
+
name: "createdAt",
|
|
31279
|
+
type: "INTEGER",
|
|
31280
|
+
notNull: true
|
|
31281
|
+
},
|
|
31282
|
+
{
|
|
31283
|
+
name: "updatedAt",
|
|
31284
|
+
type: "INTEGER",
|
|
31285
|
+
notNull: true
|
|
31286
|
+
},
|
|
31287
|
+
{
|
|
31288
|
+
name: "blob",
|
|
31289
|
+
type: "JSON",
|
|
31290
|
+
notNull: true
|
|
31291
|
+
}
|
|
31292
|
+
];
|
|
31293
|
+
/** Bounded read: this table held 1 row on the reference hub. */
|
|
31294
|
+
var LEGACY_READ_LIMIT = 500;
|
|
31295
|
+
function errText(err) {
|
|
31296
|
+
return err instanceof Error ? err.message : String(err);
|
|
31297
|
+
}
|
|
31298
|
+
function asRows(raw) {
|
|
31299
|
+
if (!Array.isArray(raw)) return [];
|
|
31300
|
+
const out = [];
|
|
31301
|
+
for (const item of raw) {
|
|
31302
|
+
if (typeof item !== "object" || item === null) continue;
|
|
31303
|
+
const id = Reflect.get(item, "id");
|
|
31304
|
+
const data = Reflect.get(item, "data");
|
|
31305
|
+
if (typeof id !== "string" || typeof data !== "object" || data === null) continue;
|
|
31306
|
+
out.push({
|
|
31307
|
+
id,
|
|
31308
|
+
data
|
|
31309
|
+
});
|
|
31310
|
+
}
|
|
31311
|
+
return out;
|
|
31312
|
+
}
|
|
31313
|
+
/**
|
|
31314
|
+
* Move every legacy row into the successor and clear the legacy table.
|
|
31315
|
+
*
|
|
31316
|
+
* Returns the number of legacy rows RETIRED — the completion metric, not the
|
|
31317
|
+
* number written. A row copied but not yet verified counts as zero and is
|
|
31318
|
+
* retried on the next boot, which is the safe direction.
|
|
31319
|
+
*/
|
|
31320
|
+
async function adoptLegacyViewerSettingsSnapshots(deps) {
|
|
31321
|
+
await deps.store.declareCollection.mutate({
|
|
31322
|
+
collection: LEGACY_VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
|
|
31323
|
+
columns: LEGACY_COLUMNS
|
|
31324
|
+
});
|
|
31325
|
+
let legacy;
|
|
31326
|
+
try {
|
|
31327
|
+
legacy = asRows(await deps.store.query.query({
|
|
31328
|
+
collection: LEGACY_VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
|
|
31329
|
+
filter: { limit: LEGACY_READ_LIMIT }
|
|
31330
|
+
}));
|
|
31331
|
+
} catch (err) {
|
|
31332
|
+
deps.logger.warn("viewer-settings legacy adoption skipped — legacy read failed", { meta: {
|
|
31333
|
+
collection: LEGACY_VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
|
|
31334
|
+
error: errText(err)
|
|
31335
|
+
} });
|
|
31336
|
+
return 0;
|
|
31337
|
+
}
|
|
31338
|
+
if (legacy.length === 0) return 0;
|
|
31339
|
+
let existing;
|
|
31340
|
+
try {
|
|
31341
|
+
existing = new Set(asRows(await deps.store.query.query({
|
|
31342
|
+
collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
|
|
31343
|
+
filter: { limit: LEGACY_READ_LIMIT }
|
|
31344
|
+
})).map((r) => r.id));
|
|
31345
|
+
} catch (err) {
|
|
31346
|
+
deps.logger.warn("viewer-settings legacy adoption skipped — successor read failed", { meta: {
|
|
31347
|
+
collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
|
|
31348
|
+
error: errText(err)
|
|
31349
|
+
} });
|
|
31350
|
+
return 0;
|
|
31351
|
+
}
|
|
31352
|
+
let written = 0;
|
|
31353
|
+
for (const row of legacy) {
|
|
31354
|
+
if (existing.has(row.id)) continue;
|
|
31355
|
+
try {
|
|
31356
|
+
await deps.store.set.mutate({
|
|
31357
|
+
collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
|
|
31358
|
+
key: row.id,
|
|
31359
|
+
value: row.data
|
|
31360
|
+
});
|
|
31361
|
+
written += 1;
|
|
31362
|
+
} catch (err) {
|
|
31363
|
+
deps.logger.warn("viewer-settings legacy adoption — one row failed to write", { meta: {
|
|
31364
|
+
snapshotId: row.id,
|
|
31365
|
+
error: errText(err)
|
|
31366
|
+
} });
|
|
31367
|
+
}
|
|
31368
|
+
}
|
|
31369
|
+
let landed;
|
|
31370
|
+
try {
|
|
31371
|
+
landed = new Set(asRows(await deps.store.query.query({
|
|
31372
|
+
collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
|
|
31373
|
+
filter: { limit: LEGACY_READ_LIMIT }
|
|
31374
|
+
})).map((r) => r.id));
|
|
31375
|
+
} catch (err) {
|
|
31376
|
+
deps.logger.warn("viewer-settings legacy adoption — verification read failed, legacy rows kept", { meta: {
|
|
31377
|
+
written,
|
|
31378
|
+
error: errText(err)
|
|
31379
|
+
} });
|
|
31380
|
+
return 0;
|
|
31381
|
+
}
|
|
31382
|
+
let retired = 0;
|
|
31383
|
+
for (const row of legacy) {
|
|
31384
|
+
if (!landed.has(row.id)) continue;
|
|
31385
|
+
try {
|
|
31386
|
+
await deps.store.delete.mutate({
|
|
31387
|
+
collection: LEGACY_VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
|
|
31388
|
+
key: row.id
|
|
31389
|
+
});
|
|
31390
|
+
retired += 1;
|
|
31391
|
+
} catch (err) {
|
|
31392
|
+
deps.logger.warn("viewer-settings legacy adoption — one legacy row failed to delete", { meta: {
|
|
31393
|
+
snapshotId: row.id,
|
|
31394
|
+
error: errText(err)
|
|
31395
|
+
} });
|
|
31396
|
+
}
|
|
31397
|
+
}
|
|
31398
|
+
deps.logger.warn("viewer-settings snapshots adopted from the legacy bare collection", { meta: {
|
|
31399
|
+
from: LEGACY_VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
|
|
31400
|
+
to: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
|
|
31401
|
+
found: legacy.length,
|
|
31402
|
+
written,
|
|
31403
|
+
retired,
|
|
31404
|
+
kept: legacy.length - retired
|
|
31405
|
+
} });
|
|
31406
|
+
return retired;
|
|
31407
|
+
}
|
|
31201
31408
|
/**
|
|
31202
31409
|
* The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
|
|
31203
31410
|
*
|
|
@@ -32005,7 +32212,11 @@ var AudioDetectionSettingsSchema = object({
|
|
|
32005
32212
|
* thresholds, is what bounds the worst case — a mis-tuned threshold can
|
|
32006
32213
|
* only ever cost the operator this many markers a day. */
|
|
32007
32214
|
audioMarkerMaxPerDay: number().int().positive().default(12),
|
|
32008
|
-
/** 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). */
|
|
32009
32220
|
audioEpisodeEnabled: boolean().default(true),
|
|
32010
32221
|
/** Class must be heard continuously this long before the episode opens. */
|
|
32011
32222
|
audioEpisodeOpenHoldSec: number().nonnegative().default(1.5),
|
|
@@ -34225,11 +34436,44 @@ async function resolveDefaultEventMedia(deps) {
|
|
|
34225
34436
|
return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
|
|
34226
34437
|
}
|
|
34227
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
|
|
34228
34470
|
//#region src/pipeline-analytics/face-recognizer.ts
|
|
34229
34471
|
/** Attribution of an identity resolved by the in-process ArcFace matcher. */
|
|
34230
34472
|
var FACE_RECOGNIZER_STEP_ID = "face-recognizer";
|
|
34231
34473
|
/** At most one "dropping imageless track" log per this interval, per recognizer. */
|
|
34232
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;
|
|
34233
34477
|
/**
|
|
34234
34478
|
* arcface model id stamped on a detail-plane face candidate when the gallery is
|
|
34235
34479
|
* empty (collect-only). The detail-subtree result carries no `embeddingModelId`
|
|
@@ -34244,6 +34488,8 @@ var FaceRecognizer = class {
|
|
|
34244
34488
|
names = /* @__PURE__ */ new Map();
|
|
34245
34489
|
aggregates = /* @__PURE__ */ new Map();
|
|
34246
34490
|
bestFace = /* @__PURE__ */ new Map();
|
|
34491
|
+
/** Per-device throttle clock for the magnitude sample line. */
|
|
34492
|
+
lastMagnitudeLogAt = /* @__PURE__ */ new Map();
|
|
34247
34493
|
/**
|
|
34248
34494
|
* Peak identity-MATCH cosine per track for the currently-assigned identity
|
|
34249
34495
|
* (NOT the detection confidence held in {@link bestFace}). Reset on an
|
|
@@ -34293,6 +34539,21 @@ var FaceRecognizer = class {
|
|
|
34293
34539
|
* input source moves from the per-frame plane to this per-track call.
|
|
34294
34540
|
*/
|
|
34295
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);
|
|
34296
34557
|
const modelId = this.gallery[0]?.modelId ?? FALLBACK_FACE_MODEL_ID;
|
|
34297
34558
|
const candidate = {
|
|
34298
34559
|
trackId: input.trackId,
|
|
@@ -34319,6 +34580,30 @@ var FaceRecognizer = class {
|
|
|
34319
34580
|
...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
|
|
34320
34581
|
});
|
|
34321
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
|
+
}
|
|
34322
34607
|
async processFrame(input) {
|
|
34323
34608
|
const { settings } = input;
|
|
34324
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);
|
|
@@ -34380,7 +34665,8 @@ var FaceRecognizer = class {
|
|
|
34380
34665
|
}
|
|
34381
34666
|
const recognizedIdentityId = state.assignedIdentityId ?? void 0;
|
|
34382
34667
|
const held = this.bestFace.get(c.trackId);
|
|
34383
|
-
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));
|
|
34384
34670
|
const needsCrop = held !== void 0 && held.crop === void 0;
|
|
34385
34671
|
if (isNewBest || needsCrop) {
|
|
34386
34672
|
const cropBbox = c.faceBbox ?? c.bbox;
|
|
@@ -34640,14 +34926,38 @@ var FaceSettingsSchema = object({
|
|
|
34640
34926
|
* stripped from the per-device schema).
|
|
34641
34927
|
*/
|
|
34642
34928
|
enabled: boolean().default(true),
|
|
34643
|
-
/**
|
|
34644
|
-
*
|
|
34645
|
-
*
|
|
34646
|
-
*
|
|
34647
|
-
|
|
34648
|
-
|
|
34649
|
-
*
|
|
34650
|
-
|
|
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),
|
|
34651
34961
|
/** Minimum face-detection confidence for a face to be considered. */
|
|
34652
34962
|
minFaceConfidence: number().min(0).max(1).default(.5),
|
|
34653
34963
|
/**
|
|
@@ -34691,6 +35001,33 @@ var FaceSettingsSchema = object({
|
|
|
34691
35001
|
* 1 (0 confirmations would assign on a single noisy frame — nonsensical;
|
|
34692
35002
|
* such a value falls back to the default). */
|
|
34693
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),
|
|
34694
35031
|
/** Max buffered faces kept per device. */
|
|
34695
35032
|
bufferMaxPerDevice: number().int().min(0).default(50)
|
|
34696
35033
|
});
|
|
@@ -34709,6 +35046,7 @@ function resolveFaceSettings(raw) {
|
|
|
34709
35046
|
recognitionMinFacePx: pick("recognitionMinFacePx"),
|
|
34710
35047
|
suggestionMinCosine: pick("suggestionMinCosine"),
|
|
34711
35048
|
minIdentitySamples: pick("minIdentitySamples"),
|
|
35049
|
+
maxEmbeddingMagnitude: pick("maxEmbeddingMagnitude"),
|
|
34712
35050
|
confirmFrames: pick("confirmFrames"),
|
|
34713
35051
|
bufferMaxPerDevice: pick("bufferMaxPerDevice")
|
|
34714
35052
|
};
|
|
@@ -39180,6 +39518,147 @@ async function applyTrackFlags(deps, input) {
|
|
|
39180
39518
|
};
|
|
39181
39519
|
}
|
|
39182
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
|
|
39183
39662
|
//#region src/pipeline-analytics/pipeline/plate-matcher.ts
|
|
39184
39663
|
/**
|
|
39185
39664
|
* plate-matcher — name a plate read against the vehicle gallery by text vicinity.
|
|
@@ -39228,11 +39707,11 @@ function matchPlateText(probe, gallery, opts) {
|
|
|
39228
39707
|
//#endregion
|
|
39229
39708
|
//#region src/pipeline-analytics/plate-recognizer.ts
|
|
39230
39709
|
/**
|
|
39231
|
-
* PlateRecognizer —
|
|
39232
|
-
*
|
|
39233
|
-
*
|
|
39234
|
-
*
|
|
39235
|
-
*
|
|
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.
|
|
39236
39715
|
*/
|
|
39237
39716
|
var PlateRecognizer = class {
|
|
39238
39717
|
deps;
|
|
@@ -39295,7 +39774,7 @@ var PlateRecognizer = class {
|
|
|
39295
39774
|
const minConfidence = input.minConfidence ?? 0;
|
|
39296
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);
|
|
39297
39776
|
if (candidates.length === 0) return;
|
|
39298
|
-
for (const c of candidates) await this.
|
|
39777
|
+
for (const c of candidates) await this.observeRead({
|
|
39299
39778
|
deviceId: input.deviceId,
|
|
39300
39779
|
trackId: c.trackId,
|
|
39301
39780
|
text: c.plateText,
|
|
@@ -39318,15 +39797,50 @@ var PlateRecognizer = class {
|
|
|
39318
39797
|
* gallery stays empty (0 plateCrop) — the observed live gap.
|
|
39319
39798
|
*/
|
|
39320
39799
|
async observePlateRead(input) {
|
|
39321
|
-
|
|
39322
|
-
|
|
39323
|
-
|
|
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);
|
|
39324
39818
|
}
|
|
39325
|
-
/**
|
|
39326
|
-
*
|
|
39327
|
-
|
|
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) {
|
|
39328
39833
|
const held = this.bestPlate.get(input.trackId);
|
|
39329
|
-
|
|
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);
|
|
39330
39844
|
let crop;
|
|
39331
39845
|
if (input.frameHandle !== void 0) try {
|
|
39332
39846
|
crop = await this.deps.captureCrop(input.frameHandle, input.bbox, input.frameWidth, input.frameHeight, input.cropPadding, input.trackId, input.deviceId) ?? void 0;
|
|
@@ -39342,6 +39856,7 @@ var PlateRecognizer = class {
|
|
|
39342
39856
|
this.bestPlate.set(input.trackId, {
|
|
39343
39857
|
text: input.text,
|
|
39344
39858
|
score: input.score,
|
|
39859
|
+
reads,
|
|
39345
39860
|
bbox: input.bbox,
|
|
39346
39861
|
timestamp: input.timestamp,
|
|
39347
39862
|
frameWidth: input.frameWidth,
|
|
@@ -39418,14 +39933,16 @@ var PlateRecognizer = class {
|
|
|
39418
39933
|
}
|
|
39419
39934
|
});
|
|
39420
39935
|
}
|
|
39421
|
-
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);
|
|
39422
39939
|
try {
|
|
39423
39940
|
await this.deps.plateStore.insert({
|
|
39424
39941
|
id: plateId,
|
|
39425
39942
|
deviceId,
|
|
39426
39943
|
trackId,
|
|
39427
39944
|
timestamp: held.timestamp,
|
|
39428
|
-
text
|
|
39945
|
+
text,
|
|
39429
39946
|
score: held.score,
|
|
39430
39947
|
...mediaKey !== void 0 ? { mediaKey } : {},
|
|
39431
39948
|
corrected: false,
|
|
@@ -39446,7 +39963,10 @@ var PlateRecognizer = class {
|
|
|
39446
39963
|
},
|
|
39447
39964
|
meta: {
|
|
39448
39965
|
plateId,
|
|
39449
|
-
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,
|
|
39450
39970
|
score: held.score,
|
|
39451
39971
|
hasCrop: mediaKey !== void 0,
|
|
39452
39972
|
recognizedVehicleId: match?.vehicleId ?? null
|
|
@@ -39960,6 +40480,129 @@ function buildRecordedStillPorts(input) {
|
|
|
39960
40480
|
})
|
|
39961
40481
|
};
|
|
39962
40482
|
}
|
|
40483
|
+
/** Where the completion marker lives. */
|
|
40484
|
+
var MIGRATION_MARKER_COLLECTION = "pipeline-analytics:migrations";
|
|
40485
|
+
var MIGRATION_MARKER_KEY = "label-tier-4g";
|
|
40486
|
+
/**
|
|
40487
|
+
* The decision for ONE row, exposed so the rule is testable without a store.
|
|
40488
|
+
*
|
|
40489
|
+
* `null` = leave the row alone.
|
|
40490
|
+
*/
|
|
40491
|
+
function decideRowMigration(data, now) {
|
|
40492
|
+
const label = data["label"];
|
|
40493
|
+
if (typeof label !== "string" || label.length === 0) return null;
|
|
40494
|
+
if (typeof data["subLabel"] === "string" && data["subLabel"].length > 0) return null;
|
|
40495
|
+
if (data["labelMeta"] !== null && data["labelMeta"] !== void 0) return null;
|
|
40496
|
+
if (isMacroClass(label)) return {
|
|
40497
|
+
label: null,
|
|
40498
|
+
labelScore: null,
|
|
40499
|
+
labelMeta: null
|
|
40500
|
+
};
|
|
40501
|
+
return {
|
|
40502
|
+
label: null,
|
|
40503
|
+
labelScore: null,
|
|
40504
|
+
labelMeta: null,
|
|
40505
|
+
subLabel: label,
|
|
40506
|
+
subLabelScore: null,
|
|
40507
|
+
subLabelMeta: {
|
|
40508
|
+
stepId: MIGRATION_STEP_ID,
|
|
40509
|
+
decidedAt: now
|
|
40510
|
+
}
|
|
40511
|
+
};
|
|
40512
|
+
}
|
|
40513
|
+
/** Migrate one collection, paged and bounded. */
|
|
40514
|
+
async function migrateCollectionLabelTiers(deps, collection) {
|
|
40515
|
+
let scanned = 0;
|
|
40516
|
+
let demoted = 0;
|
|
40517
|
+
let cleared = 0;
|
|
40518
|
+
let skipped = 0;
|
|
40519
|
+
let offset = 0;
|
|
40520
|
+
let truncated = false;
|
|
40521
|
+
for (;;) {
|
|
40522
|
+
if (scanned >= 2e5) {
|
|
40523
|
+
truncated = true;
|
|
40524
|
+
break;
|
|
40525
|
+
}
|
|
40526
|
+
const rows = await deps.store.query.query({
|
|
40527
|
+
collection,
|
|
40528
|
+
filter: {
|
|
40529
|
+
limit: 500,
|
|
40530
|
+
offset
|
|
40531
|
+
}
|
|
40532
|
+
});
|
|
40533
|
+
if (rows.length === 0) break;
|
|
40534
|
+
for (const row of rows) {
|
|
40535
|
+
scanned++;
|
|
40536
|
+
const patch = decideRowMigration(row.data, deps.now());
|
|
40537
|
+
if (patch === null) {
|
|
40538
|
+
skipped++;
|
|
40539
|
+
continue;
|
|
40540
|
+
}
|
|
40541
|
+
try {
|
|
40542
|
+
await deps.store.update.mutate({
|
|
40543
|
+
collection,
|
|
40544
|
+
id: row.id,
|
|
40545
|
+
data: patch
|
|
40546
|
+
});
|
|
40547
|
+
if (patch["subLabel"] === void 0) cleared++;
|
|
40548
|
+
else demoted++;
|
|
40549
|
+
} catch (err) {
|
|
40550
|
+
deps.logger.warn("label tier migration row failed", { meta: {
|
|
40551
|
+
collection,
|
|
40552
|
+
id: row.id,
|
|
40553
|
+
error: String(err)
|
|
40554
|
+
} });
|
|
40555
|
+
skipped++;
|
|
40556
|
+
}
|
|
40557
|
+
}
|
|
40558
|
+
if (rows.length < 500) break;
|
|
40559
|
+
offset += rows.length;
|
|
40560
|
+
}
|
|
40561
|
+
return {
|
|
40562
|
+
collection,
|
|
40563
|
+
scanned,
|
|
40564
|
+
demoted,
|
|
40565
|
+
cleared,
|
|
40566
|
+
skipped,
|
|
40567
|
+
truncated
|
|
40568
|
+
};
|
|
40569
|
+
}
|
|
40570
|
+
/**
|
|
40571
|
+
* Run the migration across the collections that carry the tiered columns, once.
|
|
40572
|
+
*
|
|
40573
|
+
* The marker short-circuits a completed migration; it is written only when
|
|
40574
|
+
* NEITHER collection was truncated, so an interrupted pass resumes on the next
|
|
40575
|
+
* boot instead of declaring itself done.
|
|
40576
|
+
*/
|
|
40577
|
+
async function runLabelTierMigration(deps, collections) {
|
|
40578
|
+
try {
|
|
40579
|
+
const marker = await deps.store.get.query({
|
|
40580
|
+
collection: MIGRATION_MARKER_COLLECTION,
|
|
40581
|
+
key: MIGRATION_MARKER_KEY
|
|
40582
|
+
});
|
|
40583
|
+
if (marker !== null && marker !== void 0) return [];
|
|
40584
|
+
} catch {}
|
|
40585
|
+
const stats = [];
|
|
40586
|
+
for (const collection of collections) stats.push(await migrateCollectionLabelTiers(deps, collection));
|
|
40587
|
+
if (stats.every((s) => !s.truncated)) try {
|
|
40588
|
+
await deps.store.set.mutate({
|
|
40589
|
+
collection: MIGRATION_MARKER_COLLECTION,
|
|
40590
|
+
key: MIGRATION_MARKER_KEY,
|
|
40591
|
+
value: { completedAt: deps.now() }
|
|
40592
|
+
});
|
|
40593
|
+
} catch (err) {
|
|
40594
|
+
deps.logger.warn("label tier migration marker failed", { meta: { error: String(err) } });
|
|
40595
|
+
}
|
|
40596
|
+
for (const s of stats) deps.logger.info("label tier migration", { meta: {
|
|
40597
|
+
collection: s.collection,
|
|
40598
|
+
scanned: s.scanned,
|
|
40599
|
+
demoted: s.demoted,
|
|
40600
|
+
cleared: s.cleared,
|
|
40601
|
+
skipped: s.skipped,
|
|
40602
|
+
truncated: s.truncated
|
|
40603
|
+
} });
|
|
40604
|
+
return stats;
|
|
40605
|
+
}
|
|
39963
40606
|
//#endregion
|
|
39964
40607
|
//#region src/durable/lts-aggregator.ts
|
|
39965
40608
|
/** Wall-clock bucket width. Five minutes, matching HA's statistics tier. */
|
|
@@ -45151,6 +45794,55 @@ var ANALYTICS_COLLECTIONS = [
|
|
|
45151
45794
|
why: "a five-minute statistics row summarises an INTERVAL, not an entity — there is no owner it can be orphaned from. Its bound is a ROW CAP per (subject, series, scope) enforced by the aggregator itself (105,120 rows ≈ one year), deliberately not an age sweep: D61 collapsed ten disagreeing age clocks into one, and an LTS series has no clock but its own."
|
|
45152
45795
|
}
|
|
45153
45796
|
},
|
|
45797
|
+
{
|
|
45798
|
+
collection: RETRAIN_ANNOTATIONS_COLLECTION,
|
|
45799
|
+
classification: {
|
|
45800
|
+
kind: "never-orphaned",
|
|
45801
|
+
why: "the ground truth an operator DREW. It declares `trackId` and must be classified by hand for that reason: the id is provenance, not ownership. The dataset addresses COPIES (D81), which is precisely what makes a 'trained' track evictable again — so a track ageing out leaves the annotations standing. Rows go only through `deleteForFrame`, when the operator deselects the frame. Deleting one because its track expired would destroy labelling work no sweep can recreate."
|
|
45802
|
+
}
|
|
45803
|
+
},
|
|
45804
|
+
{
|
|
45805
|
+
collection: RETRAIN_FRAMES_COLLECTION,
|
|
45806
|
+
classification: {
|
|
45807
|
+
kind: "never-orphaned",
|
|
45808
|
+
why: "the frame COPIES the dataset owns outright, under a `retrain` prefix no owner cascade and no age sweep walks (D81). Also declares `trackId` as provenance. Rows go only through `deleteFrame`, after their annotations, on operator deselection. NOTE: nothing caps this table or the disk it consumes — that is a deliberate decision, not an oversight, and it is the one collection here whose bound is an operator's habit."
|
|
45809
|
+
}
|
|
45810
|
+
},
|
|
45811
|
+
{
|
|
45812
|
+
collection: SCENE_COLLECTION,
|
|
45813
|
+
classification: {
|
|
45814
|
+
kind: "never-orphaned",
|
|
45815
|
+
why: "operator intent — one row per (deviceId, monitorId), created and destroyed by scene CRUD or by a device-removal sweep. Nothing retention does can orphan it, and no age clock applies: a scene the operator configured and never revisits is not stale, it is configured."
|
|
45816
|
+
}
|
|
45817
|
+
},
|
|
45818
|
+
{
|
|
45819
|
+
collection: SCENE_LATCH_COLLECTION,
|
|
45820
|
+
classification: {
|
|
45821
|
+
kind: "never-orphaned",
|
|
45822
|
+
why: "it IS owned — by the scene set — but the owner-cleanup is self-contained: `pruneExcept(the scene set)` runs on every scene reload, so a latch whose scene is gone is collected there and never by retention. Deliberately NOT `owner-indirection`: that kind puts a collection into `auditableCollections()`, and the orphan audit resolves exactly one indirection, that of `pipeline-analytics:media`."
|
|
45823
|
+
}
|
|
45824
|
+
},
|
|
45825
|
+
{
|
|
45826
|
+
collection: MIGRATION_MARKER_COLLECTION,
|
|
45827
|
+
classification: {
|
|
45828
|
+
kind: "never-orphaned",
|
|
45829
|
+
why: "a one-row completion marker for the label-tier 4g pass. It summarises a one-time event, so there is no entity it can be orphaned from — and its PRESENCE is what stops the migration running again, so deleting it is not cleanup, it is a re-run over every track, face and object-event row."
|
|
45830
|
+
}
|
|
45831
|
+
},
|
|
45832
|
+
{
|
|
45833
|
+
collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
|
|
45834
|
+
classification: {
|
|
45835
|
+
kind: "never-orphaned",
|
|
45836
|
+
why: "a hub user's named snapshot of their own viewer layout. It is not analytics data — it happens to live in this addon because the actions ride `addons.custom`. Owned by the user who saved it and deleted only by them; no track, device or event can orphan it, and no age applies."
|
|
45837
|
+
}
|
|
45838
|
+
},
|
|
45839
|
+
{
|
|
45840
|
+
collection: LEGACY_VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
|
|
45841
|
+
classification: {
|
|
45842
|
+
kind: "never-orphaned",
|
|
45843
|
+
why: "the drained predecessor of the row above, declared only so its rows can be read once and moved (`viewer-settings-legacy-adoption.ts`). Retention must never touch it: its rows go one way only, to the successor, gated on a re-read that confirms they landed. Delete this entry together with that module."
|
|
45844
|
+
}
|
|
45845
|
+
},
|
|
45154
45846
|
{
|
|
45155
45847
|
collection: STATIONARY_COLLECTION,
|
|
45156
45848
|
classification: {
|
|
@@ -49627,7 +50319,7 @@ function buildGlobalSettingsSchema() {
|
|
|
49627
50319
|
type: "boolean",
|
|
49628
50320
|
key: "audioEpisodeEnabled",
|
|
49629
50321
|
label: "Create audio episodes",
|
|
49630
|
-
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).",
|
|
49631
50323
|
default: AUDIO_DETECTION_DEFAULTS.audioEpisodeEnabled
|
|
49632
50324
|
},
|
|
49633
50325
|
{
|
|
@@ -49753,6 +50445,15 @@ function buildGlobalSettingsSchema() {
|
|
|
49753
50445
|
step: 1,
|
|
49754
50446
|
default: FACE_DEFAULTS.confirmFrames
|
|
49755
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
|
+
},
|
|
49756
50457
|
{
|
|
49757
50458
|
type: "number",
|
|
49758
50459
|
key: "bufferMaxPerDevice",
|
|
@@ -49830,129 +50531,6 @@ function evaluatePeriodicSnapshot(input) {
|
|
|
49830
50531
|
movedFraction
|
|
49831
50532
|
};
|
|
49832
50533
|
}
|
|
49833
|
-
/** Where the completion marker lives. */
|
|
49834
|
-
var MIGRATION_MARKER_COLLECTION = "pipeline-analytics:migrations";
|
|
49835
|
-
var MIGRATION_MARKER_KEY = "label-tier-4g";
|
|
49836
|
-
/**
|
|
49837
|
-
* The decision for ONE row, exposed so the rule is testable without a store.
|
|
49838
|
-
*
|
|
49839
|
-
* `null` = leave the row alone.
|
|
49840
|
-
*/
|
|
49841
|
-
function decideRowMigration(data, now) {
|
|
49842
|
-
const label = data["label"];
|
|
49843
|
-
if (typeof label !== "string" || label.length === 0) return null;
|
|
49844
|
-
if (typeof data["subLabel"] === "string" && data["subLabel"].length > 0) return null;
|
|
49845
|
-
if (data["labelMeta"] !== null && data["labelMeta"] !== void 0) return null;
|
|
49846
|
-
if (isMacroClass(label)) return {
|
|
49847
|
-
label: null,
|
|
49848
|
-
labelScore: null,
|
|
49849
|
-
labelMeta: null
|
|
49850
|
-
};
|
|
49851
|
-
return {
|
|
49852
|
-
label: null,
|
|
49853
|
-
labelScore: null,
|
|
49854
|
-
labelMeta: null,
|
|
49855
|
-
subLabel: label,
|
|
49856
|
-
subLabelScore: null,
|
|
49857
|
-
subLabelMeta: {
|
|
49858
|
-
stepId: MIGRATION_STEP_ID,
|
|
49859
|
-
decidedAt: now
|
|
49860
|
-
}
|
|
49861
|
-
};
|
|
49862
|
-
}
|
|
49863
|
-
/** Migrate one collection, paged and bounded. */
|
|
49864
|
-
async function migrateCollectionLabelTiers(deps, collection) {
|
|
49865
|
-
let scanned = 0;
|
|
49866
|
-
let demoted = 0;
|
|
49867
|
-
let cleared = 0;
|
|
49868
|
-
let skipped = 0;
|
|
49869
|
-
let offset = 0;
|
|
49870
|
-
let truncated = false;
|
|
49871
|
-
for (;;) {
|
|
49872
|
-
if (scanned >= 2e5) {
|
|
49873
|
-
truncated = true;
|
|
49874
|
-
break;
|
|
49875
|
-
}
|
|
49876
|
-
const rows = await deps.store.query.query({
|
|
49877
|
-
collection,
|
|
49878
|
-
filter: {
|
|
49879
|
-
limit: 500,
|
|
49880
|
-
offset
|
|
49881
|
-
}
|
|
49882
|
-
});
|
|
49883
|
-
if (rows.length === 0) break;
|
|
49884
|
-
for (const row of rows) {
|
|
49885
|
-
scanned++;
|
|
49886
|
-
const patch = decideRowMigration(row.data, deps.now());
|
|
49887
|
-
if (patch === null) {
|
|
49888
|
-
skipped++;
|
|
49889
|
-
continue;
|
|
49890
|
-
}
|
|
49891
|
-
try {
|
|
49892
|
-
await deps.store.update.mutate({
|
|
49893
|
-
collection,
|
|
49894
|
-
id: row.id,
|
|
49895
|
-
data: patch
|
|
49896
|
-
});
|
|
49897
|
-
if (patch["subLabel"] === void 0) cleared++;
|
|
49898
|
-
else demoted++;
|
|
49899
|
-
} catch (err) {
|
|
49900
|
-
deps.logger.warn("label tier migration row failed", { meta: {
|
|
49901
|
-
collection,
|
|
49902
|
-
id: row.id,
|
|
49903
|
-
error: String(err)
|
|
49904
|
-
} });
|
|
49905
|
-
skipped++;
|
|
49906
|
-
}
|
|
49907
|
-
}
|
|
49908
|
-
if (rows.length < 500) break;
|
|
49909
|
-
offset += rows.length;
|
|
49910
|
-
}
|
|
49911
|
-
return {
|
|
49912
|
-
collection,
|
|
49913
|
-
scanned,
|
|
49914
|
-
demoted,
|
|
49915
|
-
cleared,
|
|
49916
|
-
skipped,
|
|
49917
|
-
truncated
|
|
49918
|
-
};
|
|
49919
|
-
}
|
|
49920
|
-
/**
|
|
49921
|
-
* Run the migration across the collections that carry the tiered columns, once.
|
|
49922
|
-
*
|
|
49923
|
-
* The marker short-circuits a completed migration; it is written only when
|
|
49924
|
-
* NEITHER collection was truncated, so an interrupted pass resumes on the next
|
|
49925
|
-
* boot instead of declaring itself done.
|
|
49926
|
-
*/
|
|
49927
|
-
async function runLabelTierMigration(deps, collections) {
|
|
49928
|
-
try {
|
|
49929
|
-
const marker = await deps.store.get.query({
|
|
49930
|
-
collection: MIGRATION_MARKER_COLLECTION,
|
|
49931
|
-
key: MIGRATION_MARKER_KEY
|
|
49932
|
-
});
|
|
49933
|
-
if (marker !== null && marker !== void 0) return [];
|
|
49934
|
-
} catch {}
|
|
49935
|
-
const stats = [];
|
|
49936
|
-
for (const collection of collections) stats.push(await migrateCollectionLabelTiers(deps, collection));
|
|
49937
|
-
if (stats.every((s) => !s.truncated)) try {
|
|
49938
|
-
await deps.store.set.mutate({
|
|
49939
|
-
collection: MIGRATION_MARKER_COLLECTION,
|
|
49940
|
-
key: MIGRATION_MARKER_KEY,
|
|
49941
|
-
value: { completedAt: deps.now() }
|
|
49942
|
-
});
|
|
49943
|
-
} catch (err) {
|
|
49944
|
-
deps.logger.warn("label tier migration marker failed", { meta: { error: String(err) } });
|
|
49945
|
-
}
|
|
49946
|
-
for (const s of stats) deps.logger.info("label tier migration", { meta: {
|
|
49947
|
-
collection: s.collection,
|
|
49948
|
-
scanned: s.scanned,
|
|
49949
|
-
demoted: s.demoted,
|
|
49950
|
-
cleared: s.cleared,
|
|
49951
|
-
skipped: s.skipped,
|
|
49952
|
-
truncated: s.truncated
|
|
49953
|
-
} });
|
|
49954
|
-
return stats;
|
|
49955
|
-
}
|
|
49956
50534
|
//#endregion
|
|
49957
50535
|
//#region src/pipeline-analytics/store/object-embedding-store.ts
|
|
49958
50536
|
/**
|
|
@@ -51256,6 +51834,16 @@ var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
|
|
|
51256
51834
|
var AUDIO_EPISODE_VISUAL_QUIET_MS = 2e3;
|
|
51257
51835
|
/** Persist lastSeen on an open synthetic audio track at most this often. */
|
|
51258
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;
|
|
51259
51847
|
var MOTION_EVENT_HEARTBEAT_MS = 5e3;
|
|
51260
51848
|
/**
|
|
51261
51849
|
* Stored media kinds that carry NO drawn bounding box, in fallback preference
|
|
@@ -52364,6 +52952,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
52364
52952
|
ViewerSettingsSnapshotStore.declare(api.settingsStore)
|
|
52365
52953
|
]);
|
|
52366
52954
|
this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
|
|
52955
|
+
try {
|
|
52956
|
+
await adoptLegacyViewerSettingsSnapshots({
|
|
52957
|
+
store: api.settingsStore,
|
|
52958
|
+
logger: this.ctx.logger.child("viewer-settings")
|
|
52959
|
+
});
|
|
52960
|
+
} catch (err) {
|
|
52961
|
+
this.ctx.logger.warn("viewer-settings legacy adoption threw", { meta: { error: errMsg(err) } });
|
|
52962
|
+
}
|
|
52367
52963
|
runLabelTierMigration({
|
|
52368
52964
|
store: api.settingsStore,
|
|
52369
52965
|
logger: this.ctx.logger,
|
|
@@ -55103,6 +55699,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
55103
55699
|
frameHeight: frame.frameHeight,
|
|
55104
55700
|
score: detail.score,
|
|
55105
55701
|
embedding: decodeEmbeddingBase64(detail.embedding),
|
|
55702
|
+
...detail.embeddingMagnitude !== void 0 ? { embeddingMagnitude: detail.embeddingMagnitude } : {},
|
|
55106
55703
|
parentBbox: { ...frame.bbox },
|
|
55107
55704
|
...detail.bbox !== void 0 ? { faceBbox: { ...detail.bbox } } : {},
|
|
55108
55705
|
...detail.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: detail.nativeFaceShortSidePx } : {},
|
|
@@ -55570,7 +56167,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
55570
56167
|
return;
|
|
55571
56168
|
}
|
|
55572
56169
|
}
|
|
55573
|
-
|
|
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) {
|
|
55574
56182
|
const ev = {
|
|
55575
56183
|
id: randomUUID(),
|
|
55576
56184
|
deviceId: input.deviceId,
|
|
@@ -55579,18 +56187,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
55579
56187
|
rms: input.rms,
|
|
55580
56188
|
dbfs: input.dbfs,
|
|
55581
56189
|
classification: {
|
|
55582
|
-
className:
|
|
56190
|
+
className: input.className,
|
|
55583
56191
|
...input.originalClass !== void 0 ? { originalClass: input.originalClass } : {},
|
|
55584
56192
|
score: input.score
|
|
55585
56193
|
}
|
|
55586
56194
|
};
|
|
55587
56195
|
this.lastAudioInsertByDevice.set(input.deviceId, {
|
|
55588
|
-
className:
|
|
56196
|
+
className: input.className,
|
|
55589
56197
|
atMs: input.timestamp
|
|
55590
56198
|
});
|
|
55591
56199
|
await this.eventStore.insertAudio(ev);
|
|
55592
56200
|
this.notificationCenter?.onAudioEventPersisted(ev);
|
|
55593
|
-
this.trackStore?.addAudioLabelEpisode(input.deviceId,
|
|
56201
|
+
this.trackStore?.addAudioLabelEpisode(input.deviceId, input.className, input.score, input.timestamp);
|
|
55594
56202
|
this.ctx.eventBus.emit({
|
|
55595
56203
|
id: `pa-${ev.id}`,
|
|
55596
56204
|
timestamp: new Date(ev.timestamp),
|
|
@@ -55607,6 +56215,25 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
55607
56215
|
timestamp: ev.timestamp
|
|
55608
56216
|
}
|
|
55609
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
|
+
});
|
|
55610
56237
|
if (this.hasLiveVisualTrack(input.deviceId, input.timestamp)) return;
|
|
55611
56238
|
const materializer = this.syntheticTracks;
|
|
55612
56239
|
if (materializer === null) return;
|
|
@@ -55718,19 +56345,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
55718
56345
|
});
|
|
55719
56346
|
return;
|
|
55720
56347
|
}
|
|
55721
|
-
|
|
56348
|
+
const classified = {
|
|
55722
56349
|
deviceId,
|
|
55723
56350
|
timestamp,
|
|
55724
56351
|
className: route.className,
|
|
55725
56352
|
score: topClassification.score,
|
|
55726
|
-
originalClass: topClassification.debug
|
|
56353
|
+
...topClassification.debug?.originalClass !== void 0 ? { originalClass: topClassification.debug.originalClass } : {},
|
|
55727
56354
|
rms: level?.rms ?? 0,
|
|
55728
|
-
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,
|
|
55729
56363
|
knobs: this.audioEpisodeKnobs(settings)
|
|
55730
56364
|
});
|
|
55731
56365
|
return;
|
|
55732
56366
|
}
|
|
55733
|
-
await this.driveAudioEpisode({
|
|
56367
|
+
if (settings.audioEpisodeEnabled) await this.driveAudioEpisode({
|
|
55734
56368
|
deviceId,
|
|
55735
56369
|
timestamp,
|
|
55736
56370
|
className: null,
|