@camstack/addon-post-analysis 1.2.135 → 1.2.137
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-kn_4KEmG.js → dist-5kMCMfln.js} +127 -0
- package/dist/{dist-BAN19J7s.mjs → dist-DTdJBWXu.mjs} +127 -0
- package/dist/embedding-encoder/index.js +12 -349
- package/dist/embedding-encoder/index.mjs +11 -348
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CnLNNc0Y.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-b6u3Weak.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-CmYGqEiA.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-7L4CyDzy.mjs → hostInit-DDh-f-wy.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +923 -134
- package/dist/pipeline-analytics/index.mjs +923 -134
- 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-D_xOplZC.mjs +0 -26
|
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-5kMCMfln.js");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
7
|
node_fs = require_dist.__toESM(node_fs, 1);
|
|
8
8
|
let node_path = require("node:path");
|
|
@@ -27000,8 +27000,12 @@ function rankAndCap(faces, order, limit) {
|
|
|
27000
27000
|
* track through FaceStore.deleteByTracks, and enrolled (`assigned`) rows are
|
|
27001
27001
|
* exempt from that and from every sweep — an assignment is a curation act.
|
|
27002
27002
|
* The only other bound is capacity: pruneCapOverflow holds each camera's
|
|
27003
|
-
* UNASSIGNED buffer to bufferMaxPerDevice newest rows (default 50)
|
|
27004
|
-
*
|
|
27003
|
+
* UNASSIGNED buffer to bufferMaxPerDevice newest rows (default 50), plus a
|
|
27004
|
+
* SEPARATE, larger budget of deriveSuggestedBufferCap(bufferMaxPerDevice)
|
|
27005
|
+
* rows (default 200) for rows carrying a suggestedIdentityId — a question
|
|
27006
|
+
* the operator has not answered outlives a row nobody asked about, and
|
|
27007
|
+
* evicting one is logged with its deviceId. At 10.8 KB of JSON embedding
|
|
27008
|
+
* per row, those caps are what keep the table small."
|
|
27005
27009
|
*/
|
|
27006
27010
|
var FACES_COLLECTION = "pipeline-analytics:faces";
|
|
27007
27011
|
/**
|
|
@@ -27029,6 +27033,17 @@ var FACES_COLLECTION = "pipeline-analytics:faces";
|
|
|
27029
27033
|
* cap, so the page is always the caller's bound and never the engine's.
|
|
27030
27034
|
*/
|
|
27031
27035
|
var FACE_SWEEP_PAGE = 1e3;
|
|
27036
|
+
/** How many evicted face ids one per-camera eviction line names before it
|
|
27037
|
+
* stops — enough to find the rows, not enough to turn a log line into a page. */
|
|
27038
|
+
var SUGGESTED_EVICTION_IDS_LOGGED = 10;
|
|
27039
|
+
/**
|
|
27040
|
+
* A stored suggestion is present. The relational backend surfaces an unset
|
|
27041
|
+
* column as SQL NULL and the fake store as `undefined`; an empty string is
|
|
27042
|
+
* neither an identity nor a question, so it is not a suggestion either.
|
|
27043
|
+
*/
|
|
27044
|
+
function hasSuggestion(raw) {
|
|
27045
|
+
return typeof raw === "string" && raw.length > 0;
|
|
27046
|
+
}
|
|
27032
27047
|
/**
|
|
27033
27048
|
* Refuses to loop forever if a page ever fails to advance. 1 000 pages is a
|
|
27034
27049
|
* million faces — far past anything real, and reached only by a bug.
|
|
@@ -27235,12 +27250,21 @@ var FaceStore = class {
|
|
|
27235
27250
|
* property the recognizer actually depends on: the gallery of recent
|
|
27236
27251
|
* unassigned faces stays small.
|
|
27237
27252
|
*
|
|
27238
|
-
*
|
|
27239
|
-
*
|
|
27253
|
+
* **A row the system asked a question about outlives one it said nothing
|
|
27254
|
+
* about.** A single cap evicted both alike, so on 15 of 24 live cameras
|
|
27255
|
+
* (measured 2026-08-25) the buffer sat at exactly the cap and an unanswered
|
|
27256
|
+
* suggestion was deleted within about a day. Rows carrying a
|
|
27257
|
+
* `suggestedIdentityId` are ranked in their OWN, larger budget
|
|
27258
|
+
* (`maxSuggestedPerDevice`) — never immortality, because nothing clears a
|
|
27259
|
+
* suggestion the operator merely declines to answer. Size and trade-off:
|
|
27260
|
+
* `deriveSuggestedBufferCap` in `face-settings.ts:263`.
|
|
27261
|
+
*
|
|
27262
|
+
* ENROLLED (`assigned`) rows are skipped entirely and consume NO slot in
|
|
27263
|
+
* either tier. Returns all deleted ids (the caller removes their crops).
|
|
27240
27264
|
*/
|
|
27241
27265
|
async pruneCapOverflow(input) {
|
|
27242
27266
|
/**
|
|
27243
|
-
* Paged, not one statement. Only
|
|
27267
|
+
* Paged, not one statement. Only four scalars per row survive the page —
|
|
27244
27268
|
* the `embedding` string that dominates the row is dropped as soon as the
|
|
27245
27269
|
* page goes out of scope, so peak heap is one page rather than the whole
|
|
27246
27270
|
* face table. The prune still considers EVERY row, which is what makes it a
|
|
@@ -27262,38 +27286,79 @@ var FaceStore = class {
|
|
|
27262
27286
|
if (rows.length === 0) break;
|
|
27263
27287
|
for (const r of rows) {
|
|
27264
27288
|
const data = r.data;
|
|
27265
|
-
if (data
|
|
27266
|
-
const deviceId = Number(data
|
|
27289
|
+
if (data["assigned"]) continue;
|
|
27290
|
+
const deviceId = Number(data["deviceId"] ?? -1);
|
|
27267
27291
|
const list = byDevice.get(deviceId) ?? [];
|
|
27268
27292
|
list.push({
|
|
27269
27293
|
id: r.id,
|
|
27270
|
-
timestamp: Number(data
|
|
27294
|
+
timestamp: Number(data["timestamp"] ?? 0),
|
|
27295
|
+
suggested: hasSuggestion(data["suggestedIdentityId"])
|
|
27271
27296
|
});
|
|
27272
27297
|
byDevice.set(deviceId, list);
|
|
27273
27298
|
}
|
|
27274
27299
|
if (rows.length < 1e3) break;
|
|
27275
27300
|
}
|
|
27276
27301
|
const toDelete = /* @__PURE__ */ new Set();
|
|
27277
|
-
|
|
27302
|
+
const suggestedVictims = /* @__PURE__ */ new Map();
|
|
27303
|
+
for (const [deviceId, list] of byDevice) {
|
|
27278
27304
|
list.sort((a, b) => b.timestamp - a.timestamp);
|
|
27279
|
-
|
|
27280
|
-
|
|
27281
|
-
|
|
27305
|
+
let ordinaryRank = 0;
|
|
27306
|
+
let suggestedRank = 0;
|
|
27307
|
+
for (const row of list) {
|
|
27308
|
+
if (row.suggested) {
|
|
27309
|
+
if (suggestedRank++ < input.maxSuggestedPerDevice) continue;
|
|
27310
|
+
toDelete.add(row.id);
|
|
27311
|
+
const victims = suggestedVictims.get(deviceId) ?? [];
|
|
27312
|
+
victims.push(row.id);
|
|
27313
|
+
suggestedVictims.set(deviceId, victims);
|
|
27314
|
+
continue;
|
|
27315
|
+
}
|
|
27316
|
+
if (ordinaryRank++ < input.maxPerDevice) continue;
|
|
27317
|
+
toDelete.add(row.id);
|
|
27318
|
+
}
|
|
27282
27319
|
}
|
|
27283
|
-
const deleted =
|
|
27320
|
+
const deleted = /* @__PURE__ */ new Set();
|
|
27284
27321
|
for (const id of toDelete) try {
|
|
27285
27322
|
await this.store.delete.mutate({
|
|
27286
27323
|
collection: FACES_COLLECTION,
|
|
27287
27324
|
key: id
|
|
27288
27325
|
});
|
|
27289
|
-
deleted.
|
|
27326
|
+
deleted.add(id);
|
|
27290
27327
|
} catch (err) {
|
|
27291
27328
|
this.logger.warn("FaceStore.pruneCapOverflow delete failed", { meta: {
|
|
27292
27329
|
faceId: id,
|
|
27293
27330
|
error: String(err)
|
|
27294
27331
|
} });
|
|
27295
27332
|
}
|
|
27296
|
-
|
|
27333
|
+
this.announceSuggestedEvictions(suggestedVictims, deleted, input.maxSuggestedPerDevice);
|
|
27334
|
+
return [...deleted];
|
|
27335
|
+
}
|
|
27336
|
+
/**
|
|
27337
|
+
* Name every camera that lost an UNANSWERED suggestion, and how many.
|
|
27338
|
+
*
|
|
27339
|
+
* This exists because the loss it reports ran for weeks without a single log
|
|
27340
|
+
* line: the prune only ever logged an aggregate count with no `deviceId`, so
|
|
27341
|
+
* "why did camera 618 forget the person it asked me about" could not be asked
|
|
27342
|
+
* of the logs at all. One line per camera (not per row) keeps the volume sane
|
|
27343
|
+
* while still answering the per-camera question.
|
|
27344
|
+
*
|
|
27345
|
+
* Reports rows that were ACTUALLY deleted — a row whose delete threw is still
|
|
27346
|
+
* in the buffer, and is reported by the failure line above instead.
|
|
27347
|
+
*/
|
|
27348
|
+
announceSuggestedEvictions(victimsByDevice, deleted, cap) {
|
|
27349
|
+
for (const [deviceId, victims] of victimsByDevice) {
|
|
27350
|
+
const gone = victims.filter((id) => deleted.has(id));
|
|
27351
|
+
if (gone.length === 0) continue;
|
|
27352
|
+
this.logger.warn("face buffer evicted UNANSWERED suggestions — the operator never saw them", {
|
|
27353
|
+
tags: { deviceId },
|
|
27354
|
+
meta: {
|
|
27355
|
+
deleted: gone.length,
|
|
27356
|
+
cap,
|
|
27357
|
+
faceIds: gone.slice(0, SUGGESTED_EVICTION_IDS_LOGGED),
|
|
27358
|
+
why: "the suggested tier is full on this camera: suggestions arrive faster than they are answered, and the OLDEST unanswered ones are dropped first."
|
|
27359
|
+
}
|
|
27360
|
+
});
|
|
27361
|
+
}
|
|
27297
27362
|
}
|
|
27298
27363
|
/** Flip `assigned` to true and record which identity was matched. Optionally persist the sample id. */
|
|
27299
27364
|
async markAssigned(faceId, identityId, sampleId) {
|
|
@@ -37844,13 +37909,20 @@ var FaceSettingsSchema = require_dist.object({
|
|
|
37844
37909
|
* 0.175, best accuracy 98.3% at threshold 0.28.
|
|
37845
37910
|
*
|
|
37846
37911
|
* Recalibration plan, in this order:
|
|
37847
|
-
* 1.
|
|
37912
|
+
* 1. DONE (2026-08-19, commit b6641329f): the landmark-precision gate —
|
|
37913
|
+
* `landmark-precision-gate.ts` in `addon-pipeline`, called from
|
|
37914
|
+
* `detection-pipeline/pipeline/executor.ts:1164` BEFORE the native-size
|
|
37915
|
+
* substitution, floor `minLandmarkFaceSize` (default 24 detection px).
|
|
37916
|
+
* Do not re-implement it; the steps below are what is still open.
|
|
37848
37917
|
* 2. re-enrol the gallery from crops produced AFTER it,
|
|
37849
37918
|
* 3. re-measure genuine/impostor over a week of post-fix traffic,
|
|
37850
37919
|
* 4. expect to lower this toward ~0.30, and `margin` with it.
|
|
37851
|
-
* Lowering it
|
|
37852
|
-
*
|
|
37853
|
-
*
|
|
37920
|
+
* Lowering it before step 2 is still actively harmful: the gate stops NEW
|
|
37921
|
+
* degenerate crops, but every vector enrolled before it is still in the
|
|
37922
|
+
* gallery, and a misaligned crop scores ~0.9 against everything — so the
|
|
37923
|
+
* degenerate population sits ABOVE any threshold you could pick and the knob
|
|
37924
|
+
* cannot reach it. Steps 3 and 4 need data that only exists once step 2 has
|
|
37925
|
+
* run and post-fix traffic has accumulated.
|
|
37854
37926
|
*/
|
|
37855
37927
|
similarityThreshold: require_dist.number().min(0).max(1).default(.55),
|
|
37856
37928
|
/** Reject ambiguous matches: require best − secondBest ≥ margin. 0.10→0.15
|
|
@@ -37986,6 +38058,18 @@ function deriveSuggestionMinCosine(similarityThreshold) {
|
|
|
37986
38058
|
return rounded < similarityThreshold ? rounded : exact;
|
|
37987
38059
|
}
|
|
37988
38060
|
/**
|
|
38061
|
+
* The suggested tier's capacity for a given ordinary capacity.
|
|
38062
|
+
*
|
|
38063
|
+
* INVARIANT, total over every finite input: the result is a non-negative
|
|
38064
|
+
* integer, is never BELOW `bufferMaxPerDevice` (a suggested row is never worse
|
|
38065
|
+
* off than an unsuggested one), and is `0` exactly when the ordinary cap is `0`
|
|
38066
|
+
* (one switch turns the buffer off, not two).
|
|
38067
|
+
*/
|
|
38068
|
+
function deriveSuggestedBufferCap(bufferMaxPerDevice) {
|
|
38069
|
+
if (!Number.isFinite(bufferMaxPerDevice) || bufferMaxPerDevice <= 0) return 0;
|
|
38070
|
+
return Math.round(bufferMaxPerDevice * 4);
|
|
38071
|
+
}
|
|
38072
|
+
/**
|
|
37989
38073
|
* The settings key that used to hold the suggestion floor. Named once, here,
|
|
37990
38074
|
* because the ONLY code allowed to mention it now is the migration that removes
|
|
37991
38075
|
* it from a store.
|
|
@@ -44560,13 +44644,27 @@ var SUPPRESS_IOU = .6;
|
|
|
44560
44644
|
*/
|
|
44561
44645
|
var WAKE_ASSOC_IOU = .1;
|
|
44562
44646
|
/**
|
|
44563
|
-
* Look-back window over which a track must have stayed put to
|
|
44564
|
-
*
|
|
44565
|
-
*
|
|
44566
|
-
*
|
|
44567
|
-
* long
|
|
44568
|
-
|
|
44569
|
-
|
|
44647
|
+
* Look-back window over which a track must have stayed put to stop counting as
|
|
44648
|
+
* PRESENT for the orchestrator's session hold.
|
|
44649
|
+
*
|
|
44650
|
+
* This used to be the promotion criterion — "a track that stayed still this
|
|
44651
|
+
* long IS a parked object" — and it was measuring the wrong thing: it rewarded
|
|
44652
|
+
* an object seen well and often, while the parked cars that actually need
|
|
44653
|
+
* suppressing are re-discovered as dozens of short tracks that never live this
|
|
44654
|
+
* long (device 617: 140 tracks for one car, median life 6.8 s). Birth moved to
|
|
44655
|
+
* repetition — see `stationary-evidence.ts`.
|
|
44656
|
+
*
|
|
44657
|
+
* The stillness test survives because it was doing TWO jobs and only one of
|
|
44658
|
+
* them was destructive. Creating an entry BLINDS the pipeline to an object, so
|
|
44659
|
+
* it needs repeated evidence. Deciding that a motionless subject should stop
|
|
44660
|
+
* holding a motion session open destroys nothing — if it is wrong the session
|
|
44661
|
+
* closes early and the next motion re-opens it — so one recent stillness read
|
|
44662
|
+
* is the right amount of proof. Without this valve, removing promotion would
|
|
44663
|
+
* leave every session containing a parked car pinned to `maxSessionHoldMs`
|
|
44664
|
+
* (120 s) forever: 617 measured 504 sessions in 24 h, which is 70% detection
|
|
44665
|
+
* uptime on one camera, and three cameras are already above 50%.
|
|
44666
|
+
*/
|
|
44667
|
+
var SESSION_STILLNESS_WINDOW_MS = 3e4;
|
|
44570
44668
|
/** Unconfirmed-entry time-to-live: if no detection confirms an entry for this
|
|
44571
44669
|
* long (object removed while unobserved, or a long occlusion) → retire it. */
|
|
44572
44670
|
var ENTRY_TTL_MS = 5 * 6e4;
|
|
@@ -44587,15 +44685,20 @@ var DEFAULT_MATCH_CONFIG = {
|
|
|
44587
44685
|
* which entries are confirmed present, and which entries WOKE (their object
|
|
44588
44686
|
* moved → retire the entry and let the detection spawn a normal track).
|
|
44589
44687
|
*
|
|
44590
|
-
* 2. `
|
|
44591
|
-
*
|
|
44592
|
-
*
|
|
44593
|
-
*
|
|
44688
|
+
* 2. `trackIsStill` — given a track's recent centroid path, decides whether it
|
|
44689
|
+
* has stopped moving. Reuses the static-track-gate metrics (net
|
|
44690
|
+
* displacement + path span normalised to the frame diagonal) over the
|
|
44691
|
+
* recent look-back window.
|
|
44692
|
+
*
|
|
44693
|
+
* This does NOT create a stationary entry, and used to. Birth is decided by
|
|
44694
|
+
* repetition (`stationary-evidence.ts`); this predicate now answers the one
|
|
44695
|
+
* question whose wrong answer is harmless — "should this subject still hold
|
|
44696
|
+
* a motion session open?"
|
|
44594
44697
|
*/
|
|
44595
|
-
/** Default
|
|
44596
|
-
* static gate so "
|
|
44597
|
-
var
|
|
44598
|
-
windowMs:
|
|
44698
|
+
/** Default stillness tunables — static thresholds shared with the key-event
|
|
44699
|
+
* static gate so "still" means the same thing in both places. */
|
|
44700
|
+
var DEFAULT_SESSION_STILLNESS_CONFIG = {
|
|
44701
|
+
windowMs: SESSION_STILLNESS_WINDOW_MS,
|
|
44599
44702
|
netFracMax: STATIC_DISPLACEMENT_FRAC,
|
|
44600
44703
|
spanFracMax: STATIC_SPAN_FRAC,
|
|
44601
44704
|
minPoints: 4
|
|
@@ -44717,26 +44820,29 @@ function partitionDetectionsAgainstRegistry(input) {
|
|
|
44717
44820
|
}
|
|
44718
44821
|
var EMPTY_ARMED = /* @__PURE__ */ new Set();
|
|
44719
44822
|
/**
|
|
44720
|
-
* A track is
|
|
44721
|
-
*
|
|
44722
|
-
*
|
|
44723
|
-
* arrived
|
|
44724
|
-
* — is what lets a car that drove in then
|
|
44823
|
+
* A track is STILL when, over the recent `windowMs`, its centroid barely moved
|
|
44824
|
+
* (both net displacement and path span below the static thresholds) AND the
|
|
44825
|
+
* track has actually EXISTED for at least `windowMs` (so a car that just
|
|
44826
|
+
* arrived has not stopped yet). Judging on the recent window — not the whole
|
|
44827
|
+
* life — is what lets a car that drove in then stopped be recognised.
|
|
44828
|
+
*
|
|
44829
|
+
* The one consumer is the orchestrator's presence signal: a still subject stops
|
|
44830
|
+
* holding its motion session open. It creates nothing and suppresses nothing.
|
|
44725
44831
|
*/
|
|
44726
|
-
function
|
|
44832
|
+
function trackIsStill(input) {
|
|
44727
44833
|
const { positions, referenceDiagonalPx, now, config } = input;
|
|
44728
|
-
if (!(referenceDiagonalPx > 0) || positions.length === 0) return {
|
|
44729
|
-
if (now - positions[0].timestamp < config.windowMs) return {
|
|
44834
|
+
if (!(referenceDiagonalPx > 0) || positions.length === 0) return { still: false };
|
|
44835
|
+
if (now - positions[0].timestamp < config.windowMs) return { still: false };
|
|
44730
44836
|
const cutoff = now - config.windowMs;
|
|
44731
44837
|
const window = positions.filter((p) => p.timestamp >= cutoff);
|
|
44732
|
-
if (window.length < config.minPoints) return {
|
|
44838
|
+
if (window.length < config.minPoints) return { still: false };
|
|
44733
44839
|
const metrics = computeStaticTrackMetrics(window.map((p) => ({
|
|
44734
44840
|
x: p.x,
|
|
44735
44841
|
y: p.y
|
|
44736
44842
|
})), referenceDiagonalPx);
|
|
44737
|
-
if (metrics === void 0) return {
|
|
44843
|
+
if (metrics === void 0) return { still: false };
|
|
44738
44844
|
return {
|
|
44739
|
-
|
|
44845
|
+
still: metrics.netDisplacementFrac < config.netFracMax && metrics.pathSpanFrac < config.spanFracMax,
|
|
44740
44846
|
netFrac: metrics.netDisplacementFrac,
|
|
44741
44847
|
spanFrac: metrics.pathSpanFrac
|
|
44742
44848
|
};
|
|
@@ -45243,6 +45349,602 @@ var PackageDropDetector = class {
|
|
|
45243
45349
|
}
|
|
45244
45350
|
};
|
|
45245
45351
|
//#endregion
|
|
45352
|
+
//#region src/pipeline-analytics/pipeline/stationary/stationary-evidence.ts
|
|
45353
|
+
/**
|
|
45354
|
+
* stationary-evidence — how a parked object EARNS a registry entry.
|
|
45355
|
+
*
|
|
45356
|
+
* ## The criterion this replaces, and why it was measuring the wrong thing
|
|
45357
|
+
*
|
|
45358
|
+
* Until 2026-08-28 an object became stationary by TRACK CONTINUITY: one track
|
|
45359
|
+
* had to stay alive and barely move for `PROMOTION_WINDOW_MS` (30 s). That
|
|
45360
|
+
* rewards an object that is *seen well and often* — precisely the opposite of
|
|
45361
|
+
* what the operator needs. Measured on device 617 (the parking lot), 24 h:
|
|
45362
|
+
*
|
|
45363
|
+
* - three cars are parked in view; the registry held exactly ONE entry;
|
|
45364
|
+
* - the two it missed were re-discovered as **140 and 112 separate tracks**,
|
|
45365
|
+
* median life **6.8 s**, p90 29 s. Not one of them could ever reach 30 s.
|
|
45366
|
+
* - those two objects alone produced **13 494 of 18 402** vehicle
|
|
45367
|
+
* observations (73%) — the whole re-spawn flood the registry exists to stop.
|
|
45368
|
+
*
|
|
45369
|
+
* The track dies; the OBJECT does not. So the evidence must outlive the track.
|
|
45370
|
+
*
|
|
45371
|
+
* ## The new criterion: a RATIO, not a count
|
|
45372
|
+
*
|
|
45373
|
+
* "The same thing, in the same place, most of the times it was seen."
|
|
45374
|
+
*
|
|
45375
|
+
* N — observations that AGREED with the candidate's anchor box (IoU ≥
|
|
45376
|
+
* {@link StationaryEvidencePolicy.agreeIou}).
|
|
45377
|
+
* M — observations OFFERED by a track once it bound to this candidate,
|
|
45378
|
+
* agreeing or not.
|
|
45379
|
+
*
|
|
45380
|
+
* A count would rescue nothing: a badly-lit car at night is seen rarely, and
|
|
45381
|
+
* any floor high enough to exclude a passer-by would exclude it too. A RATIO
|
|
45382
|
+
* asks a question a rarely-seen object can still answer well — 8 sightings out
|
|
45383
|
+
* of 10 in the same spot is stationary even though 10 is a small number.
|
|
45384
|
+
*
|
|
45385
|
+
* The dilution is the whole point, and it is why M counts a bound track's
|
|
45386
|
+
* observations even after it walks away: a car that drives THROUGH a spot binds
|
|
45387
|
+
* for a few frames and then contributes dozens of disagreeing ones. Measured on
|
|
45388
|
+
* 617 the two populations do not overlap:
|
|
45389
|
+
*
|
|
45390
|
+
* | candidate | N / M | ratio |
|
|
45391
|
+
* | ---------------------------- | ----------- | ----- |
|
|
45392
|
+
* | parked car A (missed today) | 6405 / 6467 | 0.99 |
|
|
45393
|
+
* | parked car B (missed today) | 343 / 343 | 1.00 |
|
|
45394
|
+
* | car crossing the lot | 5 / 70 | 0.07 |
|
|
45395
|
+
* | car turning through the lot | 14 / 91 | 0.15 |
|
|
45396
|
+
* | car manoeuvring | 12 / 41 | 0.29 |
|
|
45397
|
+
*
|
|
45398
|
+
* The threshold sits in the empty band between 0.29 and 0.99.
|
|
45399
|
+
*
|
|
45400
|
+
* ## The span is what a ratio cannot say
|
|
45401
|
+
*
|
|
45402
|
+
* A ratio alone promotes a car that paused for four seconds — its handful of
|
|
45403
|
+
* observations all agree. So evidence must also STRETCH: the agreeing
|
|
45404
|
+
* observations must span {@link StationaryEvidencePolicy.minSpanMs}. Measured
|
|
45405
|
+
* on 617, real parked cars span 2 min to 24 h; the two momentary stops in the
|
|
45406
|
+
* same day spanned 4.2 s and 8.0 s. Setting the span to
|
|
45407
|
+
* `DEFAULT_MAX_SESSION_HOLD_MS` also buys a structural guarantee: no single
|
|
45408
|
+
* motion session, however long it is held open, can promote on its own.
|
|
45409
|
+
*
|
|
45410
|
+
* ## ONE ledger, two producers
|
|
45411
|
+
*
|
|
45412
|
+
* Evidence is anchored to a BOX, not to a track and not to a session, so an
|
|
45413
|
+
* object confirmed half by an active motion session and half by a periodic
|
|
45414
|
+
* burst still promotes. Occasion A (the session) is implemented and is
|
|
45415
|
+
* sufficient on its own — it is free, because the pipeline is already decoding
|
|
45416
|
+
* and already inferring. Occasion B (the burst) is a precaution for the cases
|
|
45417
|
+
* where A could not have fired at all (hub restarted, camera offline, motion
|
|
45418
|
+
* detection failed, object already parked at boot); it is NOT implemented yet,
|
|
45419
|
+
* and {@link StationaryEvidenceSource} is the seam it will arrive through.
|
|
45420
|
+
*
|
|
45421
|
+
* ## Identity is IoU. There is no grid.
|
|
45422
|
+
*
|
|
45423
|
+
* The predecessor mechanism (`phantom-cells.ts`) quantises a point to a 32 px
|
|
45424
|
+
* cell, and its write path passes a CENTROID while its read path passes a
|
|
45425
|
+
* top-left CORNER — at 32 px every box bigger than 32 px lands in a different
|
|
45426
|
+
* cell on read, and its unit test never crosses that boundary because it calls
|
|
45427
|
+
* both halves with the same literal coordinates. This module has no such
|
|
45428
|
+
* boundary to get wrong: a candidate is found by overlap against the boxes a
|
|
45429
|
+
* device already holds, bounded by {@link EVIDENCE_MAX_CANDIDATES_PER_DEVICE}.
|
|
45430
|
+
* Device 617 — the busiest lot in the fleet — holds 8.
|
|
45431
|
+
*/
|
|
45432
|
+
/**
|
|
45433
|
+
* `vehicle` and its kin. Every number below is read off device 617 over 24 h
|
|
45434
|
+
* (18 402 vehicle observations, 290 tracks) with the three parked cars the
|
|
45435
|
+
* operator can see out of the window as the bench.
|
|
45436
|
+
*
|
|
45437
|
+
* - `agreeIou` **0.5** — between the registry's own `WAKE_ASSOC_IOU` (0.1) and
|
|
45438
|
+
* `SUPPRESS_IOU` (0.6). At 0.7 the two missed cars lose 11% of their evidence
|
|
45439
|
+
* to night-time box wobble for no reduction in promotions; at 0.3 the day's
|
|
45440
|
+
* promotions double (10 vs 5) without catching anything new.
|
|
45441
|
+
* - `minAgreementRatio` **0.6** — the measured gap is [0.29, 0.90]; 0.6 is the
|
|
45442
|
+
* middle of it. Raising it to 0.95 starts dropping real parked cars.
|
|
45443
|
+
* - `minAgreed` **8** — a floor, not a discriminator: sweeping it 3→60 changes
|
|
45444
|
+
* nothing on 617 (the smallest true positive promotes with N=15). Set well
|
|
45445
|
+
* below that so a rarely-seen object is never excluded for being rare, which
|
|
45446
|
+
* is the entire point of using a ratio.
|
|
45447
|
+
* - `minSpanMs` **120 000** — the real discriminator. True positives span
|
|
45448
|
+
* 2 min–24 h; the day's two momentary stops spanned 4.2 s and 8.0 s.
|
|
45449
|
+
*/
|
|
45450
|
+
var VEHICLE_EVIDENCE_POLICY = {
|
|
45451
|
+
agreeIou: .5,
|
|
45452
|
+
minAgreed: 8,
|
|
45453
|
+
minAgreementRatio: .6,
|
|
45454
|
+
minSpanMs: 12e4,
|
|
45455
|
+
promotesByDefault: true
|
|
45456
|
+
};
|
|
45457
|
+
/**
|
|
45458
|
+
* `person`, and it ships OFF.
|
|
45459
|
+
*
|
|
45460
|
+
* This is not caution, it is a measurement. Replaying 24 h of person
|
|
45461
|
+
* observations from devices 617, 1443, 590 and 592 through this exact ledger:
|
|
45462
|
+
*
|
|
45463
|
+
* | ratio | minAgreed | minSpanMs | promotions / 24 h across 4 cameras |
|
|
45464
|
+
* | ----- | --------- | --------- | ---------------------------------- |
|
|
45465
|
+
* | 0.85 | 20 | 600 s | 22 |
|
|
45466
|
+
* | 0.95 | 40 | 1800 s | 10 |
|
|
45467
|
+
* | 0.99 | 80 | 1800 s | **6** |
|
|
45468
|
+
*
|
|
45469
|
+
* There is no setting that reaches zero. Device 590 holds a person-shaped thing
|
|
45470
|
+
* that agrees with itself 1292 times out of 1654 (0.78) across 26 tracks over
|
|
45471
|
+
* ten hours, and device 592 produced eleven candidates at ratio 1.00 between
|
|
45472
|
+
* 02:00 and 05:00 — a statue, a poster, an IR artifact; the ledger cannot tell
|
|
45473
|
+
* those from a person standing still, because they look identical to it.
|
|
45474
|
+
*
|
|
45475
|
+
* Promoting a person is a BLINDING decision (D49 read in the direction that
|
|
45476
|
+
* destroys work: for a car the destructive read is the RETIREMENT — the flood
|
|
45477
|
+
* comes back; for a person it is the BIRTH — we stop seeing them). So the
|
|
45478
|
+
* class is available, tuned as strictly as the data allows, and switched off
|
|
45479
|
+
* until an operator turns it on for a camera where they know what is standing
|
|
45480
|
+
* there.
|
|
45481
|
+
*/
|
|
45482
|
+
var PERSON_EVIDENCE_POLICY = {
|
|
45483
|
+
agreeIou: .5,
|
|
45484
|
+
minAgreed: 40,
|
|
45485
|
+
minAgreementRatio: .95,
|
|
45486
|
+
minSpanMs: 18e5,
|
|
45487
|
+
promotesByDefault: false
|
|
45488
|
+
};
|
|
45489
|
+
/** Classes with no policy of their own fall back to the vehicle numbers —
|
|
45490
|
+
* `animal`, `package` and friends are all "an object that ended up somewhere",
|
|
45491
|
+
* and none of them makes the camera blind to a person. */
|
|
45492
|
+
var DEFAULT_EVIDENCE_POLICY = VEHICLE_EVIDENCE_POLICY;
|
|
45493
|
+
var POLICY_BY_CLASS = new Map([["vehicle", VEHICLE_EVIDENCE_POLICY], ["person", PERSON_EVIDENCE_POLICY]]);
|
|
45494
|
+
function evidencePolicyFor(className) {
|
|
45495
|
+
return POLICY_BY_CLASS.get(className) ?? DEFAULT_EVIDENCE_POLICY;
|
|
45496
|
+
}
|
|
45497
|
+
function stationaryEvidenceKey(deviceId, className, anchorId) {
|
|
45498
|
+
return `${deviceId}:${className}:${anchorId}`;
|
|
45499
|
+
}
|
|
45500
|
+
/**
|
|
45501
|
+
* Does this candidate's evidence justify a registry entry?
|
|
45502
|
+
*
|
|
45503
|
+
* Pure, and ordered cheapest-question-first so the reason a candidate was
|
|
45504
|
+
* refused is the FIRST thing it failed, not the last.
|
|
45505
|
+
*/
|
|
45506
|
+
function evaluateStationaryEvidence(row, policy, classEnabled) {
|
|
45507
|
+
const ratio = row.seen > 0 ? row.agreed / row.seen : 0;
|
|
45508
|
+
const spanMs = row.lastAgreedAt - row.firstAgreedAt;
|
|
45509
|
+
const base = {
|
|
45510
|
+
agreed: row.agreed,
|
|
45511
|
+
seen: row.seen,
|
|
45512
|
+
ratio,
|
|
45513
|
+
spanMs
|
|
45514
|
+
};
|
|
45515
|
+
if (!classEnabled) return {
|
|
45516
|
+
kind: "accumulating",
|
|
45517
|
+
shortfall: "class-not-promoted",
|
|
45518
|
+
...base
|
|
45519
|
+
};
|
|
45520
|
+
if (row.agreed < policy.minAgreed) return {
|
|
45521
|
+
kind: "accumulating",
|
|
45522
|
+
shortfall: "too-few-agreements",
|
|
45523
|
+
...base
|
|
45524
|
+
};
|
|
45525
|
+
if (ratio < policy.minAgreementRatio) return {
|
|
45526
|
+
kind: "accumulating",
|
|
45527
|
+
shortfall: "inconsistent-position",
|
|
45528
|
+
...base
|
|
45529
|
+
};
|
|
45530
|
+
if (spanMs < policy.minSpanMs) return {
|
|
45531
|
+
kind: "accumulating",
|
|
45532
|
+
shortfall: "span-too-short",
|
|
45533
|
+
...base
|
|
45534
|
+
};
|
|
45535
|
+
return {
|
|
45536
|
+
kind: "promote",
|
|
45537
|
+
...base
|
|
45538
|
+
};
|
|
45539
|
+
}
|
|
45540
|
+
/**
|
|
45541
|
+
* The candidate this observation belongs to: the same-class row whose anchor it
|
|
45542
|
+
* overlaps most, provided the overlap reaches `agreeIou`. Overlap is the ONLY
|
|
45543
|
+
* notion of sameness here, and it is the same {@link iou} the suppression gate
|
|
45544
|
+
* uses — a second notion of "same place" is a second source of truth.
|
|
45545
|
+
*/
|
|
45546
|
+
function matchEvidenceCandidate(rows, className, bbox, agreeIou) {
|
|
45547
|
+
let best;
|
|
45548
|
+
let bestOverlap = 0;
|
|
45549
|
+
for (const row of rows) {
|
|
45550
|
+
if (row.className !== className) continue;
|
|
45551
|
+
const overlap = iou(row.anchor, bbox);
|
|
45552
|
+
if (overlap > bestOverlap) {
|
|
45553
|
+
bestOverlap = overlap;
|
|
45554
|
+
best = row;
|
|
45555
|
+
}
|
|
45556
|
+
}
|
|
45557
|
+
if (best === void 0 || bestOverlap < agreeIou) return void 0;
|
|
45558
|
+
return {
|
|
45559
|
+
row: best,
|
|
45560
|
+
overlap: bestOverlap
|
|
45561
|
+
};
|
|
45562
|
+
}
|
|
45563
|
+
/**
|
|
45564
|
+
* Fold one observation into a candidate.
|
|
45565
|
+
*
|
|
45566
|
+
* `agrees` is decided by the CALLER, because M is what makes the ratio mean
|
|
45567
|
+
* anything: once a track has bound to a candidate, every later observation of
|
|
45568
|
+
* that track counts toward M whether it agrees or not. That is what dilutes a
|
|
45569
|
+
* car that merely drove through the spot, and it is why this function cannot
|
|
45570
|
+
* re-derive agreement from the box alone.
|
|
45571
|
+
*/
|
|
45572
|
+
function foldObservation(row, observation, agrees, overlap) {
|
|
45573
|
+
const seen = row.seen + 1;
|
|
45574
|
+
if (!agrees) return {
|
|
45575
|
+
...row,
|
|
45576
|
+
seen,
|
|
45577
|
+
updatedAt: observation.timestamp
|
|
45578
|
+
};
|
|
45579
|
+
const improves = overlap >= iou(row.anchor, row.bestBbox);
|
|
45580
|
+
return {
|
|
45581
|
+
...row,
|
|
45582
|
+
seen,
|
|
45583
|
+
agreed: row.agreed + 1,
|
|
45584
|
+
firstAgreedAt: Math.min(row.firstAgreedAt, observation.timestamp),
|
|
45585
|
+
lastAgreedAt: Math.max(row.lastAgreedAt, observation.timestamp),
|
|
45586
|
+
...improves ? {
|
|
45587
|
+
bestBbox: { ...observation.bbox },
|
|
45588
|
+
...observation.trackId !== void 0 ? { sourceTrackId: observation.trackId } : {},
|
|
45589
|
+
...observation.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: observation.keyFrameMediaKey } : {},
|
|
45590
|
+
...observation.label !== void 0 ? { label: observation.label } : {}
|
|
45591
|
+
} : {},
|
|
45592
|
+
sessionAgreed: row.sessionAgreed + (observation.source === "session" ? 1 : 0),
|
|
45593
|
+
burstAgreed: row.burstAgreed + (observation.source === "burst" ? 1 : 0),
|
|
45594
|
+
updatedAt: observation.timestamp
|
|
45595
|
+
};
|
|
45596
|
+
}
|
|
45597
|
+
/** A brand-new candidate anchored on the observation that had nowhere to go. */
|
|
45598
|
+
function newEvidenceRow(observation, anchorId) {
|
|
45599
|
+
return {
|
|
45600
|
+
key: stationaryEvidenceKey(observation.deviceId, observation.className, anchorId),
|
|
45601
|
+
deviceId: observation.deviceId,
|
|
45602
|
+
className: observation.className,
|
|
45603
|
+
anchor: { ...observation.bbox },
|
|
45604
|
+
agreed: 1,
|
|
45605
|
+
seen: 1,
|
|
45606
|
+
firstAgreedAt: observation.timestamp,
|
|
45607
|
+
lastAgreedAt: observation.timestamp,
|
|
45608
|
+
bestBbox: { ...observation.bbox },
|
|
45609
|
+
...observation.trackId !== void 0 ? { sourceTrackId: observation.trackId } : {},
|
|
45610
|
+
...observation.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: observation.keyFrameMediaKey } : {},
|
|
45611
|
+
...observation.label !== void 0 ? { label: observation.label } : {},
|
|
45612
|
+
sessionAgreed: observation.source === "session" ? 1 : 0,
|
|
45613
|
+
burstAgreed: observation.source === "burst" ? 1 : 0,
|
|
45614
|
+
updatedAt: observation.timestamp
|
|
45615
|
+
};
|
|
45616
|
+
}
|
|
45617
|
+
//#endregion
|
|
45618
|
+
//#region src/pipeline-analytics/pipeline/stationary/stationary-evidence-ledger.ts
|
|
45619
|
+
/**
|
|
45620
|
+
* @durable class=ledger owner=pipeline-analytics
|
|
45621
|
+
* 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)"
|
|
45622
|
+
* 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."
|
|
45623
|
+
*/
|
|
45624
|
+
var STATIONARY_EVIDENCE_COLLECTION = "pipeline-analytics:stationary-evidence";
|
|
45625
|
+
var STATIONARY_EVIDENCE_COLUMNS = [
|
|
45626
|
+
{
|
|
45627
|
+
name: "id",
|
|
45628
|
+
type: "TEXT",
|
|
45629
|
+
primaryKey: true,
|
|
45630
|
+
notNull: true
|
|
45631
|
+
},
|
|
45632
|
+
{
|
|
45633
|
+
name: "deviceId",
|
|
45634
|
+
type: "INTEGER",
|
|
45635
|
+
notNull: true
|
|
45636
|
+
},
|
|
45637
|
+
{
|
|
45638
|
+
name: "className",
|
|
45639
|
+
type: "TEXT",
|
|
45640
|
+
notNull: true
|
|
45641
|
+
},
|
|
45642
|
+
{
|
|
45643
|
+
name: "anchor",
|
|
45644
|
+
type: "JSON"
|
|
45645
|
+
},
|
|
45646
|
+
{
|
|
45647
|
+
name: "agreed",
|
|
45648
|
+
type: "INTEGER"
|
|
45649
|
+
},
|
|
45650
|
+
{
|
|
45651
|
+
name: "seen",
|
|
45652
|
+
type: "INTEGER"
|
|
45653
|
+
},
|
|
45654
|
+
{
|
|
45655
|
+
name: "firstAgreedAt",
|
|
45656
|
+
type: "INTEGER"
|
|
45657
|
+
},
|
|
45658
|
+
{
|
|
45659
|
+
name: "lastAgreedAt",
|
|
45660
|
+
type: "INTEGER"
|
|
45661
|
+
},
|
|
45662
|
+
{
|
|
45663
|
+
name: "bestBbox",
|
|
45664
|
+
type: "JSON"
|
|
45665
|
+
},
|
|
45666
|
+
{
|
|
45667
|
+
name: "sourceTrackId",
|
|
45668
|
+
type: "TEXT"
|
|
45669
|
+
},
|
|
45670
|
+
{
|
|
45671
|
+
name: "keyFrameMediaKey",
|
|
45672
|
+
type: "TEXT"
|
|
45673
|
+
},
|
|
45674
|
+
{
|
|
45675
|
+
name: "label",
|
|
45676
|
+
type: "TEXT"
|
|
45677
|
+
},
|
|
45678
|
+
{
|
|
45679
|
+
name: "sessionAgreed",
|
|
45680
|
+
type: "INTEGER"
|
|
45681
|
+
},
|
|
45682
|
+
{
|
|
45683
|
+
name: "burstAgreed",
|
|
45684
|
+
type: "INTEGER"
|
|
45685
|
+
},
|
|
45686
|
+
{
|
|
45687
|
+
name: "updatedAt",
|
|
45688
|
+
type: "INTEGER"
|
|
45689
|
+
}
|
|
45690
|
+
];
|
|
45691
|
+
var STATIONARY_EVIDENCE_INDEXES = [{
|
|
45692
|
+
name: "idx_stationary_evidence_device",
|
|
45693
|
+
columns: ["deviceId"]
|
|
45694
|
+
}];
|
|
45695
|
+
function parseBbox(value) {
|
|
45696
|
+
if (typeof value !== "object" || value === null) return null;
|
|
45697
|
+
const { x, y, w, h } = { ...value };
|
|
45698
|
+
if (typeof x !== "number" || typeof y !== "number") return null;
|
|
45699
|
+
if (typeof w !== "number" || typeof h !== "number") return null;
|
|
45700
|
+
return {
|
|
45701
|
+
x,
|
|
45702
|
+
y,
|
|
45703
|
+
w,
|
|
45704
|
+
h
|
|
45705
|
+
};
|
|
45706
|
+
}
|
|
45707
|
+
function numberOr(value, fallback) {
|
|
45708
|
+
return typeof value === "number" ? value : fallback;
|
|
45709
|
+
}
|
|
45710
|
+
function stringOrUndefined(value) {
|
|
45711
|
+
return typeof value === "string" ? value : void 0;
|
|
45712
|
+
}
|
|
45713
|
+
var StationaryEvidenceLedger = class {
|
|
45714
|
+
ledger;
|
|
45715
|
+
logger;
|
|
45716
|
+
/** deviceId → candidate keys, so a device's candidates are found without
|
|
45717
|
+
* scanning the fleet. */
|
|
45718
|
+
byDevice = /* @__PURE__ */ new Map();
|
|
45719
|
+
/** `deviceId:trackId` → candidate key. RAM-only, see the file header. */
|
|
45720
|
+
binding = /* @__PURE__ */ new Map();
|
|
45721
|
+
/** Keys whose staged tallies have not been persisted yet. */
|
|
45722
|
+
dirty = /* @__PURE__ */ new Set();
|
|
45723
|
+
anchorSeq = 0;
|
|
45724
|
+
constructor(deps) {
|
|
45725
|
+
this.logger = deps.logger;
|
|
45726
|
+
const spec = {
|
|
45727
|
+
collection: STATIONARY_EVIDENCE_COLLECTION,
|
|
45728
|
+
columns: [...STATIONARY_EVIDENCE_COLUMNS],
|
|
45729
|
+
indexes: [...STATIONARY_EVIDENCE_INDEXES],
|
|
45730
|
+
writeMode: "write-behind",
|
|
45731
|
+
keyOf: (row) => row.key,
|
|
45732
|
+
toValue: (row) => ({
|
|
45733
|
+
deviceId: row.deviceId,
|
|
45734
|
+
className: row.className,
|
|
45735
|
+
anchor: { ...row.anchor },
|
|
45736
|
+
agreed: row.agreed,
|
|
45737
|
+
seen: row.seen,
|
|
45738
|
+
firstAgreedAt: row.firstAgreedAt,
|
|
45739
|
+
lastAgreedAt: row.lastAgreedAt,
|
|
45740
|
+
bestBbox: { ...row.bestBbox },
|
|
45741
|
+
...row.sourceTrackId !== void 0 ? { sourceTrackId: row.sourceTrackId } : {},
|
|
45742
|
+
...row.keyFrameMediaKey !== void 0 ? { keyFrameMediaKey: row.keyFrameMediaKey } : {},
|
|
45743
|
+
...row.label !== void 0 ? { label: row.label } : {},
|
|
45744
|
+
sessionAgreed: row.sessionAgreed,
|
|
45745
|
+
burstAgreed: row.burstAgreed,
|
|
45746
|
+
updatedAt: row.updatedAt
|
|
45747
|
+
}),
|
|
45748
|
+
fromRecord: (key, data) => {
|
|
45749
|
+
const deviceId = data.deviceId;
|
|
45750
|
+
const className = data.className;
|
|
45751
|
+
if (typeof deviceId !== "number" || typeof className !== "string") return null;
|
|
45752
|
+
const anchor = parseBbox(data.anchor);
|
|
45753
|
+
const bestBbox = parseBbox(data.bestBbox) ?? anchor;
|
|
45754
|
+
if (anchor === null || bestBbox === null) return null;
|
|
45755
|
+
const sourceTrackId = stringOrUndefined(data.sourceTrackId);
|
|
45756
|
+
const keyFrameMediaKey = stringOrUndefined(data.keyFrameMediaKey);
|
|
45757
|
+
const label = stringOrUndefined(data.label);
|
|
45758
|
+
const updatedAt = numberOr(data.updatedAt, 0);
|
|
45759
|
+
return {
|
|
45760
|
+
key,
|
|
45761
|
+
deviceId,
|
|
45762
|
+
className,
|
|
45763
|
+
anchor,
|
|
45764
|
+
agreed: numberOr(data.agreed, 0),
|
|
45765
|
+
seen: numberOr(data.seen, 0),
|
|
45766
|
+
firstAgreedAt: numberOr(data.firstAgreedAt, updatedAt),
|
|
45767
|
+
lastAgreedAt: numberOr(data.lastAgreedAt, updatedAt),
|
|
45768
|
+
bestBbox,
|
|
45769
|
+
...sourceTrackId !== void 0 ? { sourceTrackId } : {},
|
|
45770
|
+
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {},
|
|
45771
|
+
...label !== void 0 ? { label } : {},
|
|
45772
|
+
sessionAgreed: numberOr(data.sessionAgreed, 0),
|
|
45773
|
+
burstAgreed: numberOr(data.burstAgreed, 0),
|
|
45774
|
+
updatedAt
|
|
45775
|
+
};
|
|
45776
|
+
},
|
|
45777
|
+
deviceIdOf: (row) => row.deviceId
|
|
45778
|
+
};
|
|
45779
|
+
this.ledger = new DurableLedger({
|
|
45780
|
+
spec,
|
|
45781
|
+
store: deps.store,
|
|
45782
|
+
logger: deps.logger
|
|
45783
|
+
});
|
|
45784
|
+
}
|
|
45785
|
+
declare() {
|
|
45786
|
+
return this.ledger.declare();
|
|
45787
|
+
}
|
|
45788
|
+
/** Boot reseed. Returns how many candidates came back — "loaded 0" after a
|
|
45789
|
+
* container recreate is the one line that explains a silent regression. */
|
|
45790
|
+
async load() {
|
|
45791
|
+
const rows = await this.ledger.load();
|
|
45792
|
+
for (const row of rows) this.index(row);
|
|
45793
|
+
return rows.length;
|
|
45794
|
+
}
|
|
45795
|
+
get size() {
|
|
45796
|
+
return this.ledger.snapshot().length;
|
|
45797
|
+
}
|
|
45798
|
+
candidatesFor(deviceId) {
|
|
45799
|
+
const keys = this.byDevice.get(deviceId);
|
|
45800
|
+
if (!keys) return [];
|
|
45801
|
+
const rows = [];
|
|
45802
|
+
for (const key of keys) {
|
|
45803
|
+
const row = this.ledger.get(key);
|
|
45804
|
+
if (row) rows.push(row);
|
|
45805
|
+
}
|
|
45806
|
+
return rows;
|
|
45807
|
+
}
|
|
45808
|
+
/**
|
|
45809
|
+
* Fold ONE observation in and say whether it just earned an entry.
|
|
45810
|
+
*
|
|
45811
|
+
* Synchronous by construction: the verdict is read from the RAM mirror, so a
|
|
45812
|
+
* slow or failing store can never change what this frame decides. The
|
|
45813
|
+
* durable half is fired behind the verdict.
|
|
45814
|
+
*/
|
|
45815
|
+
observe(observation, classEnabled) {
|
|
45816
|
+
const policy = evidencePolicyFor(observation.className);
|
|
45817
|
+
this.expire(observation.deviceId, observation.timestamp);
|
|
45818
|
+
const bindKey = observation.trackId === void 0 ? void 0 : `${observation.deviceId}:${observation.trackId}`;
|
|
45819
|
+
const candidates = this.candidatesFor(observation.deviceId);
|
|
45820
|
+
let row;
|
|
45821
|
+
const boundKey = bindKey === void 0 ? void 0 : this.binding.get(bindKey);
|
|
45822
|
+
if (boundKey !== void 0) row = this.ledger.get(boundKey);
|
|
45823
|
+
let overlap = row === void 0 ? 0 : iou(row.anchor, observation.bbox);
|
|
45824
|
+
let created = false;
|
|
45825
|
+
if (row === void 0) {
|
|
45826
|
+
const match = matchEvidenceCandidate(candidates, observation.className, observation.bbox, policy.agreeIou);
|
|
45827
|
+
if (match) {
|
|
45828
|
+
row = match.row;
|
|
45829
|
+
overlap = match.overlap;
|
|
45830
|
+
} else {
|
|
45831
|
+
this.anchorSeq += 1;
|
|
45832
|
+
row = newEvidenceRow(observation, `a${this.anchorSeq}`);
|
|
45833
|
+
overlap = 1;
|
|
45834
|
+
created = true;
|
|
45835
|
+
this.index(row);
|
|
45836
|
+
this.write(row);
|
|
45837
|
+
if (bindKey !== void 0) this.binding.set(bindKey, row.key);
|
|
45838
|
+
this.capDevice(observation.deviceId);
|
|
45839
|
+
return {
|
|
45840
|
+
row,
|
|
45841
|
+
verdict: evaluateStationaryEvidence(row, policy, classEnabled),
|
|
45842
|
+
promoted: false
|
|
45843
|
+
};
|
|
45844
|
+
}
|
|
45845
|
+
if (bindKey !== void 0) this.binding.set(bindKey, row.key);
|
|
45846
|
+
}
|
|
45847
|
+
const agrees = overlap >= policy.agreeIou;
|
|
45848
|
+
const next = foldObservation(row, observation, agrees, overlap);
|
|
45849
|
+
this.ledger.stage(next);
|
|
45850
|
+
this.dirty.add(next.key);
|
|
45851
|
+
if (created) this.capDevice(observation.deviceId);
|
|
45852
|
+
const verdict = evaluateStationaryEvidence(next, policy, classEnabled);
|
|
45853
|
+
if (verdict.kind !== "promote") return {
|
|
45854
|
+
row: next,
|
|
45855
|
+
verdict,
|
|
45856
|
+
promoted: false
|
|
45857
|
+
};
|
|
45858
|
+
this.forgetKey(next.key);
|
|
45859
|
+
return {
|
|
45860
|
+
row: next,
|
|
45861
|
+
verdict,
|
|
45862
|
+
promoted: true
|
|
45863
|
+
};
|
|
45864
|
+
}
|
|
45865
|
+
/** Persist the staged tallies. Called from the registry's 5-minute sweep. */
|
|
45866
|
+
async flush() {
|
|
45867
|
+
const keys = [...this.dirty];
|
|
45868
|
+
this.dirty.clear();
|
|
45869
|
+
let written = 0;
|
|
45870
|
+
for (const key of keys) {
|
|
45871
|
+
const row = this.ledger.get(key);
|
|
45872
|
+
if (!row) continue;
|
|
45873
|
+
await this.write(row);
|
|
45874
|
+
written += 1;
|
|
45875
|
+
}
|
|
45876
|
+
return written;
|
|
45877
|
+
}
|
|
45878
|
+
/** Drop a device's evidence wholesale (camera removed / analytics reset). */
|
|
45879
|
+
async clearDevice(deviceId) {
|
|
45880
|
+
for (const row of this.candidatesFor(deviceId)) await this.forgetKeyDurable(row.key);
|
|
45881
|
+
this.byDevice.delete(deviceId);
|
|
45882
|
+
for (const [bindKey] of this.binding) if (bindKey.startsWith(`${deviceId}:`)) this.binding.delete(bindKey);
|
|
45883
|
+
}
|
|
45884
|
+
/** Forget the bindings of tracks the tracker no longer carries, so the map
|
|
45885
|
+
* cannot grow for the process's lifetime on a busy camera. */
|
|
45886
|
+
retainTracks(deviceId, liveTrackIds) {
|
|
45887
|
+
const prefix = `${deviceId}:`;
|
|
45888
|
+
for (const bindKey of [...this.binding.keys()]) {
|
|
45889
|
+
if (!bindKey.startsWith(prefix)) continue;
|
|
45890
|
+
if (!liveTrackIds.has(bindKey.slice(prefix.length))) this.binding.delete(bindKey);
|
|
45891
|
+
}
|
|
45892
|
+
}
|
|
45893
|
+
forgetKey(key) {
|
|
45894
|
+
this.forgetKeyDurable(key);
|
|
45895
|
+
}
|
|
45896
|
+
async forgetKeyDurable(key) {
|
|
45897
|
+
const row = this.ledger.get(key);
|
|
45898
|
+
this.dirty.delete(key);
|
|
45899
|
+
if (row) this.byDevice.get(row.deviceId)?.delete(key);
|
|
45900
|
+
for (const [bindKey, boundTo] of this.binding) if (boundTo === key) this.binding.delete(bindKey);
|
|
45901
|
+
await this.ledger.forget(key);
|
|
45902
|
+
}
|
|
45903
|
+
async write(row) {
|
|
45904
|
+
this.dirty.delete(row.key);
|
|
45905
|
+
await this.ledger.put(row);
|
|
45906
|
+
}
|
|
45907
|
+
index(row) {
|
|
45908
|
+
let keys = this.byDevice.get(row.deviceId);
|
|
45909
|
+
if (!keys) {
|
|
45910
|
+
keys = /* @__PURE__ */ new Set();
|
|
45911
|
+
this.byDevice.set(row.deviceId, keys);
|
|
45912
|
+
}
|
|
45913
|
+
keys.add(row.key);
|
|
45914
|
+
}
|
|
45915
|
+
/** Drop candidates untouched for {@link EVIDENCE_WINDOW_MS}. Evidence
|
|
45916
|
+
* describes an object that can drive away; a stale candidate is not a
|
|
45917
|
+
* weaker one, it is one about a thing that is gone. */
|
|
45918
|
+
expire(deviceId, now) {
|
|
45919
|
+
for (const row of this.candidatesFor(deviceId)) {
|
|
45920
|
+
if (now - row.updatedAt <= 216e5) continue;
|
|
45921
|
+
this.logger.debug("stationary evidence expired — candidate untouched past the window", {
|
|
45922
|
+
tags: { deviceId },
|
|
45923
|
+
meta: {
|
|
45924
|
+
className: row.className,
|
|
45925
|
+
agreed: row.agreed,
|
|
45926
|
+
seen: row.seen,
|
|
45927
|
+
ageMs: now - row.updatedAt
|
|
45928
|
+
}
|
|
45929
|
+
});
|
|
45930
|
+
this.forgetKey(row.key);
|
|
45931
|
+
}
|
|
45932
|
+
}
|
|
45933
|
+
capDevice(deviceId) {
|
|
45934
|
+
const rows = this.candidatesFor(deviceId);
|
|
45935
|
+
if (rows.length <= 512) return;
|
|
45936
|
+
const excess = [...rows].sort((a, b) => a.updatedAt - b.updatedAt).slice(0, rows.length - 512);
|
|
45937
|
+
this.logger.info("stationary evidence capped — least-recently-touched candidates evicted", {
|
|
45938
|
+
tags: { deviceId },
|
|
45939
|
+
meta: {
|
|
45940
|
+
evicted: excess.length,
|
|
45941
|
+
cap: 512
|
|
45942
|
+
}
|
|
45943
|
+
});
|
|
45944
|
+
for (const row of excess) this.forgetKey(row.key);
|
|
45945
|
+
}
|
|
45946
|
+
};
|
|
45947
|
+
//#endregion
|
|
45246
45948
|
//#region src/pipeline-analytics/pipeline/stationary/stationary-registry.ts
|
|
45247
45949
|
/**
|
|
45248
45950
|
* @durable class=ledger owner=pipeline-analytics
|
|
@@ -55888,8 +56590,8 @@ function resolveTrackingSettings(raw) {
|
|
|
55888
56590
|
* with no drift. Guarded by a unit test asserting the equality.
|
|
55889
56591
|
*/
|
|
55890
56592
|
var StationarySettingsSchema = require_dist.object({
|
|
55891
|
-
/** Master switch. Off ⇒
|
|
55892
|
-
* parked object keeps spawning normal tracks). */
|
|
56593
|
+
/** Master switch. Off ⇒ this camera gathers no evidence and suppresses
|
|
56594
|
+
* nothing (every parked object keeps spawning normal tracks). */
|
|
55893
56595
|
enabled: require_dist.boolean().default(true),
|
|
55894
56596
|
/** IoU at/above which a detection is the same parked object, unmoved →
|
|
55895
56597
|
* suppress its spawn. `SUPPRESS_IOU`. */
|
|
@@ -55898,9 +56600,22 @@ var StationarySettingsSchema = require_dist.object({
|
|
|
55898
56600
|
* wake it) — the overlap gate that stops a different vehicle from waking a
|
|
55899
56601
|
* parked entry (the 617 flood fix). `WAKE_ASSOC_IOU`. */
|
|
55900
56602
|
wakeAssocIou: require_dist.number().min(.02).max(.5).default(WAKE_ASSOC_IOU),
|
|
55901
|
-
/** Recent-window stillness a track
|
|
55902
|
-
*
|
|
55903
|
-
|
|
56603
|
+
/** Recent-window stillness after which a track stops holding its motion
|
|
56604
|
+
* session open (also the minimum track age). Creates nothing — birth is
|
|
56605
|
+
* decided by repeated evidence. `SESSION_STILLNESS_WINDOW_MS`. */
|
|
56606
|
+
sessionStillnessWindowMs: require_dist.number().int().min(5e3).max(12e4).default(SESSION_STILLNESS_WINDOW_MS),
|
|
56607
|
+
/**
|
|
56608
|
+
* Let a motionless PERSON become a stationary entry on this camera.
|
|
56609
|
+
*
|
|
56610
|
+
* Off, and it is a measurement rather than caution. Replaying 24 h of person
|
|
56611
|
+
* observations from devices 617/1443/590/592 through the evidence ledger
|
|
56612
|
+
* produces 6-22 entries a day depending on how strictly it is tuned, and none
|
|
56613
|
+
* of them can be tuned to zero: device 592 alone yielded eleven candidates at
|
|
56614
|
+
* a perfect agreement ratio between 02:00 and 05:00. A stationary entry
|
|
56615
|
+
* SUPPRESSES its object, so a wrong one makes the camera blind to a person —
|
|
56616
|
+
* turn this on only for a camera where you know what is standing there.
|
|
56617
|
+
*/
|
|
56618
|
+
promotePersons: require_dist.boolean().default(false),
|
|
55904
56619
|
/** Observed-time TTL: retire an entry unconfirmed for this long (measured on
|
|
55905
56620
|
* frames-flowing time, not wall-clock). `ENTRY_TTL_MS`. */
|
|
55906
56621
|
entryTtlMs: require_dist.number().int().min(6e4).max(18e5).default(ENTRY_TTL_MS)
|
|
@@ -55919,7 +56634,8 @@ function resolveStationarySettings(raw) {
|
|
|
55919
56634
|
enabled: pick("enabled"),
|
|
55920
56635
|
suppressIou: pick("suppressIou"),
|
|
55921
56636
|
wakeAssocIou: pick("wakeAssocIou"),
|
|
55922
|
-
|
|
56637
|
+
sessionStillnessWindowMs: pick("sessionStillnessWindowMs"),
|
|
56638
|
+
promotePersons: pick("promotePersons"),
|
|
55923
56639
|
entryTtlMs: pick("entryTtlMs")
|
|
55924
56640
|
};
|
|
55925
56641
|
}
|
|
@@ -56444,17 +57160,24 @@ function buildDetectionSettingsSections() {
|
|
|
56444
57160
|
},
|
|
56445
57161
|
{
|
|
56446
57162
|
type: "slider",
|
|
56447
|
-
key: "
|
|
56448
|
-
label: "
|
|
56449
|
-
description: "How long (ms)
|
|
57163
|
+
key: "sessionStillnessWindowMs",
|
|
57164
|
+
label: "Session stillness window",
|
|
57165
|
+
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.",
|
|
56450
57166
|
min: 5e3,
|
|
56451
57167
|
max: 12e4,
|
|
56452
57168
|
step: 1e3,
|
|
56453
|
-
default: STATIONARY_DEFAULTS.
|
|
57169
|
+
default: STATIONARY_DEFAULTS.sessionStillnessWindowMs,
|
|
56454
57170
|
showValue: true,
|
|
56455
57171
|
unit: "s",
|
|
56456
57172
|
displayScale: 1e3
|
|
56457
57173
|
},
|
|
57174
|
+
{
|
|
57175
|
+
type: "boolean",
|
|
57176
|
+
key: "promotePersons",
|
|
57177
|
+
label: "Also park motionless people",
|
|
57178
|
+
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.",
|
|
57179
|
+
default: STATIONARY_DEFAULTS.promotePersons
|
|
57180
|
+
},
|
|
56458
57181
|
{
|
|
56459
57182
|
type: "slider",
|
|
56460
57183
|
key: "entryTtlMs",
|
|
@@ -56603,9 +57326,9 @@ function buildGlobalSettingsSchema(cap) {
|
|
|
56603
57326
|
}, {
|
|
56604
57327
|
type: "boolean",
|
|
56605
57328
|
key: "embeddingEnabled",
|
|
56606
|
-
label: "
|
|
56607
|
-
description: "
|
|
56608
|
-
default:
|
|
57329
|
+
label: "Object/face embedding dispatcher",
|
|
57330
|
+
description: "ON by default, and running on an untouched cluster. The dispatcher pulls the native key frame for a closed track, crops each qualifying detection and encodes it through the embedding-encoder capability, on the post-processing node only. Turning it OFF stops that CPU cost and changes nothing already indexed. Takes effect on addon restart.",
|
|
57331
|
+
default: true
|
|
56609
57332
|
}]
|
|
56610
57333
|
},
|
|
56611
57334
|
{
|
|
@@ -56699,27 +57422,6 @@ function buildGlobalSettingsSchema(cap) {
|
|
|
56699
57422
|
showValue: true,
|
|
56700
57423
|
unit: "s",
|
|
56701
57424
|
displayScale: 1e3
|
|
56702
|
-
},
|
|
56703
|
-
{
|
|
56704
|
-
type: "select",
|
|
56705
|
-
key: "mediaAttachPolicy",
|
|
56706
|
-
label: "Attach media to events",
|
|
56707
|
-
description: "Which events get a persisted crop.",
|
|
56708
|
-
default: "motion",
|
|
56709
|
-
options: [
|
|
56710
|
-
{
|
|
56711
|
-
value: "always",
|
|
56712
|
-
label: "Every event"
|
|
56713
|
-
},
|
|
56714
|
-
{
|
|
56715
|
-
value: "motion",
|
|
56716
|
-
label: "Only while motion is active"
|
|
56717
|
-
},
|
|
56718
|
-
{
|
|
56719
|
-
value: "never",
|
|
56720
|
-
label: "Never"
|
|
56721
|
-
}
|
|
56722
|
-
]
|
|
56723
57425
|
}
|
|
56724
57426
|
]
|
|
56725
57427
|
},
|
|
@@ -58947,6 +59649,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
58947
59649
|
* confirmation gate's bar there; one moving track clears it. Null until
|
|
58948
59650
|
* onInitialize. */
|
|
58949
59651
|
phantomCells = null;
|
|
59652
|
+
/** Repeated-sighting evidence — the ONLY thing that creates a stationary
|
|
59653
|
+
* entry. Two producers may feed it; only the motion session does today. */
|
|
59654
|
+
stationaryEvidence = null;
|
|
58950
59655
|
mediaStore = null;
|
|
58951
59656
|
/** The enrolled gallery's rows. Retained so the photo-enrollment bridge can
|
|
58952
59657
|
* reach them without going back through the cap surface. Null until S7. */
|
|
@@ -59691,10 +60396,15 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
59691
60396
|
}
|
|
59692
60397
|
return this._trackCloser;
|
|
59693
60398
|
}
|
|
59694
|
-
/** Master toggle for the migrated object/face EmbeddingDispatcher
|
|
59695
|
-
* ON — the enrichment-engine default was `embeddingEnabled:true`). Read at
|
|
60399
|
+
/** Master toggle for the migrated object/face EmbeddingDispatcher. Read at
|
|
59696
60400
|
* init; a change takes effect on addon restart (same convention as
|
|
59697
|
-
* `postProcessingNodeId`).
|
|
60401
|
+
* `postProcessingNodeId`).
|
|
60402
|
+
*
|
|
60403
|
+
* The fallback is {@link EMBEDDING_ENABLED_DEFAULT} — the SAME constant the
|
|
60404
|
+
* settings field declares. It is imported, not restated: this handle and
|
|
60405
|
+
* that field address one store key, and when they each carried their own
|
|
60406
|
+
* default they disagreed (`true` here, `false` on the form) and the form
|
|
60407
|
+
* reported the opposite of what the cluster was doing. */
|
|
59698
60408
|
_embeddingEnabledState = null;
|
|
59699
60409
|
get embeddingEnabledState() {
|
|
59700
60410
|
if (!this._embeddingEnabledState) this._embeddingEnabledState = this.state("embeddingEnabled", EmbeddingEnabledSchema, true);
|
|
@@ -60210,6 +60920,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
60210
60920
|
}
|
|
60211
60921
|
});
|
|
60212
60922
|
await this.stationaryRegistry.load();
|
|
60923
|
+
this.stationaryEvidence = new StationaryEvidenceLedger({
|
|
60924
|
+
store: api.settingsStore,
|
|
60925
|
+
logger: logger.child("StationaryEvidence")
|
|
60926
|
+
});
|
|
60927
|
+
await this.stationaryEvidence.declare();
|
|
60928
|
+
const evidenceLoaded = await this.stationaryEvidence.load();
|
|
60929
|
+
logger.info("stationary evidence loaded", { meta: { candidates: evidenceLoaded } });
|
|
60213
60930
|
this.phantomCells = new PhantomCellRegistry({
|
|
60214
60931
|
store: api.settingsStore,
|
|
60215
60932
|
logger: logger.child("PhantomCells")
|
|
@@ -61598,6 +62315,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
61598
62315
|
this.retentionSweepTimer = setInterval(() => {
|
|
61599
62316
|
this.sweepRetention();
|
|
61600
62317
|
this.stationaryRegistry?.sweep(Date.now());
|
|
62318
|
+
this.stationaryEvidence?.flush();
|
|
61601
62319
|
this.eventsOpsLog?.pruneBefore(Date.now() - OPS_LOG_RETENTION_MS);
|
|
61602
62320
|
}, RETENTION_SWEEP_INTERVAL_MS);
|
|
61603
62321
|
this.ltsFlushTimer = setInterval(() => {
|
|
@@ -62432,38 +63150,39 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
62432
63150
|
const stationarySettings = this.stationarySettingsFromCache(deviceId);
|
|
62433
63151
|
const packageSettings = this.packageDropSettingsFromCache(deviceId);
|
|
62434
63152
|
const packageClasses = new Set(packageSettings.packageDropClassFilter);
|
|
62435
|
-
if (source === "pipeline" && this.stationaryRegistry && stationarySettings.enabled) {
|
|
62436
|
-
const
|
|
62437
|
-
|
|
62438
|
-
|
|
62439
|
-
const
|
|
62440
|
-
|
|
62441
|
-
|
|
63153
|
+
if (source === "pipeline" && this.stationaryRegistry && this.stationaryEvidence && stationarySettings.enabled) {
|
|
63154
|
+
const evidence = this.stationaryEvidence;
|
|
63155
|
+
for (const t of result.tracked) {
|
|
63156
|
+
if (packageClasses.has(t.className)) continue;
|
|
63157
|
+
const classEnabled = t.className === "person" ? stationarySettings.promotePersons : evidencePolicyFor(t.className).promotesByDefault;
|
|
63158
|
+
const active = this.trackStore?.peekActive(t.trackId) ?? null;
|
|
63159
|
+
const observedLabel = active === null ? void 0 : displayLabel(active);
|
|
63160
|
+
const observedKeyFrame = this.residents.keyFrameKey(t.trackId);
|
|
63161
|
+
const observation = {
|
|
63162
|
+
deviceId,
|
|
63163
|
+
className: t.className,
|
|
63164
|
+
bbox: { ...t.bbox },
|
|
63165
|
+
timestamp: result.timestamp,
|
|
63166
|
+
source: "session",
|
|
63167
|
+
trackId: t.trackId,
|
|
63168
|
+
...observedKeyFrame !== void 0 ? { keyFrameMediaKey: observedKeyFrame } : {},
|
|
63169
|
+
...observedLabel !== void 0 ? { label: observedLabel } : {}
|
|
62442
63170
|
};
|
|
62443
|
-
|
|
62444
|
-
|
|
62445
|
-
|
|
62446
|
-
|
|
62447
|
-
|
|
62448
|
-
|
|
62449
|
-
|
|
62450
|
-
|
|
62451
|
-
|
|
62452
|
-
|
|
62453
|
-
|
|
62454
|
-
|
|
62455
|
-
|
|
62456
|
-
key,
|
|
62457
|
-
processor,
|
|
62458
|
-
track: t,
|
|
62459
|
-
firstSeen: active.firstSeen,
|
|
62460
|
-
label: displayLabel(active),
|
|
62461
|
-
frameWidth: result.frameWidth,
|
|
62462
|
-
frameHeight: result.frameHeight,
|
|
62463
|
-
timestamp: result.timestamp
|
|
62464
|
-
});
|
|
62465
|
-
}
|
|
63171
|
+
const outcome = evidence.observe(observation, classEnabled);
|
|
63172
|
+
if (outcome === void 0 || !outcome.promoted) continue;
|
|
63173
|
+
this.birthStationaryFromEvidence({
|
|
63174
|
+
deviceId,
|
|
63175
|
+
key,
|
|
63176
|
+
processor,
|
|
63177
|
+
track: t,
|
|
63178
|
+
row: outcome.row,
|
|
63179
|
+
frameWidth: result.frameWidth,
|
|
63180
|
+
frameHeight: result.frameHeight,
|
|
63181
|
+
timestamp: result.timestamp,
|
|
63182
|
+
firstSeen: active === null ? outcome.row.firstAgreedAt : active.firstSeen
|
|
63183
|
+
});
|
|
62466
63184
|
}
|
|
63185
|
+
evidence.retainTracks(deviceId, currentTrackIds);
|
|
62467
63186
|
}
|
|
62468
63187
|
if (source === "pipeline") this.runPackageStillness({
|
|
62469
63188
|
deviceId,
|
|
@@ -62756,7 +63475,22 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
62756
63475
|
}
|
|
62757
63476
|
overlayDetections = frame.detections;
|
|
62758
63477
|
}
|
|
62759
|
-
const
|
|
63478
|
+
const stillnessConfig = {
|
|
63479
|
+
...DEFAULT_SESSION_STILLNESS_CONFIG,
|
|
63480
|
+
windowMs: this.stationarySettingsFromCache(deviceId).sessionStillnessWindowMs
|
|
63481
|
+
};
|
|
63482
|
+
const stillnessDims = this.lastFrameDimsByDevice.get(deviceId);
|
|
63483
|
+
const stillnessDiag = stillnessDims && stillnessDims.w > 0 && stillnessDims.h > 0 ? Math.hypot(stillnessDims.w, stillnessDims.h) : 0;
|
|
63484
|
+
const hasLiveTrack = result.tracked.some((t) => {
|
|
63485
|
+
const active = this.trackStore?.peekActive(t.trackId);
|
|
63486
|
+
if (!active) return true;
|
|
63487
|
+
return !trackIsStill({
|
|
63488
|
+
positions: active.positions,
|
|
63489
|
+
referenceDiagonalPx: stillnessDiag,
|
|
63490
|
+
now: result.timestamp,
|
|
63491
|
+
config: stillnessConfig
|
|
63492
|
+
}).still;
|
|
63493
|
+
});
|
|
62760
63494
|
this.ctx.eventBus.emit({
|
|
62761
63495
|
id: `pa-${(0, node_crypto.randomUUID)()}`,
|
|
62762
63496
|
timestamp: new Date(result.timestamp),
|
|
@@ -62772,7 +63506,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
62772
63506
|
frameWidth: result.frameWidth,
|
|
62773
63507
|
frameHeight: result.frameHeight,
|
|
62774
63508
|
detections: overlayDetections,
|
|
62775
|
-
|
|
63509
|
+
hasLiveTrack
|
|
62776
63510
|
}
|
|
62777
63511
|
});
|
|
62778
63512
|
}
|
|
@@ -63039,9 +63773,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
63039
63773
|
/**
|
|
63040
63774
|
* Count SHORT + MOTIONLESS track closes per frame cell.
|
|
63041
63775
|
*
|
|
63042
|
-
* The stationary registry
|
|
63043
|
-
*
|
|
63044
|
-
*
|
|
63776
|
+
* The stationary registry answers a different question (is this object parked,
|
|
63777
|
+
* and should it be suppressed?). This one raises a spawn bar. The dead
|
|
63778
|
+
* ornamental grass on device 615 produced tracks of 3.8 s,
|
|
63045
63779
|
* 4.1 s, 6.5 s and 19.0 s with 0-2.5 px of total displacement, roughly
|
|
63046
63780
|
* fifteen of them in a day, all at the same spot. Each one is individually
|
|
63047
63781
|
* innocent; the RECURRENCE is the signal, and nothing survives a track death
|
|
@@ -64578,29 +65312,41 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
64578
65312
|
* track-OWNED entity (both rows carry a `trackId`) and it aged crops out from
|
|
64579
65313
|
* under tracks still well inside their own window. Only the capacity bound
|
|
64580
65314
|
* remains (D61).
|
|
65315
|
+
*
|
|
65316
|
+
* The cap is the OPERATOR'S, not the schema default. `bufferMaxPerDevice` is
|
|
65317
|
+
* offered on the settings form and this sweep read `FACE_DEFAULTS` instead,
|
|
65318
|
+
* so an override was a knob that wrote a store nobody read — the D62 shape.
|
|
65319
|
+
* The suggested tier is DERIVED from whatever value is in force
|
|
65320
|
+
* (`deriveSuggestedBufferCap`), so the two can never disagree.
|
|
64581
65321
|
*/
|
|
64582
65322
|
async holdBufferCaps() {
|
|
64583
65323
|
const mediaStore = this.mediaStore;
|
|
64584
65324
|
if (!mediaStore) return;
|
|
65325
|
+
const bufferMaxPerDevice = await this.resolveBufferCap();
|
|
65326
|
+
const maxSuggestedPerDevice = deriveSuggestedBufferCap(bufferMaxPerDevice);
|
|
64585
65327
|
if (this.faceStore) try {
|
|
64586
|
-
const deletedFaceIds = await this.faceStore.pruneCapOverflow({
|
|
65328
|
+
const deletedFaceIds = await this.faceStore.pruneCapOverflow({
|
|
65329
|
+
maxPerDevice: bufferMaxPerDevice,
|
|
65330
|
+
maxSuggestedPerDevice
|
|
65331
|
+
});
|
|
64587
65332
|
if (deletedFaceIds.length > 0) {
|
|
64588
65333
|
await mediaStore.deleteForOwner("face", deletedFaceIds);
|
|
64589
65334
|
this.ctx.logger.info("face buffer capacity prune", { meta: {
|
|
64590
65335
|
deleted: deletedFaceIds.length,
|
|
64591
|
-
cap:
|
|
65336
|
+
cap: bufferMaxPerDevice,
|
|
65337
|
+
suggestedCap: maxSuggestedPerDevice
|
|
64592
65338
|
} });
|
|
64593
65339
|
}
|
|
64594
65340
|
} catch (err) {
|
|
64595
65341
|
this.ctx.logger.debug("face buffer cap prune failed", { meta: { error: String(err) } });
|
|
64596
65342
|
}
|
|
64597
65343
|
if (this.plateStore) try {
|
|
64598
|
-
const deletedPlateIds = await this.plateStore.pruneCapOverflow({ maxPerDevice:
|
|
65344
|
+
const deletedPlateIds = await this.plateStore.pruneCapOverflow({ maxPerDevice: bufferMaxPerDevice });
|
|
64599
65345
|
if (deletedPlateIds.length > 0) {
|
|
64600
65346
|
await mediaStore.deleteForOwner("plate", deletedPlateIds);
|
|
64601
65347
|
this.ctx.logger.info("plate buffer capacity prune", { meta: {
|
|
64602
65348
|
deleted: deletedPlateIds.length,
|
|
64603
|
-
cap:
|
|
65349
|
+
cap: bufferMaxPerDevice
|
|
64604
65350
|
} });
|
|
64605
65351
|
}
|
|
64606
65352
|
} catch (err) {
|
|
@@ -64608,6 +65354,28 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
64608
65354
|
}
|
|
64609
65355
|
}
|
|
64610
65356
|
/**
|
|
65357
|
+
* The buffer capacity actually in force: the GLOBAL setting, falling back to
|
|
65358
|
+
* the schema default when the store cannot be read.
|
|
65359
|
+
*
|
|
65360
|
+
* Global-only on purpose. The sweep is one pass over the whole collection and
|
|
65361
|
+
* a per-device cascade would mean one settings read per camera on every tick,
|
|
65362
|
+
* for a knob whose whole job is bounding a table. A per-camera capacity is a
|
|
65363
|
+
* decision nobody has asked for; the fallback here is the same number the
|
|
65364
|
+
* sweep used unconditionally before, so a settings outage cannot make the
|
|
65365
|
+
* bound stricter than it has ever been.
|
|
65366
|
+
*/
|
|
65367
|
+
async resolveBufferCap() {
|
|
65368
|
+
try {
|
|
65369
|
+
return resolveFaceSettings(await this.resolveGlobalStore()).bufferMaxPerDevice;
|
|
65370
|
+
} catch (err) {
|
|
65371
|
+
this.ctx.logger.debug("face buffer cap: global settings unreadable, using the default", { meta: {
|
|
65372
|
+
error: String(err),
|
|
65373
|
+
cap: FACE_DEFAULTS.bufferMaxPerDevice
|
|
65374
|
+
} });
|
|
65375
|
+
return FACE_DEFAULTS.bufferMaxPerDevice;
|
|
65376
|
+
}
|
|
65377
|
+
}
|
|
65378
|
+
/**
|
|
64611
65379
|
* Ingest a native (onboard) camera detection push and run it through
|
|
64612
65380
|
* the SAME analysis layers as the ML pipeline, tagged `source:
|
|
64613
65381
|
* 'onboard'`. Flag-only pushes (no bbox) can't be zoned/tracked and are
|
|
@@ -64894,29 +65662,41 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
64894
65662
|
});
|
|
64895
65663
|
}
|
|
64896
65664
|
}
|
|
64897
|
-
|
|
64898
|
-
|
|
64899
|
-
|
|
64900
|
-
|
|
64901
|
-
|
|
65665
|
+
/**
|
|
65666
|
+
* Turn a candidate whose evidence is sufficient into a registry entry.
|
|
65667
|
+
*
|
|
65668
|
+
* The BOX, label and key-frame come from the evidence row — the best agreeing
|
|
65669
|
+
* sighting across every track that ever contributed — not from whichever
|
|
65670
|
+
* track happened to be alive on the tipping frame. That is the point of the
|
|
65671
|
+
* ledger: the object outlives its tracks, so the entry must be built from
|
|
65672
|
+
* what was learned about the object, not from the last one to see it.
|
|
65673
|
+
*/
|
|
65674
|
+
birthStationaryFromEvidence(input) {
|
|
65675
|
+
const { deviceId, key, processor, track, row, firstSeen, frameWidth, frameHeight, timestamp } = input;
|
|
65676
|
+
const label = row.label;
|
|
65677
|
+
const keyFrameMediaKey = row.keyFrameMediaKey ?? this.residents.keyFrameKey(track.trackId);
|
|
65678
|
+
const plausible = isPlausibleThumbnailBox(row.bestBbox, frameWidth, frameHeight);
|
|
65679
|
+
if (!plausible) this.ctx.logger.info("stationary birth rejected (near-full-frame box)", {
|
|
64902
65680
|
tags: { deviceId },
|
|
64903
65681
|
meta: {
|
|
64904
65682
|
trackId: track.trackId,
|
|
64905
|
-
className:
|
|
64906
|
-
bbox:
|
|
65683
|
+
className: row.className,
|
|
65684
|
+
bbox: row.bestBbox,
|
|
65685
|
+
agreed: row.agreed,
|
|
65686
|
+
seen: row.seen
|
|
64907
65687
|
}
|
|
64908
65688
|
});
|
|
64909
65689
|
const entry = {
|
|
64910
65690
|
id: (0, node_crypto.randomUUID)(),
|
|
64911
65691
|
deviceId,
|
|
64912
|
-
className:
|
|
64913
|
-
bbox: { ...
|
|
65692
|
+
className: row.className,
|
|
65693
|
+
bbox: { ...row.bestBbox },
|
|
64914
65694
|
frameWidth,
|
|
64915
65695
|
frameHeight,
|
|
64916
65696
|
firstSeenAt: firstSeen,
|
|
64917
65697
|
becameStationaryAt: timestamp,
|
|
64918
65698
|
lastConfirmedAt: timestamp,
|
|
64919
|
-
sourceTrackId: track.trackId,
|
|
65699
|
+
sourceTrackId: row.sourceTrackId ?? track.trackId,
|
|
64920
65700
|
...label !== void 0 ? { label } : {},
|
|
64921
65701
|
...keyFrameMediaKey !== void 0 ? { keyFrameMediaKey } : {}
|
|
64922
65702
|
};
|
|
@@ -64954,12 +65734,19 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
64954
65734
|
this.detailDispatcher?.onTrackEnded(deviceId, track.trackId);
|
|
64955
65735
|
this.overlayState.onTrackEnded(deviceId, track.trackId);
|
|
64956
65736
|
this.lastActiveTrackIds.get(key)?.delete(track.trackId);
|
|
64957
|
-
this.ctx.logger.info("
|
|
65737
|
+
this.ctx.logger.info("stationary entry born from repeated evidence", {
|
|
64958
65738
|
tags: { deviceId },
|
|
64959
65739
|
meta: {
|
|
64960
65740
|
trackId: track.trackId,
|
|
64961
|
-
className:
|
|
65741
|
+
className: row.className,
|
|
64962
65742
|
entryId: entry.id,
|
|
65743
|
+
agreed: row.agreed,
|
|
65744
|
+
seen: row.seen,
|
|
65745
|
+
agreementRatio: row.seen > 0 ? Number((row.agreed / row.seen).toFixed(3)) : 0,
|
|
65746
|
+
spanMs: row.lastAgreedAt - row.firstAgreedAt,
|
|
65747
|
+
sessionAgreed: row.sessionAgreed,
|
|
65748
|
+
burstAgreed: row.burstAgreed,
|
|
65749
|
+
plausible,
|
|
64963
65750
|
...label ? { label } : {}
|
|
64964
65751
|
}
|
|
64965
65752
|
});
|
|
@@ -65401,6 +66188,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
65401
66188
|
this.residents.closeDevice(input.deviceId);
|
|
65402
66189
|
this.stationaryRegistry?.clearDevice(input.deviceId);
|
|
65403
66190
|
this.phantomCells?.clearDevice(input.deviceId);
|
|
66191
|
+
this.stationaryEvidence?.clearDevice(input.deviceId);
|
|
65404
66192
|
this.packageBackground?.clearDevice(input.deviceId);
|
|
65405
66193
|
this.overlayState.clearDevice(input.deviceId);
|
|
65406
66194
|
this.overlaySynthesisWarnAt.delete(input.deviceId);
|
|
@@ -66851,6 +67639,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
66851
67639
|
async wipeAllAnalytics(input) {
|
|
66852
67640
|
await this.stationaryRegistry?.clearDevice(input.deviceId);
|
|
66853
67641
|
await this.phantomCells?.clearDevice(input.deviceId);
|
|
67642
|
+
await this.stationaryEvidence?.clearDevice(input.deviceId);
|
|
66854
67643
|
await this.packageBackground?.clearDevice(input.deviceId);
|
|
66855
67644
|
return this.pruneTracksBefore({
|
|
66856
67645
|
deviceId: input.deviceId,
|