@camstack/addon-post-analysis 1.2.134 → 1.2.136
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-DruBvw_u.mjs → dist-BAN19J7s.mjs} +255 -25
- package/dist/{dist-Dqa0bdV6.js → dist-kn_4KEmG.js} +255 -25
- package/dist/embedding-encoder/index.js +3 -295
- package/dist/embedding-encoder/index.mjs +2 -294
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CWyLksI0.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CrPuzi1S.mjs} +3 -3
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-D_xOplZC.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-BTMdnoEZ.mjs → hostInit-DR1JAJld.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +1180 -132
- package/dist/pipeline-analytics/index.mjs +1180 -132
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CZJ4DupR.mjs +0 -26
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as customAction, A as NcTaxonomySchema, At as sleep, B as TIMELAPSE_DENSE_FLOOR_SEC, Bt as string, C as NcRuleSchema, Ct as errMsg, D as NcSnoozeSchema, Dt as hydrateSchema, E as NcSnoozeInputSchema, Et as createEvent, Ft as literal, G as VISIT_MERGE_GAP_MS, H as TimelapseRulePatchSchema, Ht as EventCategory, I as SCENE_DEFAULT_ANCHOR_THRESHOLD, It as number, J as assertTimelapseCadences, K as addonWidgetsSourceCapability, L as SCENE_DEFAULT_UNCOVERED_POLICY, Lt as object, Mt as array, N as RECORDING_EXPORT_MAX_READ_BYTES, Nt as boolean, O as NcSnoozeSuppressedSchema, Ot as isDeviceScopedCap, P as RetrainStatusSchema, Pt as discriminatedUnion, Q as cosineSimilarity$1, R as SCENE_DIVERGED, Rt as partialRecord, S as NcRulePatchSchema, St as zoneAnalyticsCapability, T as NcScheduleSchema, Tt as DeviceType, U as TimelapseRuleSchema, V as TimelapseRuleInputSchema, Vt as unknown, W as TrackSourceSchema, X as audioModeOf, Y as audioMetricsCapability, Z as buildEventKindDescriptor, _ as NC_DEFAULT_SNOOZE_MINUTES, _t as sceneMonitorCapability, a as DEFAULT_EVENT_COLOR, b as NcConditionDescriptorSchema, bt as vectorDimFromBase64, ct as kebabToCamel, d as EVENT_PAD_MS, dt as pickClusterStepModels, et as defineCustomActions, f as FULL_IMAGE_BBOX, ft as pipelineAnalyticsCapability, g as NC_CONDITION_CATALOG, h as NC_ALARM_SYSTEM_EVENT_KINDS, ht as readTimelapseGeneratedAt, i as CLUSTER_MODEL_SCOPED_STEPS, it as faceGalleryCapability, j as OpsLogEntrySchema, jt as _enum, k as NcSystemEventKindSchema, kt as nodePin, l as DeclaredDevices, lt as notificationRulesCapability, m as MACRO_LABELS, mt as readDeviceStateFrom, n as AnalyticsGroupRecordSchema, ot as isDetectionMacroClass, p as LabelAttributionSchema, pt as plateGalleryCapability, q as alarmPanelCapability, r as BaseDevice, rt as encodeVectorBase64, s as DETECTION_MACRO_CLASSES, st as isScheduleActive, t as AnalyticsGroupDetailSchema, tt as deriveRecordingMode, u as EVENT_KIND_BY_CAP, vt as subKindsOf, w as NcRuleTargetSchema, wt as BaseAddon, x as NcRuleInputSchema, xt as videoclipsCapability, y as NC_TAXONOMY, yt as systemEventFilterApplies, z as SceneMonitorSchema, zt as record } from "../dist-
|
|
1
|
+
import { $ as customAction, A as NcTaxonomySchema, At as sleep, B as TIMELAPSE_DENSE_FLOOR_SEC, Bt as string, C as NcRuleSchema, Ct as errMsg, D as NcSnoozeSchema, Dt as hydrateSchema, E as NcSnoozeInputSchema, Et as createEvent, Ft as literal, G as VISIT_MERGE_GAP_MS, H as TimelapseRulePatchSchema, Ht as EventCategory, I as SCENE_DEFAULT_ANCHOR_THRESHOLD, It as number, J as assertTimelapseCadences, K as addonWidgetsSourceCapability, L as SCENE_DEFAULT_UNCOVERED_POLICY, Lt as object, Mt as array, N as RECORDING_EXPORT_MAX_READ_BYTES, Nt as boolean, O as NcSnoozeSuppressedSchema, Ot as isDeviceScopedCap, P as RetrainStatusSchema, Pt as discriminatedUnion, Q as cosineSimilarity$1, R as SCENE_DIVERGED, Rt as partialRecord, S as NcRulePatchSchema, St as zoneAnalyticsCapability, T as NcScheduleSchema, Tt as DeviceType, U as TimelapseRuleSchema, V as TimelapseRuleInputSchema, Vt as unknown, W as TrackSourceSchema, X as audioModeOf, Y as audioMetricsCapability, Z as buildEventKindDescriptor, _ as NC_DEFAULT_SNOOZE_MINUTES, _t as sceneMonitorCapability, a as DEFAULT_EVENT_COLOR, b as NcConditionDescriptorSchema, bt as vectorDimFromBase64, ct as kebabToCamel, d as EVENT_PAD_MS, dt as pickClusterStepModels, et as defineCustomActions, f as FULL_IMAGE_BBOX, ft as pipelineAnalyticsCapability, g as NC_CONDITION_CATALOG, h as NC_ALARM_SYSTEM_EVENT_KINDS, ht as readTimelapseGeneratedAt, i as CLUSTER_MODEL_SCOPED_STEPS, it as faceGalleryCapability, j as OpsLogEntrySchema, jt as _enum, k as NcSystemEventKindSchema, kt as nodePin, l as DeclaredDevices, lt as notificationRulesCapability, m as MACRO_LABELS, mt as readDeviceStateFrom, n as AnalyticsGroupRecordSchema, ot as isDetectionMacroClass, p as LabelAttributionSchema, pt as plateGalleryCapability, q as alarmPanelCapability, r as BaseDevice, rt as encodeVectorBase64, s as DETECTION_MACRO_CLASSES, st as isScheduleActive, t as AnalyticsGroupDetailSchema, tt as deriveRecordingMode, u as EVENT_KIND_BY_CAP, vt as subKindsOf, w as NcRuleTargetSchema, wt as BaseAddon, x as NcRuleInputSchema, xt as videoclipsCapability, y as NC_TAXONOMY, yt as systemEventFilterApplies, z as SceneMonitorSchema, zt as record } from "../dist-BAN19J7s.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";
|
|
@@ -26978,8 +26978,12 @@ function rankAndCap(faces, order, limit) {
|
|
|
26978
26978
|
* track through FaceStore.deleteByTracks, and enrolled (`assigned`) rows are
|
|
26979
26979
|
* exempt from that and from every sweep — an assignment is a curation act.
|
|
26980
26980
|
* The only other bound is capacity: pruneCapOverflow holds each camera's
|
|
26981
|
-
* UNASSIGNED buffer to bufferMaxPerDevice newest rows (default 50)
|
|
26982
|
-
*
|
|
26981
|
+
* UNASSIGNED buffer to bufferMaxPerDevice newest rows (default 50), plus a
|
|
26982
|
+
* SEPARATE, larger budget of deriveSuggestedBufferCap(bufferMaxPerDevice)
|
|
26983
|
+
* rows (default 200) for rows carrying a suggestedIdentityId — a question
|
|
26984
|
+
* the operator has not answered outlives a row nobody asked about, and
|
|
26985
|
+
* evicting one is logged with its deviceId. At 10.8 KB of JSON embedding
|
|
26986
|
+
* per row, those caps are what keep the table small."
|
|
26983
26987
|
*/
|
|
26984
26988
|
var FACES_COLLECTION = "pipeline-analytics:faces";
|
|
26985
26989
|
/**
|
|
@@ -27007,6 +27011,17 @@ var FACES_COLLECTION = "pipeline-analytics:faces";
|
|
|
27007
27011
|
* cap, so the page is always the caller's bound and never the engine's.
|
|
27008
27012
|
*/
|
|
27009
27013
|
var FACE_SWEEP_PAGE = 1e3;
|
|
27014
|
+
/** How many evicted face ids one per-camera eviction line names before it
|
|
27015
|
+
* stops — enough to find the rows, not enough to turn a log line into a page. */
|
|
27016
|
+
var SUGGESTED_EVICTION_IDS_LOGGED = 10;
|
|
27017
|
+
/**
|
|
27018
|
+
* A stored suggestion is present. The relational backend surfaces an unset
|
|
27019
|
+
* column as SQL NULL and the fake store as `undefined`; an empty string is
|
|
27020
|
+
* neither an identity nor a question, so it is not a suggestion either.
|
|
27021
|
+
*/
|
|
27022
|
+
function hasSuggestion(raw) {
|
|
27023
|
+
return typeof raw === "string" && raw.length > 0;
|
|
27024
|
+
}
|
|
27010
27025
|
/**
|
|
27011
27026
|
* Refuses to loop forever if a page ever fails to advance. 1 000 pages is a
|
|
27012
27027
|
* million faces — far past anything real, and reached only by a bug.
|
|
@@ -27213,12 +27228,21 @@ var FaceStore = class {
|
|
|
27213
27228
|
* property the recognizer actually depends on: the gallery of recent
|
|
27214
27229
|
* unassigned faces stays small.
|
|
27215
27230
|
*
|
|
27216
|
-
*
|
|
27217
|
-
*
|
|
27231
|
+
* **A row the system asked a question about outlives one it said nothing
|
|
27232
|
+
* about.** A single cap evicted both alike, so on 15 of 24 live cameras
|
|
27233
|
+
* (measured 2026-08-25) the buffer sat at exactly the cap and an unanswered
|
|
27234
|
+
* suggestion was deleted within about a day. Rows carrying a
|
|
27235
|
+
* `suggestedIdentityId` are ranked in their OWN, larger budget
|
|
27236
|
+
* (`maxSuggestedPerDevice`) — never immortality, because nothing clears a
|
|
27237
|
+
* suggestion the operator merely declines to answer. Size and trade-off:
|
|
27238
|
+
* `deriveSuggestedBufferCap` in `face-settings.ts:263`.
|
|
27239
|
+
*
|
|
27240
|
+
* ENROLLED (`assigned`) rows are skipped entirely and consume NO slot in
|
|
27241
|
+
* either tier. Returns all deleted ids (the caller removes their crops).
|
|
27218
27242
|
*/
|
|
27219
27243
|
async pruneCapOverflow(input) {
|
|
27220
27244
|
/**
|
|
27221
|
-
* Paged, not one statement. Only
|
|
27245
|
+
* Paged, not one statement. Only four scalars per row survive the page —
|
|
27222
27246
|
* the `embedding` string that dominates the row is dropped as soon as the
|
|
27223
27247
|
* page goes out of scope, so peak heap is one page rather than the whole
|
|
27224
27248
|
* face table. The prune still considers EVERY row, which is what makes it a
|
|
@@ -27240,38 +27264,79 @@ var FaceStore = class {
|
|
|
27240
27264
|
if (rows.length === 0) break;
|
|
27241
27265
|
for (const r of rows) {
|
|
27242
27266
|
const data = r.data;
|
|
27243
|
-
if (data
|
|
27244
|
-
const deviceId = Number(data
|
|
27267
|
+
if (data["assigned"]) continue;
|
|
27268
|
+
const deviceId = Number(data["deviceId"] ?? -1);
|
|
27245
27269
|
const list = byDevice.get(deviceId) ?? [];
|
|
27246
27270
|
list.push({
|
|
27247
27271
|
id: r.id,
|
|
27248
|
-
timestamp: Number(data
|
|
27272
|
+
timestamp: Number(data["timestamp"] ?? 0),
|
|
27273
|
+
suggested: hasSuggestion(data["suggestedIdentityId"])
|
|
27249
27274
|
});
|
|
27250
27275
|
byDevice.set(deviceId, list);
|
|
27251
27276
|
}
|
|
27252
27277
|
if (rows.length < 1e3) break;
|
|
27253
27278
|
}
|
|
27254
27279
|
const toDelete = /* @__PURE__ */ new Set();
|
|
27255
|
-
|
|
27280
|
+
const suggestedVictims = /* @__PURE__ */ new Map();
|
|
27281
|
+
for (const [deviceId, list] of byDevice) {
|
|
27256
27282
|
list.sort((a, b) => b.timestamp - a.timestamp);
|
|
27257
|
-
|
|
27258
|
-
|
|
27259
|
-
|
|
27283
|
+
let ordinaryRank = 0;
|
|
27284
|
+
let suggestedRank = 0;
|
|
27285
|
+
for (const row of list) {
|
|
27286
|
+
if (row.suggested) {
|
|
27287
|
+
if (suggestedRank++ < input.maxSuggestedPerDevice) continue;
|
|
27288
|
+
toDelete.add(row.id);
|
|
27289
|
+
const victims = suggestedVictims.get(deviceId) ?? [];
|
|
27290
|
+
victims.push(row.id);
|
|
27291
|
+
suggestedVictims.set(deviceId, victims);
|
|
27292
|
+
continue;
|
|
27293
|
+
}
|
|
27294
|
+
if (ordinaryRank++ < input.maxPerDevice) continue;
|
|
27295
|
+
toDelete.add(row.id);
|
|
27296
|
+
}
|
|
27260
27297
|
}
|
|
27261
|
-
const deleted =
|
|
27298
|
+
const deleted = /* @__PURE__ */ new Set();
|
|
27262
27299
|
for (const id of toDelete) try {
|
|
27263
27300
|
await this.store.delete.mutate({
|
|
27264
27301
|
collection: FACES_COLLECTION,
|
|
27265
27302
|
key: id
|
|
27266
27303
|
});
|
|
27267
|
-
deleted.
|
|
27304
|
+
deleted.add(id);
|
|
27268
27305
|
} catch (err) {
|
|
27269
27306
|
this.logger.warn("FaceStore.pruneCapOverflow delete failed", { meta: {
|
|
27270
27307
|
faceId: id,
|
|
27271
27308
|
error: String(err)
|
|
27272
27309
|
} });
|
|
27273
27310
|
}
|
|
27274
|
-
|
|
27311
|
+
this.announceSuggestedEvictions(suggestedVictims, deleted, input.maxSuggestedPerDevice);
|
|
27312
|
+
return [...deleted];
|
|
27313
|
+
}
|
|
27314
|
+
/**
|
|
27315
|
+
* Name every camera that lost an UNANSWERED suggestion, and how many.
|
|
27316
|
+
*
|
|
27317
|
+
* This exists because the loss it reports ran for weeks without a single log
|
|
27318
|
+
* line: the prune only ever logged an aggregate count with no `deviceId`, so
|
|
27319
|
+
* "why did camera 618 forget the person it asked me about" could not be asked
|
|
27320
|
+
* of the logs at all. One line per camera (not per row) keeps the volume sane
|
|
27321
|
+
* while still answering the per-camera question.
|
|
27322
|
+
*
|
|
27323
|
+
* Reports rows that were ACTUALLY deleted — a row whose delete threw is still
|
|
27324
|
+
* in the buffer, and is reported by the failure line above instead.
|
|
27325
|
+
*/
|
|
27326
|
+
announceSuggestedEvictions(victimsByDevice, deleted, cap) {
|
|
27327
|
+
for (const [deviceId, victims] of victimsByDevice) {
|
|
27328
|
+
const gone = victims.filter((id) => deleted.has(id));
|
|
27329
|
+
if (gone.length === 0) continue;
|
|
27330
|
+
this.logger.warn("face buffer evicted UNANSWERED suggestions — the operator never saw them", {
|
|
27331
|
+
tags: { deviceId },
|
|
27332
|
+
meta: {
|
|
27333
|
+
deleted: gone.length,
|
|
27334
|
+
cap,
|
|
27335
|
+
faceIds: gone.slice(0, SUGGESTED_EVICTION_IDS_LOGGED),
|
|
27336
|
+
why: "the suggested tier is full on this camera: suggestions arrive faster than they are answered, and the OLDEST unanswered ones are dropped first."
|
|
27337
|
+
}
|
|
27338
|
+
});
|
|
27339
|
+
}
|
|
27275
27340
|
}
|
|
27276
27341
|
/** Flip `assigned` to true and record which identity was matched. Optionally persist the sample id. */
|
|
27277
27342
|
async markAssigned(faceId, identityId, sampleId) {
|
|
@@ -28997,9 +29062,18 @@ function pairRiders(detections) {
|
|
|
28997
29062
|
}
|
|
28998
29063
|
//#endregion
|
|
28999
29064
|
//#region src/pipeline-analytics/pipeline/tracker/rescue-growth.ts
|
|
29000
|
-
/**
|
|
29001
|
-
*
|
|
29002
|
-
|
|
29065
|
+
/**
|
|
29066
|
+
* How far the grown box's centroid may sit, as a multiple of the **TRACK's own**
|
|
29067
|
+
* diagonal. It was a multiple of the LARGER of the two diagonals until
|
|
29068
|
+
* 2026-08-25, which meant the bigger the INTRUDING detection, the further it
|
|
29069
|
+
* could reach to steal an identity. Live track `59dfb972` lost its car that
|
|
29070
|
+
* way: a 386×752 box 574 px away out-reached a 364×385 track purely because it
|
|
29071
|
+
* was 2.07× the area. The reach is now a property of the track alone.
|
|
29072
|
+
*
|
|
29073
|
+
* 2.3 covers the live `4285605a` bird→dog jump (74.7 px over a 35.8 px crop
|
|
29074
|
+
* diagonal = 2.085) with the same ~10% headroom the old 1.35 gave it.
|
|
29075
|
+
*/
|
|
29076
|
+
var GROWTH_CENTROID_FACTOR = 2.3;
|
|
29003
29077
|
function intersectionArea(a, b) {
|
|
29004
29078
|
const x1 = Math.max(a.x, b.x);
|
|
29005
29079
|
const y1 = Math.max(a.y, b.y);
|
|
@@ -29008,11 +29082,30 @@ function intersectionArea(a, b) {
|
|
|
29008
29082
|
if (x2 <= x1 || y2 <= y1) return 0;
|
|
29009
29083
|
return (x2 - x1) * (y2 - y1);
|
|
29010
29084
|
}
|
|
29011
|
-
/**
|
|
29012
|
-
*
|
|
29013
|
-
*
|
|
29014
|
-
*
|
|
29015
|
-
|
|
29085
|
+
/**
|
|
29086
|
+
* Same-class partial crop growing into (or collapsing from) a full-body box
|
|
29087
|
+
* with IoU 0. Used by rescue and spawn-dedup so we do not mint a second id.
|
|
29088
|
+
* A SMALL box nested inside a LARGE one is occlusion, not growth — those
|
|
29089
|
+
* stay two tracks (sort-tracker occlusion suite).
|
|
29090
|
+
*
|
|
29091
|
+
* **Geometry alone cannot make this gate safe, and that is measured, not
|
|
29092
|
+
* assumed.** The two live cases:
|
|
29093
|
+
*
|
|
29094
|
+
* | case | dist | trackDiag | dist/trackDiag | dist/maxDiag | dt | trackDiag/s |
|
|
29095
|
+
* | --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
|
29096
|
+
* | `4285605a` bird→dog (KEEP) | 74.7 | 35.8 | 2.085 | 1.238 | 170 ms | 12.26 |
|
|
29097
|
+
* | `59dfb972` car A→car B (REJECT) | 573.8 | 529.8 | 1.083 | 0.679 | 106 ms | 10.22 |
|
|
29098
|
+
*
|
|
29099
|
+
* The theft is a *milder* jump than the case the gate exists for, in EVERY
|
|
29100
|
+
* scale-relative measure — and still milder once divided by elapsed time. A
|
|
29101
|
+
* scale-invariant geometric predicate that accepts the bird therefore MUST
|
|
29102
|
+
* accept the car; so must a speed cap expressed in track-diagonals per second.
|
|
29103
|
+
* The separator has to be something other than the shape of the two boxes, and
|
|
29104
|
+
* the one that holds is track MATURITY ({@link GROWTH_MAX_TRACK_AGE_MS}).
|
|
29105
|
+
*/
|
|
29106
|
+
function growthAdjacentMatch(track, det, ctx) {
|
|
29107
|
+
if (ctx.trackAgeMs > 1500) return false;
|
|
29108
|
+
if (ctx.gapMs > 1e3) return false;
|
|
29016
29109
|
const trackArea = track.w * track.h;
|
|
29017
29110
|
const detArea = det.w * det.h;
|
|
29018
29111
|
if (trackArea <= 0 || detArea <= 0) return false;
|
|
@@ -29022,8 +29115,24 @@ function growthAdjacentMatch(track, det) {
|
|
|
29022
29115
|
if (intersectionArea(track, det) / smaller >= .5) return false;
|
|
29023
29116
|
const tc = bboxCentroid(track);
|
|
29024
29117
|
const dc = bboxCentroid(det);
|
|
29025
|
-
return Math.hypot(tc.x - dc.x, tc.y - dc.y) <= GROWTH_CENTROID_FACTOR * Math.
|
|
29118
|
+
return Math.hypot(tc.x - dc.x, tc.y - dc.y) <= GROWTH_CENTROID_FACTOR * Math.hypot(track.w, track.h);
|
|
29026
29119
|
}
|
|
29120
|
+
/** Per-(stage, gate) counter + sampling decision. One instance per tracker, so
|
|
29121
|
+
* the counts are per camera and per detection source. */
|
|
29122
|
+
var LooseBindSampler = class {
|
|
29123
|
+
counts = /* @__PURE__ */ new Map();
|
|
29124
|
+
/** Increment the (stage, gate) counter and answer with the running total when
|
|
29125
|
+
* this decision should be reported, or `undefined` when it is sampled out. */
|
|
29126
|
+
next(stage, gate) {
|
|
29127
|
+
const key = `${stage}|${gate}`;
|
|
29128
|
+
const count = (this.counts.get(key) ?? 0) + 1;
|
|
29129
|
+
this.counts.set(key, count);
|
|
29130
|
+
if (count === 1 || count % 50 === 0) return count;
|
|
29131
|
+
}
|
|
29132
|
+
reset() {
|
|
29133
|
+
this.counts.clear();
|
|
29134
|
+
}
|
|
29135
|
+
};
|
|
29027
29136
|
//#endregion
|
|
29028
29137
|
//#region src/pipeline-analytics/pipeline/tracker/sort-tracker.ts
|
|
29029
29138
|
var DEFAULT_TRACKER_CONFIG = {
|
|
@@ -29101,12 +29210,62 @@ var SortTracker = class SortTracker {
|
|
|
29101
29210
|
* moment the pair stops overlapping and are pruned when either track dies.
|
|
29102
29211
|
*/
|
|
29103
29212
|
dupStreaks = /* @__PURE__ */ new Map();
|
|
29213
|
+
/** Per-(stage, gate) counters behind the sampled salvage-bind diagnostics. */
|
|
29214
|
+
bindSampler = new LooseBindSampler();
|
|
29215
|
+
/** Sink for salvage-bind diagnostics. Null until an owner that knows the
|
|
29216
|
+
* `deviceId` wires one — see {@link setBindObserver}. */
|
|
29217
|
+
bindObserver = null;
|
|
29104
29218
|
constructor(config = {}) {
|
|
29105
29219
|
this.config = {
|
|
29106
29220
|
...DEFAULT_TRACKER_CONFIG,
|
|
29107
29221
|
...config
|
|
29108
29222
|
};
|
|
29109
29223
|
}
|
|
29224
|
+
/**
|
|
29225
|
+
* Wire the diagnostics sink for the salvage branches (rescue, resurrection,
|
|
29226
|
+
* growth spawn-dedup). The tracker has no `deviceId`; the caller that does
|
|
29227
|
+
* stamps it on the log line. Stage 1 is never reported — it runs per track
|
|
29228
|
+
* per frame at frame-rate.
|
|
29229
|
+
*/
|
|
29230
|
+
setBindObserver(observer) {
|
|
29231
|
+
this.bindObserver = observer;
|
|
29232
|
+
}
|
|
29233
|
+
/** Emit one salvage decision, subject to {@link LOOSE_BIND_LOG_SAMPLE_EVERY}.
|
|
29234
|
+
* Nothing is computed when no observer is wired. */
|
|
29235
|
+
reportBind(stage, gate, track, det, timestamp) {
|
|
29236
|
+
const observer = this.bindObserver;
|
|
29237
|
+
if (!observer) return;
|
|
29238
|
+
const bindCount = this.bindSampler.next(stage, gate);
|
|
29239
|
+
if (bindCount === void 0) return;
|
|
29240
|
+
const tc = bboxCentroid(track.bbox);
|
|
29241
|
+
const dc = bboxCentroid(det.bbox);
|
|
29242
|
+
const trackArea = track.bbox.w * track.bbox.h;
|
|
29243
|
+
const detArea = det.bbox.w * det.bbox.h;
|
|
29244
|
+
const smaller = Math.min(trackArea, detArea);
|
|
29245
|
+
observer({
|
|
29246
|
+
stage,
|
|
29247
|
+
gate,
|
|
29248
|
+
trackId: track.id,
|
|
29249
|
+
trackClass: track.class,
|
|
29250
|
+
detClass: det.class,
|
|
29251
|
+
detScore: det.score,
|
|
29252
|
+
iou: iou$2(track.bbox, det.bbox),
|
|
29253
|
+
centroidDistPx: Math.hypot(tc.x - dc.x, tc.y - dc.y),
|
|
29254
|
+
trackDiagPx: Math.hypot(track.bbox.w, track.bbox.h),
|
|
29255
|
+
areaRatio: smaller > 0 ? Math.max(trackArea, detArea) / smaller : 0,
|
|
29256
|
+
dtMs: timestamp - track.lastSeen,
|
|
29257
|
+
trackAgeMs: timestamp - track.firstSeen,
|
|
29258
|
+
bindCount,
|
|
29259
|
+
sampledEvery: 50
|
|
29260
|
+
});
|
|
29261
|
+
}
|
|
29262
|
+
/** When the pair is being judged, relative to `track`. */
|
|
29263
|
+
static growthContext(track, timestamp) {
|
|
29264
|
+
return {
|
|
29265
|
+
trackAgeMs: timestamp - track.firstSeen,
|
|
29266
|
+
gapMs: timestamp - track.lastSeen
|
|
29267
|
+
};
|
|
29268
|
+
}
|
|
29110
29269
|
/** Whether two macro-classes may be collapsed as the SAME subject. Same class
|
|
29111
29270
|
* always; the {person,animal} pair only when `personAnimalDedup` is on. */
|
|
29112
29271
|
dedupCompatible(a, b) {
|
|
@@ -29151,8 +29310,15 @@ var SortTracker = class SortTracker {
|
|
|
29151
29310
|
* set of tracks already surviving this frame (matched, coasting, resurrected,
|
|
29152
29311
|
* and earlier same-frame spawns).
|
|
29153
29312
|
*/
|
|
29154
|
-
isDuplicateSpawn(det, live) {
|
|
29155
|
-
for (const t of live)
|
|
29313
|
+
isDuplicateSpawn(det, live, timestamp) {
|
|
29314
|
+
for (const t of live) {
|
|
29315
|
+
if (!this.dedupCompatible(t.class, det.class)) continue;
|
|
29316
|
+
if (iou$2(det.bbox, t.bbox) >= this.config.dedupSpawnIou) return true;
|
|
29317
|
+
if (growthAdjacentMatch(t.bbox, det.bbox, SortTracker.growthContext(t, timestamp))) {
|
|
29318
|
+
this.reportBind("spawn-dedup", "growth", t, det, timestamp);
|
|
29319
|
+
return true;
|
|
29320
|
+
}
|
|
29321
|
+
}
|
|
29156
29322
|
if (this.config.personAnimalDedup && det.class === ANIMAL_CLASS && det.score < this.config.animalOverPersonMaxScore) {
|
|
29157
29323
|
for (const t of live) if (t.class === PERSON_CLASS && iou$2(det.bbox, t.bbox) >= this.config.animalOverPersonIou) return true;
|
|
29158
29324
|
}
|
|
@@ -29239,15 +29405,36 @@ var SortTracker = class SortTracker {
|
|
|
29239
29405
|
* crouch→animal flip when `classGroupAssoc` is on, but never by an unrelated
|
|
29240
29406
|
* class.
|
|
29241
29407
|
*/
|
|
29242
|
-
looseMatch(track, det) {
|
|
29243
|
-
if (this.assocGroup(track.class) !== this.assocGroup(det.class)) return
|
|
29244
|
-
if (iou$2(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return
|
|
29408
|
+
looseMatch(track, det, timestamp) {
|
|
29409
|
+
if (this.assocGroup(track.class) !== this.assocGroup(det.class)) return null;
|
|
29410
|
+
if (iou$2(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return "iou";
|
|
29245
29411
|
const tc = bboxCentroid(track.bbox);
|
|
29246
29412
|
const dc = bboxCentroid(det.bbox);
|
|
29247
29413
|
const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
|
|
29248
29414
|
const diag = Math.hypot(track.bbox.w, track.bbox.h);
|
|
29249
|
-
if (dist <= this.config.rescueCentroidFactor * diag) return
|
|
29250
|
-
|
|
29415
|
+
if (dist <= this.config.rescueCentroidFactor * diag) return "centroid";
|
|
29416
|
+
if (growthAdjacentMatch(track.bbox, det.bbox, SortTracker.growthContext(track, timestamp))) return "growth";
|
|
29417
|
+
return null;
|
|
29418
|
+
}
|
|
29419
|
+
/**
|
|
29420
|
+
* Whether `det` would mint a track of its own if no salvage branch claimed it:
|
|
29421
|
+
* it clears BOTH spawn floors (per-class score, per-class bbox-area fraction).
|
|
29422
|
+
*
|
|
29423
|
+
* A rescue that rests on PROXIMITY ALONE — the `centroid` clause, reached only
|
|
29424
|
+
* when the overlap is already below `rescueIouThreshold` — must lose to such a
|
|
29425
|
+
* detection. Live track `27855a56` bound a second car 452 px away at IoU 0.058
|
|
29426
|
+
* purely because 452 ≤ 0.75 × the 589×445 track diagonal; the box it stole was
|
|
29427
|
+
* a full-size, floor-clearing vehicle that would have spawned perfectly well
|
|
29428
|
+
* on its own. **A spare track costs media; a stolen track corrupts the plate,
|
|
29429
|
+
* the travelled distance and the last frame of the track it was taken from.**
|
|
29430
|
+
*
|
|
29431
|
+
* The `iou` clause is untouched (real overlap IS evidence), and so is `growth`
|
|
29432
|
+
* — that one is bounded by track maturity inside `growthAdjacentMatch`, and a
|
|
29433
|
+
* partial crop is by construction the same subject, so refusing it here would
|
|
29434
|
+
* re-fragment `4285605a`.
|
|
29435
|
+
*/
|
|
29436
|
+
isPlausibleBirth(det, frameArea) {
|
|
29437
|
+
return this.meetsClassScoreFloor(det) && this.meetsSpawnAreaFloor(det, frameArea);
|
|
29251
29438
|
}
|
|
29252
29439
|
/** Fold a matched detection's class + score into a track's lifetime vote
|
|
29253
29440
|
* tally. A non-positive score still counts as an infinitesimal vote so a
|
|
@@ -29361,20 +29548,25 @@ var SortTracker = class SortTracker {
|
|
|
29361
29548
|
for (let di = 0; di < detections.length; di++) {
|
|
29362
29549
|
if (used.has(di) || lowSet.has(di)) continue;
|
|
29363
29550
|
const det = detections[di];
|
|
29364
|
-
|
|
29551
|
+
const gate = this.looseMatch(track, det, timestamp);
|
|
29552
|
+
if (gate === null) continue;
|
|
29553
|
+
if (gate === "centroid" && this.isPlausibleBirth(det, frameArea)) continue;
|
|
29365
29554
|
rescuePairs.push({
|
|
29366
29555
|
track,
|
|
29367
29556
|
detIdx: di,
|
|
29368
|
-
score: iou$2(track.bbox, det.bbox)
|
|
29557
|
+
score: iou$2(track.bbox, det.bbox),
|
|
29558
|
+
gate
|
|
29369
29559
|
});
|
|
29370
29560
|
}
|
|
29371
29561
|
}
|
|
29372
29562
|
rescuePairs.sort((a, b) => b.score - a.score);
|
|
29373
29563
|
for (const pair of rescuePairs) {
|
|
29374
29564
|
if (matchedTracks.has(pair.track) || used.has(pair.detIdx)) continue;
|
|
29375
|
-
|
|
29565
|
+
const det = detections[pair.detIdx];
|
|
29566
|
+
matched.set(pair.track, det);
|
|
29376
29567
|
matchedTracks.add(pair.track);
|
|
29377
29568
|
used.add(pair.detIdx);
|
|
29569
|
+
this.reportBind("rescue", pair.gate, pair.track, det, timestamp);
|
|
29378
29570
|
}
|
|
29379
29571
|
if (this.config.byteTrackEnabled && lowIdxs.length > 0) this.greedyAssociate(detections, lowIdxs, this.config.byteTrackLowIouThreshold, used, matchedTracks, matched);
|
|
29380
29572
|
for (const [track, det] of matched) {
|
|
@@ -29428,17 +29620,22 @@ var SortTracker = class SortTracker {
|
|
|
29428
29620
|
const det = detections[di];
|
|
29429
29621
|
let best;
|
|
29430
29622
|
let bestScore = -1;
|
|
29623
|
+
let bestGate = "iou";
|
|
29431
29624
|
for (const lost of this.lostTracks) {
|
|
29432
29625
|
if (!lost.resurrectable) continue;
|
|
29433
29626
|
if (timestamp - lost.lostAt > this.config.resurrectionWindowMs) continue;
|
|
29434
|
-
|
|
29627
|
+
const gate = this.looseMatch(lost, det, timestamp);
|
|
29628
|
+
if (gate === null) continue;
|
|
29629
|
+
if (gate === "centroid" && this.isPlausibleBirth(det, frameArea)) continue;
|
|
29435
29630
|
const score = iou$2(lost.bbox, det.bbox);
|
|
29436
29631
|
if (score > bestScore) {
|
|
29437
29632
|
best = lost;
|
|
29438
29633
|
bestScore = score;
|
|
29634
|
+
bestGate = gate;
|
|
29439
29635
|
}
|
|
29440
29636
|
}
|
|
29441
29637
|
if (!best) continue;
|
|
29638
|
+
this.reportBind("resurrection", bestGate, best, det, timestamp);
|
|
29442
29639
|
this.lostTracks = this.lostTracks.filter((t) => t !== best);
|
|
29443
29640
|
best.bbox = det.bbox;
|
|
29444
29641
|
best.class = det.class;
|
|
@@ -29478,7 +29675,7 @@ var SortTracker = class SortTracker {
|
|
|
29478
29675
|
continue;
|
|
29479
29676
|
}
|
|
29480
29677
|
if (this.config.occlusionEnabled && occluderBoxes.some((ob) => containment(det.bbox, ob) >= this.config.occlusionContainment)) continue;
|
|
29481
|
-
if (this.config.dedupEnabled && this.isDuplicateSpawn(det, surviving)) {
|
|
29678
|
+
if (this.config.dedupEnabled && this.isDuplicateSpawn(det, surviving, timestamp)) {
|
|
29482
29679
|
used.add(di);
|
|
29483
29680
|
continue;
|
|
29484
29681
|
}
|
|
@@ -29537,6 +29734,7 @@ var SortTracker = class SortTracker {
|
|
|
29537
29734
|
this.tracks = [];
|
|
29538
29735
|
this.lostTracks = [];
|
|
29539
29736
|
this.dupStreaks.clear();
|
|
29737
|
+
this.bindSampler.reset();
|
|
29540
29738
|
}
|
|
29541
29739
|
};
|
|
29542
29740
|
//#endregion
|
|
@@ -29953,6 +30151,16 @@ var FrameProcessor = class {
|
|
|
29953
30151
|
setStationaryGate(gate) {
|
|
29954
30152
|
this.stationaryGate = gate;
|
|
29955
30153
|
}
|
|
30154
|
+
/**
|
|
30155
|
+
* Wire (or clear) the tracker's salvage-bind diagnostics sink. The tracker
|
|
30156
|
+
* itself knows no `deviceId`; the addon that owns this processor stamps
|
|
30157
|
+
* `tags: { deviceId }` on the line — see the wiring in `pipeline-analytics`.
|
|
30158
|
+
* Only the rescue / resurrection / growth branches report, and they report
|
|
30159
|
+
* sampled: stage 1 runs per track per frame at frame-rate.
|
|
30160
|
+
*/
|
|
30161
|
+
setBindObserver(observer) {
|
|
30162
|
+
this.tracker.setBindObserver(observer);
|
|
30163
|
+
}
|
|
29956
30164
|
/** Forget a track in the underlying tracker (used when the addon promotes it
|
|
29957
30165
|
* to a stationary-object registry entry). */
|
|
29958
30166
|
dropTrack(trackId) {
|
|
@@ -34874,6 +35082,70 @@ async function makeSquareThumb(bytes, size) {
|
|
|
34874
35082
|
}).jpeg({ quality: THUMB_QUALITY }).toBuffer();
|
|
34875
35083
|
}
|
|
34876
35084
|
//#endregion
|
|
35085
|
+
//#region src/pipeline-analytics/track-media-scope.ts
|
|
35086
|
+
/** The stored kind a detail owner's crop is written with. */
|
|
35087
|
+
var STORED_DETAIL_CROP_KIND = "crop";
|
|
35088
|
+
/** The kinds only a detail owner can produce. */
|
|
35089
|
+
var DETAIL_CROP_KINDS = ["faceCrop", "plateCrop"];
|
|
35090
|
+
/**
|
|
35091
|
+
* Every owner that holds media for `trackId`, in publication order (the track's
|
|
35092
|
+
* own rows first, then face, then plate).
|
|
35093
|
+
*/
|
|
35094
|
+
function trackMediaOwnerScopes(trackId) {
|
|
35095
|
+
return [
|
|
35096
|
+
{
|
|
35097
|
+
ownerKind: "track",
|
|
35098
|
+
ownerId: trackId,
|
|
35099
|
+
publishCropAs: null
|
|
35100
|
+
},
|
|
35101
|
+
{
|
|
35102
|
+
ownerKind: "face",
|
|
35103
|
+
ownerId: `${FACE_MEDIA_OWNER_PREFIX}${trackId}`,
|
|
35104
|
+
publishCropAs: "faceCrop"
|
|
35105
|
+
},
|
|
35106
|
+
{
|
|
35107
|
+
ownerKind: "plate",
|
|
35108
|
+
ownerId: `${PLATE_MEDIA_OWNER_PREFIX}${trackId}`,
|
|
35109
|
+
publishCropAs: "plateCrop"
|
|
35110
|
+
}
|
|
35111
|
+
];
|
|
35112
|
+
}
|
|
35113
|
+
/**
|
|
35114
|
+
* The scopes worth READING for a `kinds`-filtered request.
|
|
35115
|
+
*
|
|
35116
|
+
* A detail owner can only ever publish `faceCrop` / `plateCrop`, so a request
|
|
35117
|
+
* that names neither is answered without opening those two owners — which is
|
|
35118
|
+
* what keeps the common per-entry fetch (`kinds: ['keyFrameSmall']`) at exactly
|
|
35119
|
+
* its previous cost. The track scope is never skipped: it is the only owner
|
|
35120
|
+
* whose kinds are open-ended, and dropping it on a guess is how a listing
|
|
35121
|
+
* starts lying about what a track has.
|
|
35122
|
+
*/
|
|
35123
|
+
function trackMediaScopesForKinds(trackId, kinds) {
|
|
35124
|
+
const scopes = trackMediaOwnerScopes(trackId);
|
|
35125
|
+
if (kinds === void 0 || kinds.length === 0) return scopes;
|
|
35126
|
+
return kinds.some((kind) => DETAIL_CROP_KINDS.includes(kind)) ? scopes : scopes.filter((scope) => scope.publishCropAs === null);
|
|
35127
|
+
}
|
|
35128
|
+
/**
|
|
35129
|
+
* Fold every scope's rows into ONE track media listing: detail crops
|
|
35130
|
+
* relabelled, everything filtered to `kinds`, ordered oldest first.
|
|
35131
|
+
*
|
|
35132
|
+
* `relabel` is injected rather than spread inline so the caller keeps its own
|
|
35133
|
+
* concrete row type (`MediaFile` with bytes, `MediaFileInfo` without) — there
|
|
35134
|
+
* is no cast and no third shape in between.
|
|
35135
|
+
*/
|
|
35136
|
+
function collectTrackMedia(scoped, relabel, kinds) {
|
|
35137
|
+
const out = [];
|
|
35138
|
+
for (const { scope, rows } of scoped) for (const row of rows) {
|
|
35139
|
+
if (scope.publishCropAs === null) {
|
|
35140
|
+
out.push(row);
|
|
35141
|
+
continue;
|
|
35142
|
+
}
|
|
35143
|
+
if (row.kind !== STORED_DETAIL_CROP_KIND) continue;
|
|
35144
|
+
out.push(relabel(row, scope.publishCropAs));
|
|
35145
|
+
}
|
|
35146
|
+
return (kinds === void 0 || kinds.length === 0 ? out : out.filter((row) => kinds.includes(row.kind))).toSorted((a, b) => a.timestamp - b.timestamp);
|
|
35147
|
+
}
|
|
35148
|
+
//#endregion
|
|
34877
35149
|
//#region src/pipeline-analytics/zone-name-stamp.ts
|
|
34878
35150
|
/**
|
|
34879
35151
|
* Copy each track with `zoneNames` stamped on.
|
|
@@ -34993,10 +35265,25 @@ var AnalyticsQueryFacade = class {
|
|
|
34993
35265
|
const all = await (this.deps.mediaStore()?.listByOwner("event", input.eventId, input.deviceId) ?? Promise.resolve([]));
|
|
34994
35266
|
return input.kind ? all.filter((m) => m.kind === input.kind) : all;
|
|
34995
35267
|
}
|
|
35268
|
+
/**
|
|
35269
|
+
* A track's media WITH the bytes, restricted to `kinds` when given.
|
|
35270
|
+
*
|
|
35271
|
+
* Reads every owner a track's media lives under — its own rows plus the face
|
|
35272
|
+
* and plate detail crops, whose stored `kind: 'crop'` is published as
|
|
35273
|
+
* `faceCrop` / `plateCrop`. See `track-media-scope.ts` for why the rename
|
|
35274
|
+
* happens on the way out and not in the caller's question.
|
|
35275
|
+
*/
|
|
34996
35276
|
async getTrackMedia(input) {
|
|
34997
|
-
const
|
|
34998
|
-
|
|
34999
|
-
|
|
35277
|
+
const store = this.deps.mediaStore();
|
|
35278
|
+
if (store === null) return [];
|
|
35279
|
+
const scopes = trackMediaScopesForKinds(input.trackId, input.kinds);
|
|
35280
|
+
return collectTrackMedia(await Promise.all(scopes.map(async (scope) => ({
|
|
35281
|
+
scope,
|
|
35282
|
+
rows: await store.listByOwner(scope.ownerKind, scope.ownerId, input.deviceId)
|
|
35283
|
+
}))), (row, kind) => ({
|
|
35284
|
+
...row,
|
|
35285
|
+
kind
|
|
35286
|
+
}), input.kinds);
|
|
35000
35287
|
}
|
|
35001
35288
|
/**
|
|
35002
35289
|
* A track's media WITHOUT the bytes.
|
|
@@ -35006,9 +35293,21 @@ var AnalyticsQueryFacade = class {
|
|
|
35006
35293
|
* that path reads every blob off disk and base64-encodes it first, so the
|
|
35007
35294
|
* response would shrink while the work did not. Measured: 7.67 MB read and
|
|
35008
35295
|
* ~10 MB of string allocated, to answer a question the index already holds.
|
|
35296
|
+
*
|
|
35297
|
+
* Covers the same owners as {@link getTrackMedia} — a manifest that listed
|
|
35298
|
+
* fewer keys than the byte read can serve (or more) is the disagreement the
|
|
35299
|
+
* viewer's per-entry loader turns into a red triangle.
|
|
35009
35300
|
*/
|
|
35010
35301
|
async listTrackMedia(input) {
|
|
35011
|
-
|
|
35302
|
+
const store = this.deps.mediaStore();
|
|
35303
|
+
if (store === null) return [];
|
|
35304
|
+
return collectTrackMedia(await Promise.all(trackMediaOwnerScopes(input.trackId).map(async (scope) => ({
|
|
35305
|
+
scope,
|
|
35306
|
+
rows: await store.listInfoByOwner(scope.ownerKind, scope.ownerId, input.deviceId)
|
|
35307
|
+
}))), (row, kind) => ({
|
|
35308
|
+
...row,
|
|
35309
|
+
kind
|
|
35310
|
+
}), void 0);
|
|
35012
35311
|
}
|
|
35013
35312
|
async pruneEventsBefore(input) {
|
|
35014
35313
|
const eventStore = this.deps.eventStore();
|
|
@@ -37548,13 +37847,20 @@ var FaceSettingsSchema = object({
|
|
|
37548
37847
|
* 0.175, best accuracy 98.3% at threshold 0.28.
|
|
37549
37848
|
*
|
|
37550
37849
|
* Recalibration plan, in this order:
|
|
37551
|
-
* 1.
|
|
37850
|
+
* 1. DONE (2026-08-19, commit b6641329f): the landmark-precision gate —
|
|
37851
|
+
* `landmark-precision-gate.ts` in `addon-pipeline`, called from
|
|
37852
|
+
* `detection-pipeline/pipeline/executor.ts:1164` BEFORE the native-size
|
|
37853
|
+
* substitution, floor `minLandmarkFaceSize` (default 24 detection px).
|
|
37854
|
+
* Do not re-implement it; the steps below are what is still open.
|
|
37552
37855
|
* 2. re-enrol the gallery from crops produced AFTER it,
|
|
37553
37856
|
* 3. re-measure genuine/impostor over a week of post-fix traffic,
|
|
37554
37857
|
* 4. expect to lower this toward ~0.30, and `margin` with it.
|
|
37555
|
-
* Lowering it
|
|
37556
|
-
*
|
|
37557
|
-
*
|
|
37858
|
+
* Lowering it before step 2 is still actively harmful: the gate stops NEW
|
|
37859
|
+
* degenerate crops, but every vector enrolled before it is still in the
|
|
37860
|
+
* gallery, and a misaligned crop scores ~0.9 against everything — so the
|
|
37861
|
+
* degenerate population sits ABOVE any threshold you could pick and the knob
|
|
37862
|
+
* cannot reach it. Steps 3 and 4 need data that only exists once step 2 has
|
|
37863
|
+
* run and post-fix traffic has accumulated.
|
|
37558
37864
|
*/
|
|
37559
37865
|
similarityThreshold: number().min(0).max(1).default(.55),
|
|
37560
37866
|
/** Reject ambiguous matches: require best − secondBest ≥ margin. 0.10→0.15
|
|
@@ -37690,6 +37996,18 @@ function deriveSuggestionMinCosine(similarityThreshold) {
|
|
|
37690
37996
|
return rounded < similarityThreshold ? rounded : exact;
|
|
37691
37997
|
}
|
|
37692
37998
|
/**
|
|
37999
|
+
* The suggested tier's capacity for a given ordinary capacity.
|
|
38000
|
+
*
|
|
38001
|
+
* INVARIANT, total over every finite input: the result is a non-negative
|
|
38002
|
+
* integer, is never BELOW `bufferMaxPerDevice` (a suggested row is never worse
|
|
38003
|
+
* off than an unsuggested one), and is `0` exactly when the ordinary cap is `0`
|
|
38004
|
+
* (one switch turns the buffer off, not two).
|
|
38005
|
+
*/
|
|
38006
|
+
function deriveSuggestedBufferCap(bufferMaxPerDevice) {
|
|
38007
|
+
if (!Number.isFinite(bufferMaxPerDevice) || bufferMaxPerDevice <= 0) return 0;
|
|
38008
|
+
return Math.round(bufferMaxPerDevice * 4);
|
|
38009
|
+
}
|
|
38010
|
+
/**
|
|
37693
38011
|
* The settings key that used to hold the suggestion floor. Named once, here,
|
|
37694
38012
|
* because the ONLY code allowed to mention it now is the migration that removes
|
|
37695
38013
|
* it from a store.
|
|
@@ -44263,13 +44581,27 @@ var SUPPRESS_IOU = .6;
|
|
|
44263
44581
|
*/
|
|
44264
44582
|
var WAKE_ASSOC_IOU = .1;
|
|
44265
44583
|
/**
|
|
44266
|
-
* Look-back window over which a track must have stayed put to
|
|
44267
|
-
*
|
|
44268
|
-
*
|
|
44269
|
-
*
|
|
44270
|
-
* long
|
|
44271
|
-
|
|
44272
|
-
|
|
44584
|
+
* Look-back window over which a track must have stayed put to stop counting as
|
|
44585
|
+
* PRESENT for the orchestrator's session hold.
|
|
44586
|
+
*
|
|
44587
|
+
* This used to be the promotion criterion — "a track that stayed still this
|
|
44588
|
+
* long IS a parked object" — and it was measuring the wrong thing: it rewarded
|
|
44589
|
+
* an object seen well and often, while the parked cars that actually need
|
|
44590
|
+
* suppressing are re-discovered as dozens of short tracks that never live this
|
|
44591
|
+
* long (device 617: 140 tracks for one car, median life 6.8 s). Birth moved to
|
|
44592
|
+
* repetition — see `stationary-evidence.ts`.
|
|
44593
|
+
*
|
|
44594
|
+
* The stillness test survives because it was doing TWO jobs and only one of
|
|
44595
|
+
* them was destructive. Creating an entry BLINDS the pipeline to an object, so
|
|
44596
|
+
* it needs repeated evidence. Deciding that a motionless subject should stop
|
|
44597
|
+
* holding a motion session open destroys nothing — if it is wrong the session
|
|
44598
|
+
* closes early and the next motion re-opens it — so one recent stillness read
|
|
44599
|
+
* is the right amount of proof. Without this valve, removing promotion would
|
|
44600
|
+
* leave every session containing a parked car pinned to `maxSessionHoldMs`
|
|
44601
|
+
* (120 s) forever: 617 measured 504 sessions in 24 h, which is 70% detection
|
|
44602
|
+
* uptime on one camera, and three cameras are already above 50%.
|
|
44603
|
+
*/
|
|
44604
|
+
var SESSION_STILLNESS_WINDOW_MS = 3e4;
|
|
44273
44605
|
/** Unconfirmed-entry time-to-live: if no detection confirms an entry for this
|
|
44274
44606
|
* long (object removed while unobserved, or a long occlusion) → retire it. */
|
|
44275
44607
|
var ENTRY_TTL_MS = 5 * 6e4;
|
|
@@ -44290,15 +44622,20 @@ var DEFAULT_MATCH_CONFIG = {
|
|
|
44290
44622
|
* which entries are confirmed present, and which entries WOKE (their object
|
|
44291
44623
|
* moved → retire the entry and let the detection spawn a normal track).
|
|
44292
44624
|
*
|
|
44293
|
-
* 2. `
|
|
44294
|
-
*
|
|
44295
|
-
*
|
|
44296
|
-
*
|
|
44625
|
+
* 2. `trackIsStill` — given a track's recent centroid path, decides whether it
|
|
44626
|
+
* has stopped moving. Reuses the static-track-gate metrics (net
|
|
44627
|
+
* displacement + path span normalised to the frame diagonal) over the
|
|
44628
|
+
* recent look-back window.
|
|
44629
|
+
*
|
|
44630
|
+
* This does NOT create a stationary entry, and used to. Birth is decided by
|
|
44631
|
+
* repetition (`stationary-evidence.ts`); this predicate now answers the one
|
|
44632
|
+
* question whose wrong answer is harmless — "should this subject still hold
|
|
44633
|
+
* a motion session open?"
|
|
44297
44634
|
*/
|
|
44298
|
-
/** Default
|
|
44299
|
-
* static gate so "
|
|
44300
|
-
var
|
|
44301
|
-
windowMs:
|
|
44635
|
+
/** Default stillness tunables — static thresholds shared with the key-event
|
|
44636
|
+
* static gate so "still" means the same thing in both places. */
|
|
44637
|
+
var DEFAULT_SESSION_STILLNESS_CONFIG = {
|
|
44638
|
+
windowMs: SESSION_STILLNESS_WINDOW_MS,
|
|
44302
44639
|
netFracMax: STATIC_DISPLACEMENT_FRAC,
|
|
44303
44640
|
spanFracMax: STATIC_SPAN_FRAC,
|
|
44304
44641
|
minPoints: 4
|
|
@@ -44420,26 +44757,29 @@ function partitionDetectionsAgainstRegistry(input) {
|
|
|
44420
44757
|
}
|
|
44421
44758
|
var EMPTY_ARMED = /* @__PURE__ */ new Set();
|
|
44422
44759
|
/**
|
|
44423
|
-
* A track is
|
|
44424
|
-
*
|
|
44425
|
-
*
|
|
44426
|
-
* arrived
|
|
44427
|
-
* — is what lets a car that drove in then
|
|
44760
|
+
* A track is STILL when, over the recent `windowMs`, its centroid barely moved
|
|
44761
|
+
* (both net displacement and path span below the static thresholds) AND the
|
|
44762
|
+
* track has actually EXISTED for at least `windowMs` (so a car that just
|
|
44763
|
+
* arrived has not stopped yet). Judging on the recent window — not the whole
|
|
44764
|
+
* life — is what lets a car that drove in then stopped be recognised.
|
|
44765
|
+
*
|
|
44766
|
+
* The one consumer is the orchestrator's presence signal: a still subject stops
|
|
44767
|
+
* holding its motion session open. It creates nothing and suppresses nothing.
|
|
44428
44768
|
*/
|
|
44429
|
-
function
|
|
44769
|
+
function trackIsStill(input) {
|
|
44430
44770
|
const { positions, referenceDiagonalPx, now, config } = input;
|
|
44431
|
-
if (!(referenceDiagonalPx > 0) || positions.length === 0) return {
|
|
44432
|
-
if (now - positions[0].timestamp < config.windowMs) return {
|
|
44771
|
+
if (!(referenceDiagonalPx > 0) || positions.length === 0) return { still: false };
|
|
44772
|
+
if (now - positions[0].timestamp < config.windowMs) return { still: false };
|
|
44433
44773
|
const cutoff = now - config.windowMs;
|
|
44434
44774
|
const window = positions.filter((p) => p.timestamp >= cutoff);
|
|
44435
|
-
if (window.length < config.minPoints) return {
|
|
44775
|
+
if (window.length < config.minPoints) return { still: false };
|
|
44436
44776
|
const metrics = computeStaticTrackMetrics(window.map((p) => ({
|
|
44437
44777
|
x: p.x,
|
|
44438
44778
|
y: p.y
|
|
44439
44779
|
})), referenceDiagonalPx);
|
|
44440
|
-
if (metrics === void 0) return {
|
|
44780
|
+
if (metrics === void 0) return { still: false };
|
|
44441
44781
|
return {
|
|
44442
|
-
|
|
44782
|
+
still: metrics.netDisplacementFrac < config.netFracMax && metrics.pathSpanFrac < config.spanFracMax,
|
|
44443
44783
|
netFrac: metrics.netDisplacementFrac,
|
|
44444
44784
|
spanFrac: metrics.pathSpanFrac
|
|
44445
44785
|
};
|
|
@@ -44946,6 +45286,602 @@ var PackageDropDetector = class {
|
|
|
44946
45286
|
}
|
|
44947
45287
|
};
|
|
44948
45288
|
//#endregion
|
|
45289
|
+
//#region src/pipeline-analytics/pipeline/stationary/stationary-evidence.ts
|
|
45290
|
+
/**
|
|
45291
|
+
* stationary-evidence — how a parked object EARNS a registry entry.
|
|
45292
|
+
*
|
|
45293
|
+
* ## The criterion this replaces, and why it was measuring the wrong thing
|
|
45294
|
+
*
|
|
45295
|
+
* Until 2026-08-28 an object became stationary by TRACK CONTINUITY: one track
|
|
45296
|
+
* had to stay alive and barely move for `PROMOTION_WINDOW_MS` (30 s). That
|
|
45297
|
+
* rewards an object that is *seen well and often* — precisely the opposite of
|
|
45298
|
+
* what the operator needs. Measured on device 617 (the parking lot), 24 h:
|
|
45299
|
+
*
|
|
45300
|
+
* - three cars are parked in view; the registry held exactly ONE entry;
|
|
45301
|
+
* - the two it missed were re-discovered as **140 and 112 separate tracks**,
|
|
45302
|
+
* median life **6.8 s**, p90 29 s. Not one of them could ever reach 30 s.
|
|
45303
|
+
* - those two objects alone produced **13 494 of 18 402** vehicle
|
|
45304
|
+
* observations (73%) — the whole re-spawn flood the registry exists to stop.
|
|
45305
|
+
*
|
|
45306
|
+
* The track dies; the OBJECT does not. So the evidence must outlive the track.
|
|
45307
|
+
*
|
|
45308
|
+
* ## The new criterion: a RATIO, not a count
|
|
45309
|
+
*
|
|
45310
|
+
* "The same thing, in the same place, most of the times it was seen."
|
|
45311
|
+
*
|
|
45312
|
+
* N — observations that AGREED with the candidate's anchor box (IoU ≥
|
|
45313
|
+
* {@link StationaryEvidencePolicy.agreeIou}).
|
|
45314
|
+
* M — observations OFFERED by a track once it bound to this candidate,
|
|
45315
|
+
* agreeing or not.
|
|
45316
|
+
*
|
|
45317
|
+
* A count would rescue nothing: a badly-lit car at night is seen rarely, and
|
|
45318
|
+
* any floor high enough to exclude a passer-by would exclude it too. A RATIO
|
|
45319
|
+
* asks a question a rarely-seen object can still answer well — 8 sightings out
|
|
45320
|
+
* of 10 in the same spot is stationary even though 10 is a small number.
|
|
45321
|
+
*
|
|
45322
|
+
* The dilution is the whole point, and it is why M counts a bound track's
|
|
45323
|
+
* observations even after it walks away: a car that drives THROUGH a spot binds
|
|
45324
|
+
* for a few frames and then contributes dozens of disagreeing ones. Measured on
|
|
45325
|
+
* 617 the two populations do not overlap:
|
|
45326
|
+
*
|
|
45327
|
+
* | candidate | N / M | ratio |
|
|
45328
|
+
* | ---------------------------- | ----------- | ----- |
|
|
45329
|
+
* | parked car A (missed today) | 6405 / 6467 | 0.99 |
|
|
45330
|
+
* | parked car B (missed today) | 343 / 343 | 1.00 |
|
|
45331
|
+
* | car crossing the lot | 5 / 70 | 0.07 |
|
|
45332
|
+
* | car turning through the lot | 14 / 91 | 0.15 |
|
|
45333
|
+
* | car manoeuvring | 12 / 41 | 0.29 |
|
|
45334
|
+
*
|
|
45335
|
+
* The threshold sits in the empty band between 0.29 and 0.99.
|
|
45336
|
+
*
|
|
45337
|
+
* ## The span is what a ratio cannot say
|
|
45338
|
+
*
|
|
45339
|
+
* A ratio alone promotes a car that paused for four seconds — its handful of
|
|
45340
|
+
* observations all agree. So evidence must also STRETCH: the agreeing
|
|
45341
|
+
* observations must span {@link StationaryEvidencePolicy.minSpanMs}. Measured
|
|
45342
|
+
* on 617, real parked cars span 2 min to 24 h; the two momentary stops in the
|
|
45343
|
+
* same day spanned 4.2 s and 8.0 s. Setting the span to
|
|
45344
|
+
* `DEFAULT_MAX_SESSION_HOLD_MS` also buys a structural guarantee: no single
|
|
45345
|
+
* motion session, however long it is held open, can promote on its own.
|
|
45346
|
+
*
|
|
45347
|
+
* ## ONE ledger, two producers
|
|
45348
|
+
*
|
|
45349
|
+
* Evidence is anchored to a BOX, not to a track and not to a session, so an
|
|
45350
|
+
* object confirmed half by an active motion session and half by a periodic
|
|
45351
|
+
* burst still promotes. Occasion A (the session) is implemented and is
|
|
45352
|
+
* sufficient on its own — it is free, because the pipeline is already decoding
|
|
45353
|
+
* and already inferring. Occasion B (the burst) is a precaution for the cases
|
|
45354
|
+
* where A could not have fired at all (hub restarted, camera offline, motion
|
|
45355
|
+
* detection failed, object already parked at boot); it is NOT implemented yet,
|
|
45356
|
+
* and {@link StationaryEvidenceSource} is the seam it will arrive through.
|
|
45357
|
+
*
|
|
45358
|
+
* ## Identity is IoU. There is no grid.
|
|
45359
|
+
*
|
|
45360
|
+
* The predecessor mechanism (`phantom-cells.ts`) quantises a point to a 32 px
|
|
45361
|
+
* cell, and its write path passes a CENTROID while its read path passes a
|
|
45362
|
+
* top-left CORNER — at 32 px every box bigger than 32 px lands in a different
|
|
45363
|
+
* cell on read, and its unit test never crosses that boundary because it calls
|
|
45364
|
+
* both halves with the same literal coordinates. This module has no such
|
|
45365
|
+
* boundary to get wrong: a candidate is found by overlap against the boxes a
|
|
45366
|
+
* device already holds, bounded by {@link EVIDENCE_MAX_CANDIDATES_PER_DEVICE}.
|
|
45367
|
+
* Device 617 — the busiest lot in the fleet — holds 8.
|
|
45368
|
+
*/
|
|
45369
|
+
/**
|
|
45370
|
+
* `vehicle` and its kin. Every number below is read off device 617 over 24 h
|
|
45371
|
+
* (18 402 vehicle observations, 290 tracks) with the three parked cars the
|
|
45372
|
+
* operator can see out of the window as the bench.
|
|
45373
|
+
*
|
|
45374
|
+
* - `agreeIou` **0.5** — between the registry's own `WAKE_ASSOC_IOU` (0.1) and
|
|
45375
|
+
* `SUPPRESS_IOU` (0.6). At 0.7 the two missed cars lose 11% of their evidence
|
|
45376
|
+
* to night-time box wobble for no reduction in promotions; at 0.3 the day's
|
|
45377
|
+
* promotions double (10 vs 5) without catching anything new.
|
|
45378
|
+
* - `minAgreementRatio` **0.6** — the measured gap is [0.29, 0.90]; 0.6 is the
|
|
45379
|
+
* middle of it. Raising it to 0.95 starts dropping real parked cars.
|
|
45380
|
+
* - `minAgreed` **8** — a floor, not a discriminator: sweeping it 3→60 changes
|
|
45381
|
+
* nothing on 617 (the smallest true positive promotes with N=15). Set well
|
|
45382
|
+
* below that so a rarely-seen object is never excluded for being rare, which
|
|
45383
|
+
* is the entire point of using a ratio.
|
|
45384
|
+
* - `minSpanMs` **120 000** — the real discriminator. True positives span
|
|
45385
|
+
* 2 min–24 h; the day's two momentary stops spanned 4.2 s and 8.0 s.
|
|
45386
|
+
*/
|
|
45387
|
+
var VEHICLE_EVIDENCE_POLICY = {
|
|
45388
|
+
agreeIou: .5,
|
|
45389
|
+
minAgreed: 8,
|
|
45390
|
+
minAgreementRatio: .6,
|
|
45391
|
+
minSpanMs: 12e4,
|
|
45392
|
+
promotesByDefault: true
|
|
45393
|
+
};
|
|
45394
|
+
/**
|
|
45395
|
+
* `person`, and it ships OFF.
|
|
45396
|
+
*
|
|
45397
|
+
* This is not caution, it is a measurement. Replaying 24 h of person
|
|
45398
|
+
* observations from devices 617, 1443, 590 and 592 through this exact ledger:
|
|
45399
|
+
*
|
|
45400
|
+
* | ratio | minAgreed | minSpanMs | promotions / 24 h across 4 cameras |
|
|
45401
|
+
* | ----- | --------- | --------- | ---------------------------------- |
|
|
45402
|
+
* | 0.85 | 20 | 600 s | 22 |
|
|
45403
|
+
* | 0.95 | 40 | 1800 s | 10 |
|
|
45404
|
+
* | 0.99 | 80 | 1800 s | **6** |
|
|
45405
|
+
*
|
|
45406
|
+
* There is no setting that reaches zero. Device 590 holds a person-shaped thing
|
|
45407
|
+
* that agrees with itself 1292 times out of 1654 (0.78) across 26 tracks over
|
|
45408
|
+
* ten hours, and device 592 produced eleven candidates at ratio 1.00 between
|
|
45409
|
+
* 02:00 and 05:00 — a statue, a poster, an IR artifact; the ledger cannot tell
|
|
45410
|
+
* those from a person standing still, because they look identical to it.
|
|
45411
|
+
*
|
|
45412
|
+
* Promoting a person is a BLINDING decision (D49 read in the direction that
|
|
45413
|
+
* destroys work: for a car the destructive read is the RETIREMENT — the flood
|
|
45414
|
+
* comes back; for a person it is the BIRTH — we stop seeing them). So the
|
|
45415
|
+
* class is available, tuned as strictly as the data allows, and switched off
|
|
45416
|
+
* until an operator turns it on for a camera where they know what is standing
|
|
45417
|
+
* there.
|
|
45418
|
+
*/
|
|
45419
|
+
var PERSON_EVIDENCE_POLICY = {
|
|
45420
|
+
agreeIou: .5,
|
|
45421
|
+
minAgreed: 40,
|
|
45422
|
+
minAgreementRatio: .95,
|
|
45423
|
+
minSpanMs: 18e5,
|
|
45424
|
+
promotesByDefault: false
|
|
45425
|
+
};
|
|
45426
|
+
/** Classes with no policy of their own fall back to the vehicle numbers —
|
|
45427
|
+
* `animal`, `package` and friends are all "an object that ended up somewhere",
|
|
45428
|
+
* and none of them makes the camera blind to a person. */
|
|
45429
|
+
var DEFAULT_EVIDENCE_POLICY = VEHICLE_EVIDENCE_POLICY;
|
|
45430
|
+
var POLICY_BY_CLASS = new Map([["vehicle", VEHICLE_EVIDENCE_POLICY], ["person", PERSON_EVIDENCE_POLICY]]);
|
|
45431
|
+
function evidencePolicyFor(className) {
|
|
45432
|
+
return POLICY_BY_CLASS.get(className) ?? DEFAULT_EVIDENCE_POLICY;
|
|
45433
|
+
}
|
|
45434
|
+
function stationaryEvidenceKey(deviceId, className, anchorId) {
|
|
45435
|
+
return `${deviceId}:${className}:${anchorId}`;
|
|
45436
|
+
}
|
|
45437
|
+
/**
|
|
45438
|
+
* Does this candidate's evidence justify a registry entry?
|
|
45439
|
+
*
|
|
45440
|
+
* Pure, and ordered cheapest-question-first so the reason a candidate was
|
|
45441
|
+
* refused is the FIRST thing it failed, not the last.
|
|
45442
|
+
*/
|
|
45443
|
+
function evaluateStationaryEvidence(row, policy, classEnabled) {
|
|
45444
|
+
const ratio = row.seen > 0 ? row.agreed / row.seen : 0;
|
|
45445
|
+
const spanMs = row.lastAgreedAt - row.firstAgreedAt;
|
|
45446
|
+
const base = {
|
|
45447
|
+
agreed: row.agreed,
|
|
45448
|
+
seen: row.seen,
|
|
45449
|
+
ratio,
|
|
45450
|
+
spanMs
|
|
45451
|
+
};
|
|
45452
|
+
if (!classEnabled) return {
|
|
45453
|
+
kind: "accumulating",
|
|
45454
|
+
shortfall: "class-not-promoted",
|
|
45455
|
+
...base
|
|
45456
|
+
};
|
|
45457
|
+
if (row.agreed < policy.minAgreed) return {
|
|
45458
|
+
kind: "accumulating",
|
|
45459
|
+
shortfall: "too-few-agreements",
|
|
45460
|
+
...base
|
|
45461
|
+
};
|
|
45462
|
+
if (ratio < policy.minAgreementRatio) return {
|
|
45463
|
+
kind: "accumulating",
|
|
45464
|
+
shortfall: "inconsistent-position",
|
|
45465
|
+
...base
|
|
45466
|
+
};
|
|
45467
|
+
if (spanMs < policy.minSpanMs) return {
|
|
45468
|
+
kind: "accumulating",
|
|
45469
|
+
shortfall: "span-too-short",
|
|
45470
|
+
...base
|
|
45471
|
+
};
|
|
45472
|
+
return {
|
|
45473
|
+
kind: "promote",
|
|
45474
|
+
...base
|
|
45475
|
+
};
|
|
45476
|
+
}
|
|
45477
|
+
/**
|
|
45478
|
+
* The candidate this observation belongs to: the same-class row whose anchor it
|
|
45479
|
+
* overlaps most, provided the overlap reaches `agreeIou`. Overlap is the ONLY
|
|
45480
|
+
* notion of sameness here, and it is the same {@link iou} the suppression gate
|
|
45481
|
+
* uses — a second notion of "same place" is a second source of truth.
|
|
45482
|
+
*/
|
|
45483
|
+
function matchEvidenceCandidate(rows, className, bbox, agreeIou) {
|
|
45484
|
+
let best;
|
|
45485
|
+
let bestOverlap = 0;
|
|
45486
|
+
for (const row of rows) {
|
|
45487
|
+
if (row.className !== className) continue;
|
|
45488
|
+
const overlap = iou(row.anchor, bbox);
|
|
45489
|
+
if (overlap > bestOverlap) {
|
|
45490
|
+
bestOverlap = overlap;
|
|
45491
|
+
best = row;
|
|
45492
|
+
}
|
|
45493
|
+
}
|
|
45494
|
+
if (best === void 0 || bestOverlap < agreeIou) return void 0;
|
|
45495
|
+
return {
|
|
45496
|
+
row: best,
|
|
45497
|
+
overlap: bestOverlap
|
|
45498
|
+
};
|
|
45499
|
+
}
|
|
45500
|
+
/**
|
|
45501
|
+
* Fold one observation into a candidate.
|
|
45502
|
+
*
|
|
45503
|
+
* `agrees` is decided by the CALLER, because M is what makes the ratio mean
|
|
45504
|
+
* anything: once a track has bound to a candidate, every later observation of
|
|
45505
|
+
* that track counts toward M whether it agrees or not. That is what dilutes a
|
|
45506
|
+
* car that merely drove through the spot, and it is why this function cannot
|
|
45507
|
+
* re-derive agreement from the box alone.
|
|
45508
|
+
*/
|
|
45509
|
+
function foldObservation(row, observation, agrees, overlap) {
|
|
45510
|
+
const seen = row.seen + 1;
|
|
45511
|
+
if (!agrees) return {
|
|
45512
|
+
...row,
|
|
45513
|
+
seen,
|
|
45514
|
+
updatedAt: observation.timestamp
|
|
45515
|
+
};
|
|
45516
|
+
const improves = overlap >= iou(row.anchor, row.bestBbox);
|
|
45517
|
+
return {
|
|
45518
|
+
...row,
|
|
45519
|
+
seen,
|
|
45520
|
+
agreed: row.agreed + 1,
|
|
45521
|
+
firstAgreedAt: Math.min(row.firstAgreedAt, observation.timestamp),
|
|
45522
|
+
lastAgreedAt: Math.max(row.lastAgreedAt, observation.timestamp),
|
|
45523
|
+
...improves ? {
|
|
45524
|
+
bestBbox: { ...observation.bbox },
|
|
45525
|
+
...observation.trackId !== void 0 ? { sourceTrackId: observation.trackId } : {},
|
|
45526
|
+
...observation.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: observation.keyFrameMediaKey } : {},
|
|
45527
|
+
...observation.label !== void 0 ? { label: observation.label } : {}
|
|
45528
|
+
} : {},
|
|
45529
|
+
sessionAgreed: row.sessionAgreed + (observation.source === "session" ? 1 : 0),
|
|
45530
|
+
burstAgreed: row.burstAgreed + (observation.source === "burst" ? 1 : 0),
|
|
45531
|
+
updatedAt: observation.timestamp
|
|
45532
|
+
};
|
|
45533
|
+
}
|
|
45534
|
+
/** A brand-new candidate anchored on the observation that had nowhere to go. */
|
|
45535
|
+
function newEvidenceRow(observation, anchorId) {
|
|
45536
|
+
return {
|
|
45537
|
+
key: stationaryEvidenceKey(observation.deviceId, observation.className, anchorId),
|
|
45538
|
+
deviceId: observation.deviceId,
|
|
45539
|
+
className: observation.className,
|
|
45540
|
+
anchor: { ...observation.bbox },
|
|
45541
|
+
agreed: 1,
|
|
45542
|
+
seen: 1,
|
|
45543
|
+
firstAgreedAt: observation.timestamp,
|
|
45544
|
+
lastAgreedAt: observation.timestamp,
|
|
45545
|
+
bestBbox: { ...observation.bbox },
|
|
45546
|
+
...observation.trackId !== void 0 ? { sourceTrackId: observation.trackId } : {},
|
|
45547
|
+
...observation.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: observation.keyFrameMediaKey } : {},
|
|
45548
|
+
...observation.label !== void 0 ? { label: observation.label } : {},
|
|
45549
|
+
sessionAgreed: observation.source === "session" ? 1 : 0,
|
|
45550
|
+
burstAgreed: observation.source === "burst" ? 1 : 0,
|
|
45551
|
+
updatedAt: observation.timestamp
|
|
45552
|
+
};
|
|
45553
|
+
}
|
|
45554
|
+
//#endregion
|
|
45555
|
+
//#region src/pipeline-analytics/pipeline/stationary/stationary-evidence-ledger.ts
|
|
45556
|
+
/**
|
|
45557
|
+
* @durable class=ledger owner=pipeline-analytics
|
|
45558
|
+
* write="a candidate's BIRTH and its PROMOTION are written immediately; the per-frame agreement tallies are STAGED in RAM and flushed on the 5-minute sweep (a parked car is observed 5-30 times a second and one commit per observation would offer ~10^6 writes a day to maintain a handful of rows)"
|
|
45559
|
+
* retention="a candidate untouched for 6h is deleted, and a device is capped at 512 candidates (least-recently-touched evicted). A promoted candidate is deleted the moment its entry is born. Never orphaned by track retention — evidence describes an OBJECT that outlives every track that ever saw it, which is the entire reason this collection exists."
|
|
45560
|
+
*/
|
|
45561
|
+
var STATIONARY_EVIDENCE_COLLECTION = "pipeline-analytics:stationary-evidence";
|
|
45562
|
+
var STATIONARY_EVIDENCE_COLUMNS = [
|
|
45563
|
+
{
|
|
45564
|
+
name: "id",
|
|
45565
|
+
type: "TEXT",
|
|
45566
|
+
primaryKey: true,
|
|
45567
|
+
notNull: true
|
|
45568
|
+
},
|
|
45569
|
+
{
|
|
45570
|
+
name: "deviceId",
|
|
45571
|
+
type: "INTEGER",
|
|
45572
|
+
notNull: true
|
|
45573
|
+
},
|
|
45574
|
+
{
|
|
45575
|
+
name: "className",
|
|
45576
|
+
type: "TEXT",
|
|
45577
|
+
notNull: true
|
|
45578
|
+
},
|
|
45579
|
+
{
|
|
45580
|
+
name: "anchor",
|
|
45581
|
+
type: "JSON"
|
|
45582
|
+
},
|
|
45583
|
+
{
|
|
45584
|
+
name: "agreed",
|
|
45585
|
+
type: "INTEGER"
|
|
45586
|
+
},
|
|
45587
|
+
{
|
|
45588
|
+
name: "seen",
|
|
45589
|
+
type: "INTEGER"
|
|
45590
|
+
},
|
|
45591
|
+
{
|
|
45592
|
+
name: "firstAgreedAt",
|
|
45593
|
+
type: "INTEGER"
|
|
45594
|
+
},
|
|
45595
|
+
{
|
|
45596
|
+
name: "lastAgreedAt",
|
|
45597
|
+
type: "INTEGER"
|
|
45598
|
+
},
|
|
45599
|
+
{
|
|
45600
|
+
name: "bestBbox",
|
|
45601
|
+
type: "JSON"
|
|
45602
|
+
},
|
|
45603
|
+
{
|
|
45604
|
+
name: "sourceTrackId",
|
|
45605
|
+
type: "TEXT"
|
|
45606
|
+
},
|
|
45607
|
+
{
|
|
45608
|
+
name: "keyFrameMediaKey",
|
|
45609
|
+
type: "TEXT"
|
|
45610
|
+
},
|
|
45611
|
+
{
|
|
45612
|
+
name: "label",
|
|
45613
|
+
type: "TEXT"
|
|
45614
|
+
},
|
|
45615
|
+
{
|
|
45616
|
+
name: "sessionAgreed",
|
|
45617
|
+
type: "INTEGER"
|
|
45618
|
+
},
|
|
45619
|
+
{
|
|
45620
|
+
name: "burstAgreed",
|
|
45621
|
+
type: "INTEGER"
|
|
45622
|
+
},
|
|
45623
|
+
{
|
|
45624
|
+
name: "updatedAt",
|
|
45625
|
+
type: "INTEGER"
|
|
45626
|
+
}
|
|
45627
|
+
];
|
|
45628
|
+
var STATIONARY_EVIDENCE_INDEXES = [{
|
|
45629
|
+
name: "idx_stationary_evidence_device",
|
|
45630
|
+
columns: ["deviceId"]
|
|
45631
|
+
}];
|
|
45632
|
+
function parseBbox(value) {
|
|
45633
|
+
if (typeof value !== "object" || value === null) return null;
|
|
45634
|
+
const { x, y, w, h } = { ...value };
|
|
45635
|
+
if (typeof x !== "number" || typeof y !== "number") return null;
|
|
45636
|
+
if (typeof w !== "number" || typeof h !== "number") return null;
|
|
45637
|
+
return {
|
|
45638
|
+
x,
|
|
45639
|
+
y,
|
|
45640
|
+
w,
|
|
45641
|
+
h
|
|
45642
|
+
};
|
|
45643
|
+
}
|
|
45644
|
+
function numberOr(value, fallback) {
|
|
45645
|
+
return typeof value === "number" ? value : fallback;
|
|
45646
|
+
}
|
|
45647
|
+
function stringOrUndefined(value) {
|
|
45648
|
+
return typeof value === "string" ? value : void 0;
|
|
45649
|
+
}
|
|
45650
|
+
var StationaryEvidenceLedger = class {
|
|
45651
|
+
ledger;
|
|
45652
|
+
logger;
|
|
45653
|
+
/** deviceId → candidate keys, so a device's candidates are found without
|
|
45654
|
+
* scanning the fleet. */
|
|
45655
|
+
byDevice = /* @__PURE__ */ new Map();
|
|
45656
|
+
/** `deviceId:trackId` → candidate key. RAM-only, see the file header. */
|
|
45657
|
+
binding = /* @__PURE__ */ new Map();
|
|
45658
|
+
/** Keys whose staged tallies have not been persisted yet. */
|
|
45659
|
+
dirty = /* @__PURE__ */ new Set();
|
|
45660
|
+
anchorSeq = 0;
|
|
45661
|
+
constructor(deps) {
|
|
45662
|
+
this.logger = deps.logger;
|
|
45663
|
+
const spec = {
|
|
45664
|
+
collection: STATIONARY_EVIDENCE_COLLECTION,
|
|
45665
|
+
columns: [...STATIONARY_EVIDENCE_COLUMNS],
|
|
45666
|
+
indexes: [...STATIONARY_EVIDENCE_INDEXES],
|
|
45667
|
+
writeMode: "write-behind",
|
|
45668
|
+
keyOf: (row) => row.key,
|
|
45669
|
+
toValue: (row) => ({
|
|
45670
|
+
deviceId: row.deviceId,
|
|
45671
|
+
className: row.className,
|
|
45672
|
+
anchor: { ...row.anchor },
|
|
45673
|
+
agreed: row.agreed,
|
|
45674
|
+
seen: row.seen,
|
|
45675
|
+
firstAgreedAt: row.firstAgreedAt,
|
|
45676
|
+
lastAgreedAt: row.lastAgreedAt,
|
|
45677
|
+
bestBbox: { ...row.bestBbox },
|
|
45678
|
+
...row.sourceTrackId !== void 0 ? { sourceTrackId: row.sourceTrackId } : {},
|
|
45679
|
+
...row.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: row.keyFrameMediaKey } : {},
|
|
45680
|
+
...row.label !== void 0 ? { label: row.label } : {},
|
|
45681
|
+
sessionAgreed: row.sessionAgreed,
|
|
45682
|
+
burstAgreed: row.burstAgreed,
|
|
45683
|
+
updatedAt: row.updatedAt
|
|
45684
|
+
}),
|
|
45685
|
+
fromRecord: (key, data) => {
|
|
45686
|
+
const deviceId = data.deviceId;
|
|
45687
|
+
const className = data.className;
|
|
45688
|
+
if (typeof deviceId !== "number" || typeof className !== "string") return null;
|
|
45689
|
+
const anchor = parseBbox(data.anchor);
|
|
45690
|
+
const bestBbox = parseBbox(data.bestBbox) ?? anchor;
|
|
45691
|
+
if (anchor === null || bestBbox === null) return null;
|
|
45692
|
+
const sourceTrackId = stringOrUndefined(data.sourceTrackId);
|
|
45693
|
+
const keyFrameMediaKey = stringOrUndefined(data.keyFrameMediaKey);
|
|
45694
|
+
const label = stringOrUndefined(data.label);
|
|
45695
|
+
const updatedAt = numberOr(data.updatedAt, 0);
|
|
45696
|
+
return {
|
|
45697
|
+
key,
|
|
45698
|
+
deviceId,
|
|
45699
|
+
className,
|
|
45700
|
+
anchor,
|
|
45701
|
+
agreed: numberOr(data.agreed, 0),
|
|
45702
|
+
seen: numberOr(data.seen, 0),
|
|
45703
|
+
firstAgreedAt: numberOr(data.firstAgreedAt, updatedAt),
|
|
45704
|
+
lastAgreedAt: numberOr(data.lastAgreedAt, updatedAt),
|
|
45705
|
+
bestBbox,
|
|
45706
|
+
...sourceTrackId !== void 0 ? { sourceTrackId } : {},
|
|
45707
|
+
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
|
|
45708
|
+
...label !== void 0 ? { label } : {},
|
|
45709
|
+
sessionAgreed: numberOr(data.sessionAgreed, 0),
|
|
45710
|
+
burstAgreed: numberOr(data.burstAgreed, 0),
|
|
45711
|
+
updatedAt
|
|
45712
|
+
};
|
|
45713
|
+
},
|
|
45714
|
+
deviceIdOf: (row) => row.deviceId
|
|
45715
|
+
};
|
|
45716
|
+
this.ledger = new DurableLedger({
|
|
45717
|
+
spec,
|
|
45718
|
+
store: deps.store,
|
|
45719
|
+
logger: deps.logger
|
|
45720
|
+
});
|
|
45721
|
+
}
|
|
45722
|
+
declare() {
|
|
45723
|
+
return this.ledger.declare();
|
|
45724
|
+
}
|
|
45725
|
+
/** Boot reseed. Returns how many candidates came back — "loaded 0" after a
|
|
45726
|
+
* container recreate is the one line that explains a silent regression. */
|
|
45727
|
+
async load() {
|
|
45728
|
+
const rows = await this.ledger.load();
|
|
45729
|
+
for (const row of rows) this.index(row);
|
|
45730
|
+
return rows.length;
|
|
45731
|
+
}
|
|
45732
|
+
get size() {
|
|
45733
|
+
return this.ledger.snapshot().length;
|
|
45734
|
+
}
|
|
45735
|
+
candidatesFor(deviceId) {
|
|
45736
|
+
const keys = this.byDevice.get(deviceId);
|
|
45737
|
+
if (!keys) return [];
|
|
45738
|
+
const rows = [];
|
|
45739
|
+
for (const key of keys) {
|
|
45740
|
+
const row = this.ledger.get(key);
|
|
45741
|
+
if (row) rows.push(row);
|
|
45742
|
+
}
|
|
45743
|
+
return rows;
|
|
45744
|
+
}
|
|
45745
|
+
/**
|
|
45746
|
+
* Fold ONE observation in and say whether it just earned an entry.
|
|
45747
|
+
*
|
|
45748
|
+
* Synchronous by construction: the verdict is read from the RAM mirror, so a
|
|
45749
|
+
* slow or failing store can never change what this frame decides. The
|
|
45750
|
+
* durable half is fired behind the verdict.
|
|
45751
|
+
*/
|
|
45752
|
+
observe(observation, classEnabled) {
|
|
45753
|
+
const policy = evidencePolicyFor(observation.className);
|
|
45754
|
+
this.expire(observation.deviceId, observation.timestamp);
|
|
45755
|
+
const bindKey = observation.trackId === void 0 ? void 0 : `${observation.deviceId}:${observation.trackId}`;
|
|
45756
|
+
const candidates = this.candidatesFor(observation.deviceId);
|
|
45757
|
+
let row;
|
|
45758
|
+
const boundKey = bindKey === void 0 ? void 0 : this.binding.get(bindKey);
|
|
45759
|
+
if (boundKey !== void 0) row = this.ledger.get(boundKey);
|
|
45760
|
+
let overlap = row === void 0 ? 0 : iou(row.anchor, observation.bbox);
|
|
45761
|
+
let created = false;
|
|
45762
|
+
if (row === void 0) {
|
|
45763
|
+
const match = matchEvidenceCandidate(candidates, observation.className, observation.bbox, policy.agreeIou);
|
|
45764
|
+
if (match) {
|
|
45765
|
+
row = match.row;
|
|
45766
|
+
overlap = match.overlap;
|
|
45767
|
+
} else {
|
|
45768
|
+
this.anchorSeq += 1;
|
|
45769
|
+
row = newEvidenceRow(observation, `a${this.anchorSeq}`);
|
|
45770
|
+
overlap = 1;
|
|
45771
|
+
created = true;
|
|
45772
|
+
this.index(row);
|
|
45773
|
+
this.write(row);
|
|
45774
|
+
if (bindKey !== void 0) this.binding.set(bindKey, row.key);
|
|
45775
|
+
this.capDevice(observation.deviceId);
|
|
45776
|
+
return {
|
|
45777
|
+
row,
|
|
45778
|
+
verdict: evaluateStationaryEvidence(row, policy, classEnabled),
|
|
45779
|
+
promoted: false
|
|
45780
|
+
};
|
|
45781
|
+
}
|
|
45782
|
+
if (bindKey !== void 0) this.binding.set(bindKey, row.key);
|
|
45783
|
+
}
|
|
45784
|
+
const agrees = overlap >= policy.agreeIou;
|
|
45785
|
+
const next = foldObservation(row, observation, agrees, overlap);
|
|
45786
|
+
this.ledger.stage(next);
|
|
45787
|
+
this.dirty.add(next.key);
|
|
45788
|
+
if (created) this.capDevice(observation.deviceId);
|
|
45789
|
+
const verdict = evaluateStationaryEvidence(next, policy, classEnabled);
|
|
45790
|
+
if (verdict.kind !== "promote") return {
|
|
45791
|
+
row: next,
|
|
45792
|
+
verdict,
|
|
45793
|
+
promoted: false
|
|
45794
|
+
};
|
|
45795
|
+
this.forgetKey(next.key);
|
|
45796
|
+
return {
|
|
45797
|
+
row: next,
|
|
45798
|
+
verdict,
|
|
45799
|
+
promoted: true
|
|
45800
|
+
};
|
|
45801
|
+
}
|
|
45802
|
+
/** Persist the staged tallies. Called from the registry's 5-minute sweep. */
|
|
45803
|
+
async flush() {
|
|
45804
|
+
const keys = [...this.dirty];
|
|
45805
|
+
this.dirty.clear();
|
|
45806
|
+
let written = 0;
|
|
45807
|
+
for (const key of keys) {
|
|
45808
|
+
const row = this.ledger.get(key);
|
|
45809
|
+
if (!row) continue;
|
|
45810
|
+
await this.write(row);
|
|
45811
|
+
written += 1;
|
|
45812
|
+
}
|
|
45813
|
+
return written;
|
|
45814
|
+
}
|
|
45815
|
+
/** Drop a device's evidence wholesale (camera removed / analytics reset). */
|
|
45816
|
+
async clearDevice(deviceId) {
|
|
45817
|
+
for (const row of this.candidatesFor(deviceId)) await this.forgetKeyDurable(row.key);
|
|
45818
|
+
this.byDevice.delete(deviceId);
|
|
45819
|
+
for (const [bindKey] of this.binding) if (bindKey.startsWith(`${deviceId}:`)) this.binding.delete(bindKey);
|
|
45820
|
+
}
|
|
45821
|
+
/** Forget the bindings of tracks the tracker no longer carries, so the map
|
|
45822
|
+
* cannot grow for the process's lifetime on a busy camera. */
|
|
45823
|
+
retainTracks(deviceId, liveTrackIds) {
|
|
45824
|
+
const prefix = `${deviceId}:`;
|
|
45825
|
+
for (const bindKey of [...this.binding.keys()]) {
|
|
45826
|
+
if (!bindKey.startsWith(prefix)) continue;
|
|
45827
|
+
if (!liveTrackIds.has(bindKey.slice(prefix.length))) this.binding.delete(bindKey);
|
|
45828
|
+
}
|
|
45829
|
+
}
|
|
45830
|
+
forgetKey(key) {
|
|
45831
|
+
this.forgetKeyDurable(key);
|
|
45832
|
+
}
|
|
45833
|
+
async forgetKeyDurable(key) {
|
|
45834
|
+
const row = this.ledger.get(key);
|
|
45835
|
+
this.dirty.delete(key);
|
|
45836
|
+
if (row) this.byDevice.get(row.deviceId)?.delete(key);
|
|
45837
|
+
for (const [bindKey, boundTo] of this.binding) if (boundTo === key) this.binding.delete(bindKey);
|
|
45838
|
+
await this.ledger.forget(key);
|
|
45839
|
+
}
|
|
45840
|
+
async write(row) {
|
|
45841
|
+
this.dirty.delete(row.key);
|
|
45842
|
+
await this.ledger.put(row);
|
|
45843
|
+
}
|
|
45844
|
+
index(row) {
|
|
45845
|
+
let keys = this.byDevice.get(row.deviceId);
|
|
45846
|
+
if (!keys) {
|
|
45847
|
+
keys = /* @__PURE__ */ new Set();
|
|
45848
|
+
this.byDevice.set(row.deviceId, keys);
|
|
45849
|
+
}
|
|
45850
|
+
keys.add(row.key);
|
|
45851
|
+
}
|
|
45852
|
+
/** Drop candidates untouched for {@link EVIDENCE_WINDOW_MS}. Evidence
|
|
45853
|
+
* describes an object that can drive away; a stale candidate is not a
|
|
45854
|
+
* weaker one, it is one about a thing that is gone. */
|
|
45855
|
+
expire(deviceId, now) {
|
|
45856
|
+
for (const row of this.candidatesFor(deviceId)) {
|
|
45857
|
+
if (now - row.updatedAt <= 216e5) continue;
|
|
45858
|
+
this.logger.debug("stationary evidence expired — candidate untouched past the window", {
|
|
45859
|
+
tags: { deviceId },
|
|
45860
|
+
meta: {
|
|
45861
|
+
className: row.className,
|
|
45862
|
+
agreed: row.agreed,
|
|
45863
|
+
seen: row.seen,
|
|
45864
|
+
ageMs: now - row.updatedAt
|
|
45865
|
+
}
|
|
45866
|
+
});
|
|
45867
|
+
this.forgetKey(row.key);
|
|
45868
|
+
}
|
|
45869
|
+
}
|
|
45870
|
+
capDevice(deviceId) {
|
|
45871
|
+
const rows = this.candidatesFor(deviceId);
|
|
45872
|
+
if (rows.length <= 512) return;
|
|
45873
|
+
const excess = [...rows].sort((a, b) => a.updatedAt - b.updatedAt).slice(0, rows.length - 512);
|
|
45874
|
+
this.logger.info("stationary evidence capped — least-recently-touched candidates evicted", {
|
|
45875
|
+
tags: { deviceId },
|
|
45876
|
+
meta: {
|
|
45877
|
+
evicted: excess.length,
|
|
45878
|
+
cap: 512
|
|
45879
|
+
}
|
|
45880
|
+
});
|
|
45881
|
+
for (const row of excess) this.forgetKey(row.key);
|
|
45882
|
+
}
|
|
45883
|
+
};
|
|
45884
|
+
//#endregion
|
|
44949
45885
|
//#region src/pipeline-analytics/pipeline/stationary/stationary-registry.ts
|
|
44950
45886
|
/**
|
|
44951
45887
|
* @durable class=ledger owner=pipeline-analytics
|
|
@@ -55591,8 +56527,8 @@ function resolveTrackingSettings(raw) {
|
|
|
55591
56527
|
* with no drift. Guarded by a unit test asserting the equality.
|
|
55592
56528
|
*/
|
|
55593
56529
|
var StationarySettingsSchema = object({
|
|
55594
|
-
/** Master switch. Off ⇒
|
|
55595
|
-
* parked object keeps spawning normal tracks). */
|
|
56530
|
+
/** Master switch. Off ⇒ this camera gathers no evidence and suppresses
|
|
56531
|
+
* nothing (every parked object keeps spawning normal tracks). */
|
|
55596
56532
|
enabled: boolean().default(true),
|
|
55597
56533
|
/** IoU at/above which a detection is the same parked object, unmoved →
|
|
55598
56534
|
* suppress its spawn. `SUPPRESS_IOU`. */
|
|
@@ -55601,9 +56537,22 @@ var StationarySettingsSchema = object({
|
|
|
55601
56537
|
* wake it) — the overlap gate that stops a different vehicle from waking a
|
|
55602
56538
|
* parked entry (the 617 flood fix). `WAKE_ASSOC_IOU`. */
|
|
55603
56539
|
wakeAssocIou: number().min(.02).max(.5).default(WAKE_ASSOC_IOU),
|
|
55604
|
-
/** Recent-window stillness a track
|
|
55605
|
-
*
|
|
55606
|
-
|
|
56540
|
+
/** Recent-window stillness after which a track stops holding its motion
|
|
56541
|
+
* session open (also the minimum track age). Creates nothing — birth is
|
|
56542
|
+
* decided by repeated evidence. `SESSION_STILLNESS_WINDOW_MS`. */
|
|
56543
|
+
sessionStillnessWindowMs: number().int().min(5e3).max(12e4).default(SESSION_STILLNESS_WINDOW_MS),
|
|
56544
|
+
/**
|
|
56545
|
+
* Let a motionless PERSON become a stationary entry on this camera.
|
|
56546
|
+
*
|
|
56547
|
+
* Off, and it is a measurement rather than caution. Replaying 24 h of person
|
|
56548
|
+
* observations from devices 617/1443/590/592 through the evidence ledger
|
|
56549
|
+
* produces 6-22 entries a day depending on how strictly it is tuned, and none
|
|
56550
|
+
* of them can be tuned to zero: device 592 alone yielded eleven candidates at
|
|
56551
|
+
* a perfect agreement ratio between 02:00 and 05:00. A stationary entry
|
|
56552
|
+
* SUPPRESSES its object, so a wrong one makes the camera blind to a person —
|
|
56553
|
+
* turn this on only for a camera where you know what is standing there.
|
|
56554
|
+
*/
|
|
56555
|
+
promotePersons: boolean().default(false),
|
|
55607
56556
|
/** Observed-time TTL: retire an entry unconfirmed for this long (measured on
|
|
55608
56557
|
* frames-flowing time, not wall-clock). `ENTRY_TTL_MS`. */
|
|
55609
56558
|
entryTtlMs: number().int().min(6e4).max(18e5).default(ENTRY_TTL_MS)
|
|
@@ -55622,7 +56571,8 @@ function resolveStationarySettings(raw) {
|
|
|
55622
56571
|
enabled: pick("enabled"),
|
|
55623
56572
|
suppressIou: pick("suppressIou"),
|
|
55624
56573
|
wakeAssocIou: pick("wakeAssocIou"),
|
|
55625
|
-
|
|
56574
|
+
sessionStillnessWindowMs: pick("sessionStillnessWindowMs"),
|
|
56575
|
+
promotePersons: pick("promotePersons"),
|
|
55626
56576
|
entryTtlMs: pick("entryTtlMs")
|
|
55627
56577
|
};
|
|
55628
56578
|
}
|
|
@@ -56147,17 +57097,24 @@ function buildDetectionSettingsSections() {
|
|
|
56147
57097
|
},
|
|
56148
57098
|
{
|
|
56149
57099
|
type: "slider",
|
|
56150
|
-
key: "
|
|
56151
|
-
label: "
|
|
56152
|
-
description: "How long (ms)
|
|
57100
|
+
key: "sessionStillnessWindowMs",
|
|
57101
|
+
label: "Session stillness window",
|
|
57102
|
+
description: "How long (ms) a tracked object must stay put before it stops holding a motion session open. It creates nothing: a parked object becomes an entry by being seen repeatedly in the same place, not by one track surviving this long.",
|
|
56153
57103
|
min: 5e3,
|
|
56154
57104
|
max: 12e4,
|
|
56155
57105
|
step: 1e3,
|
|
56156
|
-
default: STATIONARY_DEFAULTS.
|
|
57106
|
+
default: STATIONARY_DEFAULTS.sessionStillnessWindowMs,
|
|
56157
57107
|
showValue: true,
|
|
56158
57108
|
unit: "s",
|
|
56159
57109
|
displayScale: 1e3
|
|
56160
57110
|
},
|
|
57111
|
+
{
|
|
57112
|
+
type: "boolean",
|
|
57113
|
+
key: "promotePersons",
|
|
57114
|
+
label: "Also park motionless people",
|
|
57115
|
+
description: "Off by default. A stationary entry SUPPRESSES its object, so a wrong one makes this camera blind to a person standing there. Replaying a day of real person detections produced entries on every camera tried, at every threshold tried — turn this on only where you know what is standing still.",
|
|
57116
|
+
default: STATIONARY_DEFAULTS.promotePersons
|
|
57117
|
+
},
|
|
56161
57118
|
{
|
|
56162
57119
|
type: "slider",
|
|
56163
57120
|
key: "entryTtlMs",
|
|
@@ -58650,6 +59607,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
58650
59607
|
* confirmation gate's bar there; one moving track clears it. Null until
|
|
58651
59608
|
* onInitialize. */
|
|
58652
59609
|
phantomCells = null;
|
|
59610
|
+
/** Repeated-sighting evidence — the ONLY thing that creates a stationary
|
|
59611
|
+
* entry. Two producers may feed it; only the motion session does today. */
|
|
59612
|
+
stationaryEvidence = null;
|
|
58653
59613
|
mediaStore = null;
|
|
58654
59614
|
/** The enrolled gallery's rows. Retained so the photo-enrollment bridge can
|
|
58655
59615
|
* reach them without going back through the cap surface. Null until S7. */
|
|
@@ -59913,6 +60873,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
59913
60873
|
}
|
|
59914
60874
|
});
|
|
59915
60875
|
await this.stationaryRegistry.load();
|
|
60876
|
+
this.stationaryEvidence = new StationaryEvidenceLedger({
|
|
60877
|
+
store: api.settingsStore,
|
|
60878
|
+
logger: logger.child("StationaryEvidence")
|
|
60879
|
+
});
|
|
60880
|
+
await this.stationaryEvidence.declare();
|
|
60881
|
+
const evidenceLoaded = await this.stationaryEvidence.load();
|
|
60882
|
+
logger.info("stationary evidence loaded", { meta: { candidates: evidenceLoaded } });
|
|
59916
60883
|
this.phantomCells = new PhantomCellRegistry({
|
|
59917
60884
|
store: api.settingsStore,
|
|
59918
60885
|
logger: logger.child("PhantomCells")
|
|
@@ -61301,6 +62268,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
61301
62268
|
this.retentionSweepTimer = setInterval(() => {
|
|
61302
62269
|
this.sweepRetention();
|
|
61303
62270
|
this.stationaryRegistry?.sweep(Date.now());
|
|
62271
|
+
this.stationaryEvidence?.flush();
|
|
61304
62272
|
this.eventsOpsLog?.pruneBefore(Date.now() - OPS_LOG_RETENTION_MS);
|
|
61305
62273
|
}, RETENTION_SWEEP_INTERVAL_MS);
|
|
61306
62274
|
this.ltsFlushTimer = setInterval(() => {
|
|
@@ -62135,38 +63103,39 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
62135
63103
|
const stationarySettings = this.stationarySettingsFromCache(deviceId);
|
|
62136
63104
|
const packageSettings = this.packageDropSettingsFromCache(deviceId);
|
|
62137
63105
|
const packageClasses = new Set(packageSettings.packageDropClassFilter);
|
|
62138
|
-
if (source === "pipeline" && this.stationaryRegistry && stationarySettings.enabled) {
|
|
62139
|
-
const
|
|
62140
|
-
|
|
62141
|
-
|
|
62142
|
-
const
|
|
62143
|
-
|
|
62144
|
-
|
|
63106
|
+
if (source === "pipeline" && this.stationaryRegistry && this.stationaryEvidence && stationarySettings.enabled) {
|
|
63107
|
+
const evidence = this.stationaryEvidence;
|
|
63108
|
+
for (const t of result.tracked) {
|
|
63109
|
+
if (packageClasses.has(t.className)) continue;
|
|
63110
|
+
const classEnabled = t.className === "person" ? stationarySettings.promotePersons : evidencePolicyFor(t.className).promotesByDefault;
|
|
63111
|
+
const active = this.trackStore?.peekActive(t.trackId) ?? null;
|
|
63112
|
+
const observedLabel = active === null ? void 0 : displayLabel(active);
|
|
63113
|
+
const observedKeyFrame = this.residents.keyFrameKey(t.trackId);
|
|
63114
|
+
const observation = {
|
|
63115
|
+
deviceId,
|
|
63116
|
+
className: t.className,
|
|
63117
|
+
bbox: { ...t.bbox },
|
|
63118
|
+
timestamp: result.timestamp,
|
|
63119
|
+
source: "session",
|
|
63120
|
+
trackId: t.trackId,
|
|
63121
|
+
...observedKeyFrame !== void 0 ? { keyFrameMediaKey: observedKeyFrame } : {},
|
|
63122
|
+
...observedLabel !== void 0 ? { label: observedLabel } : {}
|
|
62145
63123
|
};
|
|
62146
|
-
|
|
62147
|
-
|
|
62148
|
-
|
|
62149
|
-
|
|
62150
|
-
|
|
62151
|
-
|
|
62152
|
-
|
|
62153
|
-
|
|
62154
|
-
|
|
62155
|
-
|
|
62156
|
-
|
|
62157
|
-
|
|
62158
|
-
|
|
62159
|
-
key,
|
|
62160
|
-
processor,
|
|
62161
|
-
track: t,
|
|
62162
|
-
firstSeen: active.firstSeen,
|
|
62163
|
-
label: displayLabel(active),
|
|
62164
|
-
frameWidth: result.frameWidth,
|
|
62165
|
-
frameHeight: result.frameHeight,
|
|
62166
|
-
timestamp: result.timestamp
|
|
62167
|
-
});
|
|
62168
|
-
}
|
|
63124
|
+
const outcome = evidence.observe(observation, classEnabled);
|
|
63125
|
+
if (outcome === void 0 || !outcome.promoted) continue;
|
|
63126
|
+
this.birthStationaryFromEvidence({
|
|
63127
|
+
deviceId,
|
|
63128
|
+
key,
|
|
63129
|
+
processor,
|
|
63130
|
+
track: t,
|
|
63131
|
+
row: outcome.row,
|
|
63132
|
+
frameWidth: result.frameWidth,
|
|
63133
|
+
frameHeight: result.frameHeight,
|
|
63134
|
+
timestamp: result.timestamp,
|
|
63135
|
+
firstSeen: active === null ? outcome.row.firstAgreedAt : active.firstSeen
|
|
63136
|
+
});
|
|
62169
63137
|
}
|
|
63138
|
+
evidence.retainTracks(deviceId, currentTrackIds);
|
|
62170
63139
|
}
|
|
62171
63140
|
if (source === "pipeline") this.runPackageStillness({
|
|
62172
63141
|
deviceId,
|
|
@@ -62459,7 +63428,22 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
62459
63428
|
}
|
|
62460
63429
|
overlayDetections = frame.detections;
|
|
62461
63430
|
}
|
|
62462
|
-
const
|
|
63431
|
+
const stillnessConfig = {
|
|
63432
|
+
...DEFAULT_SESSION_STILLNESS_CONFIG,
|
|
63433
|
+
windowMs: this.stationarySettingsFromCache(deviceId).sessionStillnessWindowMs
|
|
63434
|
+
};
|
|
63435
|
+
const stillnessDims = this.lastFrameDimsByDevice.get(deviceId);
|
|
63436
|
+
const stillnessDiag = stillnessDims && stillnessDims.w > 0 && stillnessDims.h > 0 ? Math.hypot(stillnessDims.w, stillnessDims.h) : 0;
|
|
63437
|
+
const hasLiveTrack = result.tracked.some((t) => {
|
|
63438
|
+
const active = this.trackStore?.peekActive(t.trackId);
|
|
63439
|
+
if (!active) return true;
|
|
63440
|
+
return !trackIsStill({
|
|
63441
|
+
positions: active.positions,
|
|
63442
|
+
referenceDiagonalPx: stillnessDiag,
|
|
63443
|
+
now: result.timestamp,
|
|
63444
|
+
config: stillnessConfig
|
|
63445
|
+
}).still;
|
|
63446
|
+
});
|
|
62463
63447
|
this.ctx.eventBus.emit({
|
|
62464
63448
|
id: `pa-${randomUUID()}`,
|
|
62465
63449
|
timestamp: new Date(result.timestamp),
|
|
@@ -62475,7 +63459,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
62475
63459
|
frameWidth: result.frameWidth,
|
|
62476
63460
|
frameHeight: result.frameHeight,
|
|
62477
63461
|
detections: overlayDetections,
|
|
62478
|
-
|
|
63462
|
+
hasLiveTrack
|
|
62479
63463
|
}
|
|
62480
63464
|
});
|
|
62481
63465
|
}
|
|
@@ -62742,9 +63726,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
62742
63726
|
/**
|
|
62743
63727
|
* Count SHORT + MOTIONLESS track closes per frame cell.
|
|
62744
63728
|
*
|
|
62745
|
-
* The stationary registry
|
|
62746
|
-
*
|
|
62747
|
-
*
|
|
63729
|
+
* The stationary registry answers a different question (is this object parked,
|
|
63730
|
+
* and should it be suppressed?). This one raises a spawn bar. The dead
|
|
63731
|
+
* ornamental grass on device 615 produced tracks of 3.8 s,
|
|
62748
63732
|
* 4.1 s, 6.5 s and 19.0 s with 0-2.5 px of total displacement, roughly
|
|
62749
63733
|
* fifteen of them in a day, all at the same spot. Each one is individually
|
|
62750
63734
|
* innocent; the RECURRENCE is the signal, and nothing survives a track death
|
|
@@ -64281,29 +65265,41 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
64281
65265
|
* track-OWNED entity (both rows carry a `trackId`) and it aged crops out from
|
|
64282
65266
|
* under tracks still well inside their own window. Only the capacity bound
|
|
64283
65267
|
* remains (D61).
|
|
65268
|
+
*
|
|
65269
|
+
* The cap is the OPERATOR'S, not the schema default. `bufferMaxPerDevice` is
|
|
65270
|
+
* offered on the settings form and this sweep read `FACE_DEFAULTS` instead,
|
|
65271
|
+
* so an override was a knob that wrote a store nobody read — the D62 shape.
|
|
65272
|
+
* The suggested tier is DERIVED from whatever value is in force
|
|
65273
|
+
* (`deriveSuggestedBufferCap`), so the two can never disagree.
|
|
64284
65274
|
*/
|
|
64285
65275
|
async holdBufferCaps() {
|
|
64286
65276
|
const mediaStore = this.mediaStore;
|
|
64287
65277
|
if (!mediaStore) return;
|
|
65278
|
+
const bufferMaxPerDevice = await this.resolveBufferCap();
|
|
65279
|
+
const maxSuggestedPerDevice = deriveSuggestedBufferCap(bufferMaxPerDevice);
|
|
64288
65280
|
if (this.faceStore) try {
|
|
64289
|
-
const deletedFaceIds = await this.faceStore.pruneCapOverflow({
|
|
65281
|
+
const deletedFaceIds = await this.faceStore.pruneCapOverflow({
|
|
65282
|
+
maxPerDevice: bufferMaxPerDevice,
|
|
65283
|
+
maxSuggestedPerDevice
|
|
65284
|
+
});
|
|
64290
65285
|
if (deletedFaceIds.length > 0) {
|
|
64291
65286
|
await mediaStore.deleteForOwner("face", deletedFaceIds);
|
|
64292
65287
|
this.ctx.logger.info("face buffer capacity prune", { meta: {
|
|
64293
65288
|
deleted: deletedFaceIds.length,
|
|
64294
|
-
cap:
|
|
65289
|
+
cap: bufferMaxPerDevice,
|
|
65290
|
+
suggestedCap: maxSuggestedPerDevice
|
|
64295
65291
|
} });
|
|
64296
65292
|
}
|
|
64297
65293
|
} catch (err) {
|
|
64298
65294
|
this.ctx.logger.debug("face buffer cap prune failed", { meta: { error: String(err) } });
|
|
64299
65295
|
}
|
|
64300
65296
|
if (this.plateStore) try {
|
|
64301
|
-
const deletedPlateIds = await this.plateStore.pruneCapOverflow({ maxPerDevice:
|
|
65297
|
+
const deletedPlateIds = await this.plateStore.pruneCapOverflow({ maxPerDevice: bufferMaxPerDevice });
|
|
64302
65298
|
if (deletedPlateIds.length > 0) {
|
|
64303
65299
|
await mediaStore.deleteForOwner("plate", deletedPlateIds);
|
|
64304
65300
|
this.ctx.logger.info("plate buffer capacity prune", { meta: {
|
|
64305
65301
|
deleted: deletedPlateIds.length,
|
|
64306
|
-
cap:
|
|
65302
|
+
cap: bufferMaxPerDevice
|
|
64307
65303
|
} });
|
|
64308
65304
|
}
|
|
64309
65305
|
} catch (err) {
|
|
@@ -64311,6 +65307,28 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
64311
65307
|
}
|
|
64312
65308
|
}
|
|
64313
65309
|
/**
|
|
65310
|
+
* The buffer capacity actually in force: the GLOBAL setting, falling back to
|
|
65311
|
+
* the schema default when the store cannot be read.
|
|
65312
|
+
*
|
|
65313
|
+
* Global-only on purpose. The sweep is one pass over the whole collection and
|
|
65314
|
+
* a per-device cascade would mean one settings read per camera on every tick,
|
|
65315
|
+
* for a knob whose whole job is bounding a table. A per-camera capacity is a
|
|
65316
|
+
* decision nobody has asked for; the fallback here is the same number the
|
|
65317
|
+
* sweep used unconditionally before, so a settings outage cannot make the
|
|
65318
|
+
* bound stricter than it has ever been.
|
|
65319
|
+
*/
|
|
65320
|
+
async resolveBufferCap() {
|
|
65321
|
+
try {
|
|
65322
|
+
return resolveFaceSettings(await this.resolveGlobalStore()).bufferMaxPerDevice;
|
|
65323
|
+
} catch (err) {
|
|
65324
|
+
this.ctx.logger.debug("face buffer cap: global settings unreadable, using the default", { meta: {
|
|
65325
|
+
error: String(err),
|
|
65326
|
+
cap: FACE_DEFAULTS.bufferMaxPerDevice
|
|
65327
|
+
} });
|
|
65328
|
+
return FACE_DEFAULTS.bufferMaxPerDevice;
|
|
65329
|
+
}
|
|
65330
|
+
}
|
|
65331
|
+
/**
|
|
64314
65332
|
* Ingest a native (onboard) camera detection push and run it through
|
|
64315
65333
|
* the SAME analysis layers as the ML pipeline, tagged `source:
|
|
64316
65334
|
* 'onboard'`. Flag-only pushes (no bbox) can't be zoned/tracked and are
|
|
@@ -64597,29 +65615,41 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
64597
65615
|
});
|
|
64598
65616
|
}
|
|
64599
65617
|
}
|
|
64600
|
-
|
|
64601
|
-
|
|
64602
|
-
|
|
64603
|
-
|
|
64604
|
-
|
|
65618
|
+
/**
|
|
65619
|
+
* Turn a candidate whose evidence is sufficient into a registry entry.
|
|
65620
|
+
*
|
|
65621
|
+
* The BOX, label and key-frame come from the evidence row — the best agreeing
|
|
65622
|
+
* sighting across every track that ever contributed — not from whichever
|
|
65623
|
+
* track happened to be alive on the tipping frame. That is the point of the
|
|
65624
|
+
* ledger: the object outlives its tracks, so the entry must be built from
|
|
65625
|
+
* what was learned about the object, not from the last one to see it.
|
|
65626
|
+
*/
|
|
65627
|
+
birthStationaryFromEvidence(input) {
|
|
65628
|
+
const { deviceId, key, processor, track, row, firstSeen, frameWidth, frameHeight, timestamp } = input;
|
|
65629
|
+
const label = row.label;
|
|
65630
|
+
const keyFrameMediaKey = row.keyFrameMediaKey ?? this.residents.keyFrameKey(track.trackId);
|
|
65631
|
+
const plausible = isPlausibleThumbnailBox(row.bestBbox, frameWidth, frameHeight);
|
|
65632
|
+
if (!plausible) this.ctx.logger.info("stationary birth rejected (near-full-frame box)", {
|
|
64605
65633
|
tags: { deviceId },
|
|
64606
65634
|
meta: {
|
|
64607
65635
|
trackId: track.trackId,
|
|
64608
|
-
className:
|
|
64609
|
-
bbox:
|
|
65636
|
+
className: row.className,
|
|
65637
|
+
bbox: row.bestBbox,
|
|
65638
|
+
agreed: row.agreed,
|
|
65639
|
+
seen: row.seen
|
|
64610
65640
|
}
|
|
64611
65641
|
});
|
|
64612
65642
|
const entry = {
|
|
64613
65643
|
id: randomUUID(),
|
|
64614
65644
|
deviceId,
|
|
64615
|
-
className:
|
|
64616
|
-
bbox: { ...
|
|
65645
|
+
className: row.className,
|
|
65646
|
+
bbox: { ...row.bestBbox },
|
|
64617
65647
|
frameWidth,
|
|
64618
65648
|
frameHeight,
|
|
64619
65649
|
firstSeenAt: firstSeen,
|
|
64620
65650
|
becameStationaryAt: timestamp,
|
|
64621
65651
|
lastConfirmedAt: timestamp,
|
|
64622
|
-
sourceTrackId: track.trackId,
|
|
65652
|
+
sourceTrackId: row.sourceTrackId ?? track.trackId,
|
|
64623
65653
|
...label !== void 0 ? { label } : {},
|
|
64624
65654
|
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
|
|
64625
65655
|
};
|
|
@@ -64657,12 +65687,19 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
64657
65687
|
this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
|
|
64658
65688
|
this.overlayState.onTrackEnded(deviceId, track.trackId);
|
|
64659
65689
|
this.lastActiveTrackIds.get(key)?.delete(track.trackId);
|
|
64660
|
-
this.ctx.logger.info("
|
|
65690
|
+
this.ctx.logger.info("stationary entry born from repeated evidence", {
|
|
64661
65691
|
tags: { deviceId },
|
|
64662
65692
|
meta: {
|
|
64663
65693
|
trackId: track.trackId,
|
|
64664
|
-
className:
|
|
65694
|
+
className: row.className,
|
|
64665
65695
|
entryId: entry.id,
|
|
65696
|
+
agreed: row.agreed,
|
|
65697
|
+
seen: row.seen,
|
|
65698
|
+
agreementRatio: row.seen > 0 ? Number((row.agreed / row.seen).toFixed(3)) : 0,
|
|
65699
|
+
spanMs: row.lastAgreedAt - row.firstAgreedAt,
|
|
65700
|
+
sessionAgreed: row.sessionAgreed,
|
|
65701
|
+
burstAgreed: row.burstAgreed,
|
|
65702
|
+
plausible,
|
|
64666
65703
|
...label ? { label } : {}
|
|
64667
65704
|
}
|
|
64668
65705
|
});
|
|
@@ -64719,6 +65756,15 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
64719
65756
|
cooldownSec,
|
|
64720
65757
|
minTrackAgeMs: trk.minTrackAgeMs
|
|
64721
65758
|
}, source);
|
|
65759
|
+
p.setBindObserver((record) => {
|
|
65760
|
+
this.ctx.logger.debug("tracker: salvage bind", {
|
|
65761
|
+
tags: { deviceId },
|
|
65762
|
+
meta: {
|
|
65763
|
+
source,
|
|
65764
|
+
...record
|
|
65765
|
+
}
|
|
65766
|
+
});
|
|
65767
|
+
});
|
|
64722
65768
|
p.setTrackLabelState((trackId) => {
|
|
64723
65769
|
const active = this.trackStore?.getActiveByTrack(trackId);
|
|
64724
65770
|
return active !== null && active !== void 0 ? {
|
|
@@ -65095,6 +66141,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
65095
66141
|
this.residents.closeDevice(input.deviceId);
|
|
65096
66142
|
this.stationaryRegistry?.clearDevice(input.deviceId);
|
|
65097
66143
|
this.phantomCells?.clearDevice(input.deviceId);
|
|
66144
|
+
this.stationaryEvidence?.clearDevice(input.deviceId);
|
|
65098
66145
|
this.packageBackground?.clearDevice(input.deviceId);
|
|
65099
66146
|
this.overlayState.clearDevice(input.deviceId);
|
|
65100
66147
|
this.overlaySynthesisWarnAt.delete(input.deviceId);
|
|
@@ -66545,6 +67592,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
66545
67592
|
async wipeAllAnalytics(input) {
|
|
66546
67593
|
await this.stationaryRegistry?.clearDevice(input.deviceId);
|
|
66547
67594
|
await this.phantomCells?.clearDevice(input.deviceId);
|
|
67595
|
+
await this.stationaryEvidence?.clearDevice(input.deviceId);
|
|
66548
67596
|
await this.packageBackground?.clearDevice(input.deviceId);
|
|
66549
67597
|
return this.pruneTracksBefore({
|
|
66550
67598
|
deviceId: input.deviceId,
|