@camstack/addon-post-analysis 1.2.218 → 1.2.220
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-CbQHenLE.js → dist-Bw4aTXFP.js} +86 -29
- package/dist/{dist-BqkhuC85.mjs → dist-DWXR9KNe.mjs} +81 -24
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-JPlVJb-8.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Bfm6wOeY.mjs} +3 -3
- package/dist/pipeline-analytics/{hostInit-CqdkxAhm.mjs → hostInit-CbC3Ttww.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +572 -160
- package/dist/pipeline-analytics/index.mjs +572 -160
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as VISIT_MERGE_GAP_MS, $t as object, A as NcRulePatchSchema, Bt as CamProfileSchema, C as NC_ALARM_SYSTEM_EVENT_KINDS, D as NC_TAXONOMY, Dt as readDeviceStateFrom, Et as plateGalleryCapability, F as NcSnoozeSchema, Ft as vectorDimFromBase64, G as SCENE_DEFAULT_UNCOVERED_POLICY, Gt as nodePin, H as RetrainStatusSchema, Ht as createEvent, I as NcSnoozeSuppressedSchema, It as videoclipsCapability, J as TIMELAPSE_DENSE_FLOOR_SEC, Jt as array, K as SCENE_DIVERGED, Kt as sleep$1, L as NcSystemEventKindSchema, Lt as zoneAnalyticsCapability, M as NcRuleTargetSchema, Mt as storageOccupancyCapability, N as NcScheduleSchema, Nt as subKindsOf, O as NcConditionDescriptorSchema, Ot as readTimelapseGeneratedAt, P as NcSnoozeInputSchema, Pt as systemEventFilterApplies, Q as TrackSourceSchema, Qt as number, R as NcTaxonomySchema, Rt as errMsg$1, S as MediaFileKindEnum, St as notificationRulesCapability, T as NC_DEFAULT_SNOOZE_MINUTES, Tt as pipelineAnalyticsCapability, Ut as hydrateSchema, V as RECORDING_EXPORT_MAX_READ_BYTES, Vt as DeviceType, W as SCENE_DEFAULT_ANCHOR_THRESHOLD, Wt as isDeviceScopedCap, X as TimelapseRulePatchSchema, Xt as discriminatedUnion, Y as TimelapseRuleInputSchema, Yt as boolean, Z as TimelapseRuleSchema, Zt as literal, _ as
|
|
1
|
+
import { $ as VISIT_MERGE_GAP_MS, $t as object, A as NcRulePatchSchema, Bt as CamProfileSchema, C as NC_ALARM_SYSTEM_EVENT_KINDS, D as NC_TAXONOMY, Dt as readDeviceStateFrom, Et as plateGalleryCapability, F as NcSnoozeSchema, Ft as vectorDimFromBase64, G as SCENE_DEFAULT_UNCOVERED_POLICY, Gt as nodePin, H as RetrainStatusSchema, Ht as createEvent, I as NcSnoozeSuppressedSchema, It as videoclipsCapability, J as TIMELAPSE_DENSE_FLOOR_SEC, Jt as array, K as SCENE_DIVERGED, Kt as sleep$1, L as NcSystemEventKindSchema, Lt as zoneAnalyticsCapability, M as NcRuleTargetSchema, Mt as storageOccupancyCapability, N as NcScheduleSchema, Nt as subKindsOf, O as NcConditionDescriptorSchema, Ot as readTimelapseGeneratedAt, P as NcSnoozeInputSchema, Pt as systemEventFilterApplies, Q as TrackSourceSchema, Qt as number, R as NcTaxonomySchema, Rt as errMsg$1, S as MediaFileKindEnum, St as notificationRulesCapability, T as NC_DEFAULT_SNOOZE_MINUTES, Tt as pipelineAnalyticsCapability, Ut as hydrateSchema, V as RECORDING_EXPORT_MAX_READ_BYTES, Vt as DeviceType, W as SCENE_DEFAULT_ANCHOR_THRESHOLD, Wt as isDeviceScopedCap, X as TimelapseRulePatchSchema, Xt as discriminatedUnion, Y as TimelapseRuleInputSchema, Yt as boolean, Z as TimelapseRuleSchema, Zt as literal, _ as FailureCounters, _t as isDetectionMacroClass, a as CLUSTER_MODEL_SCOPED_STEPS, at as buildEventKindDescriptor, b as MAX_BIRTH_DECISION_RECORDS, bt as kebabToCamel, ct as defineCustomActions, dt as encodeVectorBase64, en as partialRecord, et as addonWidgetsSourceCapability, f as DeclaredDevices, ft as evaluateSensorEdge, g as FULL_IMAGE_BBOX, h as FIRST_LEVEL_MACRO_CLASSES, ht as failureContributionCapability, i as BirthDecisionRecordSchema, in as EventCategory, it as audioModeOf, j as NcRuleSchema, jt as sceneMonitorCapability, k as NcRuleInputSchema, kt as resolveLocationMode, lt as deriveRecordingMode, m as EVENT_PAD_MS, mt as faceGalleryCapability, n as ArchivedDebugNoteSchema, nn as string, nt as assertTimelapseCadences, o as COCO_TO_MACRO, ot as cosineSimilarity$1, p as EVENT_KIND_BY_CAP, pt as evictionPolicyOfLocation, q as SceneMonitorSchema, qt as _enum, r as BaseDevice, rn as unknown, rt as audioMetricsCapability, s as DEFAULT_EVENT_COLOR, st as customAction, t as AUDIO_MACRO_LABELS, tn as record, tt as alarmPanelCapability, u as DETECTION_MACRO_CLASSES, v as LabelAttributionSchema, vt as isScheduleActive, w as NC_CONDITION_CATALOG, wt as pickClusterStepModels, xt as mayWriteToLocation, y as MAX_ARCHIVED_DEBUG_NOTES, yt as isSourceCap, z as OpsLogEntrySchema, zt as BaseAddon } from "../dist-DWXR9KNe.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";
|
|
@@ -8334,8 +8334,55 @@ function incomingFromSystemEvent(event, lookup) {
|
|
|
8334
8334
|
default: return null;
|
|
8335
8335
|
}
|
|
8336
8336
|
}
|
|
8337
|
+
/** The shells whose update has its own sentence. Anything else gets the plain
|
|
8338
|
+
* one: a body that names a shell nobody observed is worse than a vague body. */
|
|
8339
|
+
var WRAPPER_BODY_KINDS = new Set([
|
|
8340
|
+
"docker",
|
|
8341
|
+
"electron",
|
|
8342
|
+
"native"
|
|
8343
|
+
]);
|
|
8344
|
+
/**
|
|
8345
|
+
* The SHELL announcement (D437) — the container / app bundle a node runs
|
|
8346
|
+
* inside is behind the release contract.
|
|
8347
|
+
*
|
|
8348
|
+
* It is deliberately NOT folded into `server-update-available`: the versions
|
|
8349
|
+
* here are the shell's (a seed closure, an app bundle), not the running code's,
|
|
8350
|
+
* and on 2026-08-02 the running code was perfectly current on a hub whose image
|
|
8351
|
+
* was 13 days old from a repository nothing rebuilt. It also mints no button —
|
|
8352
|
+
* no process can replace the shell it is running inside — so the BODY is the
|
|
8353
|
+
* whole delivery, and it is chosen by `wrapperKind` so a docker operator is
|
|
8354
|
+
* handed the exact command instead of a button that cannot exist.
|
|
8355
|
+
*/
|
|
8356
|
+
function incomingFromWrapperUpdate(facts, data) {
|
|
8357
|
+
const packages = updatePackages(data);
|
|
8358
|
+
if (packages.length === 0) return null;
|
|
8359
|
+
const head = packages[0];
|
|
8360
|
+
const nodeId = head.nodeId ?? stringField(data, "nodeId");
|
|
8361
|
+
const wrapperKind = stringField(data, "wrapperKind");
|
|
8362
|
+
const bodySuffix = wrapperKind !== void 0 && WRAPPER_BODY_KINDS.has(wrapperKind) ? wrapperKind : null;
|
|
8363
|
+
return systemSubject({
|
|
8364
|
+
facts,
|
|
8365
|
+
kind: "wrapper-update-available",
|
|
8366
|
+
subject: `wrapper:${nodeId ?? "hub"}`,
|
|
8367
|
+
titleKey: "system.wrapper-update-available.title",
|
|
8368
|
+
bodyKey: bodySuffix === null ? "system.wrapper-update-available.body" : `system.wrapper-update-available.body.${bodySuffix}`,
|
|
8369
|
+
textParams: {
|
|
8370
|
+
currentVersion: head.currentVersion,
|
|
8371
|
+
latestVersion: head.latestVersion,
|
|
8372
|
+
...nodeId !== void 0 ? { nodeId } : {}
|
|
8373
|
+
},
|
|
8374
|
+
textCount: 1,
|
|
8375
|
+
packageName: head.packageName,
|
|
8376
|
+
packageNames: [head.packageName],
|
|
8377
|
+
...nodeId !== void 0 ? {
|
|
8378
|
+
nodeId,
|
|
8379
|
+
nodeIds: [nodeId]
|
|
8380
|
+
} : {}
|
|
8381
|
+
});
|
|
8382
|
+
}
|
|
8337
8383
|
function incomingFromUpdateAvailable(facts, data) {
|
|
8338
8384
|
const target = stringField(data, "target");
|
|
8385
|
+
if (target === "wrapper") return incomingFromWrapperUpdate(facts, data);
|
|
8339
8386
|
if (target !== "addon" && target !== "server") return null;
|
|
8340
8387
|
const packages = updatePackages(data);
|
|
8341
8388
|
if (packages.length === 0) return null;
|
|
@@ -9445,7 +9492,7 @@ var CAMERA_DEVICE_TYPE = "camera";
|
|
|
9445
9492
|
/** Kinds whose action is "go and apply an ADDON update". */
|
|
9446
9493
|
var ADDON_UPDATE_KINDS = new Set(["addon-update-available"]);
|
|
9447
9494
|
/** Kinds whose action is "go and apply a NODE's own (`@camstack/server`) update". */
|
|
9448
|
-
var NODE_UPDATE_KINDS = new Set(["server-update-available"]);
|
|
9495
|
+
var NODE_UPDATE_KINDS = new Set(["server-update-available", "wrapper-update-available"]);
|
|
9449
9496
|
/**
|
|
9450
9497
|
* Kinds whose action is "go and look at this device's stream".
|
|
9451
9498
|
*
|
|
@@ -10010,6 +10057,11 @@ var en_default = {
|
|
|
10010
10057
|
"system.server-update-available.body": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}})",
|
|
10011
10058
|
"system.server-update-available.body.onNode": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on node {{nodeId}}",
|
|
10012
10059
|
"system.server-update-available.body.onNodes": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on {{nodes}}",
|
|
10060
|
+
"system.wrapper-update-available.title": "The container or app on {{nodeId}} is out of date",
|
|
10061
|
+
"system.wrapper-update-available.body": "The shell this node runs inside carries {{currentVersion}}; the release contract delivers {{latestVersion}}. Updating the server keeps the code current but never replaces the shell.",
|
|
10062
|
+
"system.wrapper-update-available.body.docker": "This node's image carries seed {{currentVersion}}; the release contract delivers {{latestVersion}}. A container cannot replace its own image — recreate it from the contract image: docker compose pull && docker compose up -d",
|
|
10063
|
+
"system.wrapper-update-available.body.electron": "The desktop app on {{nodeId}} carries {{currentVersion}}; the release contract delivers {{latestVersion}}. The app downloads the new build on its own and installs it the next time it quits.",
|
|
10064
|
+
"system.wrapper-update-available.body.native": "The installed app on {{nodeId}} carries {{currentVersion}}; the release contract delivers {{latestVersion}}. Reinstall it from the current release to converge.",
|
|
10013
10065
|
"system.addon-updated.title": {
|
|
10014
10066
|
"one": "{{count}} addon updated",
|
|
10015
10067
|
"other": "{{count}} addons updated"
|
|
@@ -10265,6 +10317,11 @@ var it_default = {
|
|
|
10265
10317
|
"system.server-update-available.body": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}})",
|
|
10266
10318
|
"system.server-update-available.body.onNode": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) sul nodo {{nodeId}}",
|
|
10267
10319
|
"system.server-update-available.body.onNodes": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) su {{nodes}}",
|
|
10320
|
+
"system.wrapper-update-available.title": "Il contenitore o l'app di {{nodeId}} è indietro",
|
|
10321
|
+
"system.wrapper-update-available.body": "Il guscio in cui gira questo nodo porta {{currentVersion}}; il contratto di rilascio consegna {{latestVersion}}. Aggiornare il server tiene il codice aggiornato ma non sostituisce mai il guscio.",
|
|
10322
|
+
"system.wrapper-update-available.body.docker": "L'immagine di questo nodo porta il seed {{currentVersion}}; il contratto di rilascio consegna {{latestVersion}}. Un container non può sostituire la propria immagine — ricrealo dall'immagine del contratto: docker compose pull && docker compose up -d",
|
|
10323
|
+
"system.wrapper-update-available.body.electron": "L'app desktop su {{nodeId}} porta {{currentVersion}}; il contratto di rilascio consegna {{latestVersion}}. L'app scarica da sola la nuova build e la installa alla prossima chiusura.",
|
|
10324
|
+
"system.wrapper-update-available.body.native": "L'app installata su {{nodeId}} porta {{currentVersion}}; il contratto di rilascio consegna {{latestVersion}}. Reinstallala dal rilascio corrente per allinearla.",
|
|
10268
10325
|
"system.addon-updated.title": {
|
|
10269
10326
|
"one": "{{count}} addon aggiornato",
|
|
10270
10327
|
"other": "{{count}} addon aggiornati"
|
|
@@ -19923,6 +19980,14 @@ function checkTimelapsePacing(input) {
|
|
|
19923
19980
|
unservedWindows: []
|
|
19924
19981
|
};
|
|
19925
19982
|
}
|
|
19983
|
+
//#endregion
|
|
19984
|
+
//#region src/notification-center/timelapse/timelapse-renderer.ts
|
|
19985
|
+
/** `part / whole` to two decimals — the shape a log line is read in. An empty
|
|
19986
|
+
* whole is 0, never NaN: a ratio nobody can compare is not a measurement. */
|
|
19987
|
+
function ratio(part, whole) {
|
|
19988
|
+
if (whole <= 0) return 0;
|
|
19989
|
+
return Math.round(part / whole * 100) / 100;
|
|
19990
|
+
}
|
|
19926
19991
|
/** Bound on the post-render pacing check. The check is best-effort by charter:
|
|
19927
19992
|
* a byte read that hangs must delay delivery by at most this, never sink it. */
|
|
19928
19993
|
var PACING_CHECK_TIMEOUT_MS = 2e4;
|
|
@@ -19946,25 +20011,6 @@ function profileCost(profile) {
|
|
|
19946
20011
|
const i = PROFILE_BY_COST.indexOf(profile);
|
|
19947
20012
|
return i === -1 ? PROFILE_BY_COST.length : i;
|
|
19948
20013
|
}
|
|
19949
|
-
/**
|
|
19950
|
-
* The CHEAPEST profile that has footage over the window.
|
|
19951
|
-
*
|
|
19952
|
-
* It used to be the profile with the MOST coverage, ties broken by name — and
|
|
19953
|
-
* that is a rule which picks `high` almost every time, because the high band is
|
|
19954
|
-
* the one that records continuously. On 2026-09-03 it did: a test render of a
|
|
19955
|
-
* ten-hour window chose `profile=high` at 97% coverage, which on this hub means
|
|
19956
|
-
* 4K read from a USB spinning disk. Decoding it made the machine stop answering
|
|
19957
|
-
* SSH and its own web UI for half an hour, and it had to be power-cycled.
|
|
19958
|
-
*
|
|
19959
|
-
* A timelapse is watched accelerated. Resolution buys nothing at 12 fps over a
|
|
19960
|
-
* night, and the cost difference between the bands is one to two orders of
|
|
19961
|
-
* magnitude in both bytes read and frames decoded. So there is no trade to
|
|
19962
|
-
* weigh: take the cheapest band that has anything, every time.
|
|
19963
|
-
*
|
|
19964
|
-
* Coverage no longer decides WHICH — only whether a band qualifies at all. A
|
|
19965
|
-
* band with less footage is still the right source; the gaps show as a shorter
|
|
19966
|
-
* video, not as a reason to read twenty times the bytes.
|
|
19967
|
-
*/
|
|
19968
20014
|
function pickProfile(ranges, window) {
|
|
19969
20015
|
const byProfile = /* @__PURE__ */ new Map();
|
|
19970
20016
|
for (const r of ranges) {
|
|
@@ -20211,18 +20257,20 @@ var TimelapseRenderer = class {
|
|
|
20211
20257
|
const finished = await this.awaitReady(queued, tags);
|
|
20212
20258
|
const published = await this.publish(finished.id, request, tags);
|
|
20213
20259
|
const downloadUrl = await this.ports.resolveDownloadUrl?.({ exportId: finished.id }).catch(() => null);
|
|
20260
|
+
const archive = await this.measureArchive(finished.id, request, chosen, tags);
|
|
20261
|
+
const deliveredRows = archive?.rows ?? availability.filter((r) => r.profile === chosen.profile);
|
|
20214
20262
|
await this.verifyPacing({
|
|
20215
20263
|
exportId: finished.id,
|
|
20216
20264
|
request,
|
|
20217
20265
|
dense,
|
|
20218
20266
|
subjects,
|
|
20219
|
-
availability:
|
|
20267
|
+
availability: deliveredRows,
|
|
20220
20268
|
tags
|
|
20221
20269
|
});
|
|
20222
20270
|
return {
|
|
20223
20271
|
exportId: finished.id,
|
|
20224
20272
|
profile: chosen.profile,
|
|
20225
|
-
coverage: chosen.coverage,
|
|
20273
|
+
coverage: archive?.coverage ?? chosen.coverage,
|
|
20226
20274
|
plan,
|
|
20227
20275
|
detections,
|
|
20228
20276
|
artifact: published.video,
|
|
@@ -20330,6 +20378,96 @@ var TimelapseRenderer = class {
|
|
|
20330
20378
|
throw new TimelapseRenderError("export-timeout", `export ${queued.id} timed out after ${this.timeoutMs}ms` + (pollFailures > 0 ? ` (${pollFailures} poll failures, last: ${lastPollError})` : ""));
|
|
20331
20379
|
}
|
|
20332
20380
|
/**
|
|
20381
|
+
* The window against the archive, measured AFTER the export and always said.
|
|
20382
|
+
*
|
|
20383
|
+
* Three figures, one line: the window's length, what the PLAN was cut from
|
|
20384
|
+
* (the availability read before the export) and what the ARCHIVE holds (the
|
|
20385
|
+
* same read after it — the export's verified walk has repaired the index the
|
|
20386
|
+
* calendar serves from by then). `plannedOfArchive` is the ratio D445 found
|
|
20387
|
+
* at 0.20 on camera 4374 and nothing reported; a healthy night reads 1.00
|
|
20388
|
+
* because both reads return the same rows.
|
|
20389
|
+
*
|
|
20390
|
+
* WHY THE THRESHOLD IS A SLACK AND NOT A RATIO. The two reads are the SAME
|
|
20391
|
+
* query against the SAME index, so on a healthy night they return the same
|
|
20392
|
+
* rows and the shortfall is exactly 0 — there is no distribution to pick a
|
|
20393
|
+
* percentile out of. It is non-zero only when something CHANGED the index
|
|
20394
|
+
* between them, and the export's verified walk is the only thing that does.
|
|
20395
|
+
* So the bar is not "how short is too short" (the robot's night was 0.20 and
|
|
20396
|
+
* a milder one would be just as wrong); it is only "is this a real change or
|
|
20397
|
+
* a boundary artefact". One artefact exists: the window is closed, but a
|
|
20398
|
+
* segment straddling its end can finalize while the export runs and extend
|
|
20399
|
+
* the last range by its own length. {@link COVERAGE_COMPLETE_SLACK_MS} (30 s)
|
|
20400
|
+
* is the slack the coverage figure already allows at exactly those edges,
|
|
20401
|
+
* and it is several segments wide. No second constant, and nothing between
|
|
20402
|
+
* "unchanged" and "the planner rendered from a short answer".
|
|
20403
|
+
*
|
|
20404
|
+
* Never throws. A re-read that fails is SAID, the plan's figure stands, and
|
|
20405
|
+
* every figure that needed the archive is `null` rather than a stand-in.
|
|
20406
|
+
*/
|
|
20407
|
+
async measureArchive(exportId, request, chosen, tags) {
|
|
20408
|
+
const { window } = request;
|
|
20409
|
+
const windowSec = Math.round((window.endMs - window.startMs) / 1e3);
|
|
20410
|
+
const plannedSec = Math.round(chosen.coverage.coveredMs / 1e3);
|
|
20411
|
+
let rows;
|
|
20412
|
+
try {
|
|
20413
|
+
rows = (await this.ports.getAvailability({
|
|
20414
|
+
deviceId: request.deviceId,
|
|
20415
|
+
fromMs: window.startMs,
|
|
20416
|
+
toMs: window.endMs,
|
|
20417
|
+
profile: chosen.profile
|
|
20418
|
+
})).ranges.filter((r) => r.profile === chosen.profile);
|
|
20419
|
+
} catch (err) {
|
|
20420
|
+
this.ports.logger.warn("timelapse coverage: archive re-read failed — the window is reported against the plan only", {
|
|
20421
|
+
tags,
|
|
20422
|
+
meta: {
|
|
20423
|
+
exportId,
|
|
20424
|
+
ruleId: request.ruleId,
|
|
20425
|
+
profile: chosen.profile,
|
|
20426
|
+
windowSec,
|
|
20427
|
+
plannedSec,
|
|
20428
|
+
plannedOfWindow: ratio(chosen.coverage.coveredMs, window.endMs - window.startMs),
|
|
20429
|
+
archiveSec: null,
|
|
20430
|
+
plannedOfArchive: null,
|
|
20431
|
+
archiveOfWindow: null,
|
|
20432
|
+
error: String(err)
|
|
20433
|
+
}
|
|
20434
|
+
});
|
|
20435
|
+
return null;
|
|
20436
|
+
}
|
|
20437
|
+
const coverage = footageCoverage(rows, window.startMs, window.endMs);
|
|
20438
|
+
const shortfallMs = coverage.coveredMs - chosen.coverage.coveredMs;
|
|
20439
|
+
const meta = {
|
|
20440
|
+
exportId,
|
|
20441
|
+
ruleId: request.ruleId,
|
|
20442
|
+
profile: chosen.profile,
|
|
20443
|
+
windowSec,
|
|
20444
|
+
plannedSec,
|
|
20445
|
+
archiveSec: Math.round(coverage.coveredMs / 1e3),
|
|
20446
|
+
/** What the operator ASKED for against what the file HOLDS — the ratio
|
|
20447
|
+
* the robot's night made someone file a report over: ten hours in,
|
|
20448
|
+
* 6.4 s of video out. Low is not by itself a defect (a lazy source is
|
|
20449
|
+
* legitimately idle most of a night), which is why it never gates. */
|
|
20450
|
+
plannedOfWindow: ratio(chosen.coverage.coveredMs, window.endMs - window.startMs),
|
|
20451
|
+
archiveOfWindow: ratio(coverage.coveredMs, window.endMs - window.startMs),
|
|
20452
|
+
/** The one that IS a defect: what the planner saw against what the
|
|
20453
|
+
* export's verified walk found. 0.20 on camera 4374, 1.00 when healthy. */
|
|
20454
|
+
plannedOfArchive: ratio(chosen.coverage.coveredMs, coverage.coveredMs),
|
|
20455
|
+
shortfallSec: Math.round(shortfallMs / 1e3)
|
|
20456
|
+
};
|
|
20457
|
+
if (shortfallMs > 3e4) this.ports.logger.warn("timelapse coverage: the plan was cut from less footage than the archive holds", {
|
|
20458
|
+
tags,
|
|
20459
|
+
meta
|
|
20460
|
+
});
|
|
20461
|
+
else this.ports.logger.info("timelapse coverage: the window against the archive", {
|
|
20462
|
+
tags,
|
|
20463
|
+
meta
|
|
20464
|
+
});
|
|
20465
|
+
return {
|
|
20466
|
+
rows,
|
|
20467
|
+
coverage
|
|
20468
|
+
};
|
|
20469
|
+
}
|
|
20470
|
+
/**
|
|
20333
20471
|
* Run the pacing check against the finished file, and SAY what it found.
|
|
20334
20472
|
*
|
|
20335
20473
|
* Never throws and never blocks past its bound: the check is telemetry about
|
|
@@ -25802,6 +25940,7 @@ function systemEventSubject(kind, scope) {
|
|
|
25802
25940
|
if (kind === "detection-blind") return `detection-blind:${scope.deviceId ?? 0}`;
|
|
25803
25941
|
if (kind === "addon-crash-loop") return `addon-crash:${scope.nodeId ?? "hub"}:${TEST_CRASHED_RUNNER}`;
|
|
25804
25942
|
if (kind === "addon-update-available" || kind === "server-update-available") return `package:${scope.packageName ?? "@camstack/server"}`;
|
|
25943
|
+
if (kind === "wrapper-update-available") return `wrapper:${scope.nodeId ?? "hub"}`;
|
|
25805
25944
|
if (kind === "addon-updated") return `packages:addon-updated:${scope.packageName ?? "pkg"}`;
|
|
25806
25945
|
if (kind === "server-updated") return `server-updated:${scope.nodeId ?? "hub"}`;
|
|
25807
25946
|
if (kind === "export-completed") return `export:${scope.deviceId ?? 0}`;
|
|
@@ -28351,6 +28490,34 @@ var MEDIA_INDEXES = [
|
|
|
28351
28490
|
"deviceId",
|
|
28352
28491
|
"sizeBytes"
|
|
28353
28492
|
]
|
|
28493
|
+
}),
|
|
28494
|
+
(
|
|
28495
|
+
/**
|
|
28496
|
+
* The per-LOCATION footprint — `COUNT(*)`/`SUM(sizeBytes) WHERE locationId
|
|
28497
|
+
* = ?`, and the legacy `locationId IS NULL` half of the same question. See
|
|
28498
|
+
* {@link MediaStore.footprintByLocation} (D388) and D447.
|
|
28499
|
+
*
|
|
28500
|
+
* **Why not `idx_media_location`.** It leads with `locationId`, so it finds
|
|
28501
|
+
* the range — and then, exactly as the `idx_media_device_size` note above
|
|
28502
|
+
* describes for `deviceId`, fetches the table row behind every entry to read
|
|
28503
|
+
* `sizeBytes`. Every row of the store is on SOME location, so the range is
|
|
28504
|
+
* the table: measured 2026-09-10/11 on `hub/sqlite-settings`, 4 128–6 962 ms
|
|
28505
|
+
* per statement, every five minutes (the occupancy refresh), all night —
|
|
28506
|
+
* `plan=["SEARCH … USING INDEX idx_media_location (locationId=?)"]` on every
|
|
28507
|
+
* WARN. That thread is synchronous, so every configuration read in the
|
|
28508
|
+
* cluster queued behind it: the orchestrator's `configMs=6640` at 23:26:11
|
|
28509
|
+
* is this statement's 6 002 ms at 23:26:10.
|
|
28510
|
+
*
|
|
28511
|
+
* With `sizeBytes` in the index the planner never touches the table
|
|
28512
|
+
* (`USING COVERING INDEX idx_media_location_size`), and the seal gate keeps
|
|
28513
|
+
* its own narrower index. Same shape, same one-off caveat as its two
|
|
28514
|
+
* siblings: ~30 bytes a row, one more b-tree per insert, and the first
|
|
28515
|
+
* `declareCollection` after this ships builds it over every existing row —
|
|
28516
|
+
* do not deploy it beside a running drain or seal.
|
|
28517
|
+
*/
|
|
28518
|
+
{
|
|
28519
|
+
name: "idx_media_location_size",
|
|
28520
|
+
columns: ["locationId", "sizeBytes"]
|
|
28354
28521
|
})
|
|
28355
28522
|
];
|
|
28356
28523
|
/** The storage location of a media row/record: its stamped `locationId`, or
|
|
@@ -33403,7 +33570,12 @@ var FaceGalleryProvider = class {
|
|
|
33403
33570
|
identityId: input.identityId
|
|
33404
33571
|
});
|
|
33405
33572
|
assigned += 1;
|
|
33406
|
-
} catch {
|
|
33573
|
+
} catch (err) {
|
|
33574
|
+
this.logger.error("assignFaces refused a face", { meta: {
|
|
33575
|
+
faceId,
|
|
33576
|
+
identityId: input.identityId,
|
|
33577
|
+
error: err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
|
33578
|
+
} });
|
|
33407
33579
|
failed.push(faceId);
|
|
33408
33580
|
}
|
|
33409
33581
|
return {
|
|
@@ -34785,6 +34957,21 @@ var SpawnBesideCoastingSampler = class {
|
|
|
34785
34957
|
this.counts.clear();
|
|
34786
34958
|
}
|
|
34787
34959
|
};
|
|
34960
|
+
/** Per-cause counter + sampling decision. One instance per tracker, so the
|
|
34961
|
+
* counts are per camera and per detection source. */
|
|
34962
|
+
var TrackLossSampler = class {
|
|
34963
|
+
counts = /* @__PURE__ */ new Map();
|
|
34964
|
+
/** The running count when this loss should be emitted, else `undefined`. */
|
|
34965
|
+
next(cause) {
|
|
34966
|
+
const count = (this.counts.get(cause) ?? 0) + 1;
|
|
34967
|
+
this.counts.set(cause, count);
|
|
34968
|
+
if (count <= 10) return count;
|
|
34969
|
+
return count % 50 === 0 ? count : void 0;
|
|
34970
|
+
}
|
|
34971
|
+
reset() {
|
|
34972
|
+
this.counts.clear();
|
|
34973
|
+
}
|
|
34974
|
+
};
|
|
34788
34975
|
/** Per-reason counter + sampling decision. One instance per tracker, so the
|
|
34789
34976
|
* counts are per camera and per detection source. */
|
|
34790
34977
|
var SpawnRefusalSampler = class {
|
|
@@ -34893,6 +35080,16 @@ var SortTracker = class SortTracker {
|
|
|
34893
35080
|
refusalSink = null;
|
|
34894
35081
|
/** Per-reason counters behind the sampled spawn-beside-coasting diagnostics. */
|
|
34895
35082
|
spawnBesideSampler = new SpawnBesideCoastingSampler();
|
|
35083
|
+
/**
|
|
35084
|
+
* Deliveries to {@link update} since this tracker was constructed. One per
|
|
35085
|
+
* frame the pipeline actually hands over — which is exactly what "the camera
|
|
35086
|
+
* was looking" means, and is NOT derivable from wall clock: a quiet camera
|
|
35087
|
+
* and a busy one span the same seconds (D438).
|
|
35088
|
+
*/
|
|
35089
|
+
frameSeq = 0;
|
|
35090
|
+
/** Sink for track deaths — see {@link setTrackLossObserver}. */
|
|
35091
|
+
trackLossObserver = null;
|
|
35092
|
+
trackLossSampler = new TrackLossSampler();
|
|
34896
35093
|
/** Sink for fragmentation diagnostics — see {@link setSpawnBesideCoastingObserver}. */
|
|
34897
35094
|
spawnBesideObserver = null;
|
|
34898
35095
|
constructor(config = {}) {
|
|
@@ -34925,6 +35122,13 @@ var SortTracker = class SortTracker {
|
|
|
34925
35122
|
* Same contract as the other two sinks — the tracker has no `deviceId`, the
|
|
34926
35123
|
* caller stamps it, and nothing is computed when no observer is wired.
|
|
34927
35124
|
*/
|
|
35125
|
+
/**
|
|
35126
|
+
* Wire the sink for {@link TrackLossRecord}. Nothing is computed when no
|
|
35127
|
+
* observer is attached.
|
|
35128
|
+
*/
|
|
35129
|
+
setTrackLossObserver(observer) {
|
|
35130
|
+
this.trackLossObserver = observer;
|
|
35131
|
+
}
|
|
34928
35132
|
setSpawnBesideCoastingObserver(observer) {
|
|
34929
35133
|
this.spawnBesideObserver = observer;
|
|
34930
35134
|
}
|
|
@@ -34939,13 +35143,13 @@ var SortTracker = class SortTracker {
|
|
|
34939
35143
|
reportSpawnBesideCoasting(spawnedId, det, coasting, detections, frameArea, timestamp) {
|
|
34940
35144
|
const observer = this.spawnBesideObserver;
|
|
34941
35145
|
if (!observer) return;
|
|
34942
|
-
const
|
|
35146
|
+
const family = SortTracker.spawnBesideFamily(det.class);
|
|
34943
35147
|
const dc = bboxCentroid(det.bbox);
|
|
34944
35148
|
let best;
|
|
34945
35149
|
let bestDist = Number.POSITIVE_INFINITY;
|
|
34946
35150
|
let bestReach = 0;
|
|
34947
35151
|
for (const track of coasting) {
|
|
34948
|
-
if (
|
|
35152
|
+
if (SortTracker.spawnBesideFamily(track.class) !== family) continue;
|
|
34949
35153
|
const tc = bboxCentroid(track.bbox);
|
|
34950
35154
|
const dist = Math.hypot(tc.x - dc.x, tc.y - dc.y);
|
|
34951
35155
|
const reach = this.reacquireReach(track, timestamp, true);
|
|
@@ -34960,6 +35164,7 @@ var SortTracker = class SortTracker {
|
|
|
34960
35164
|
const declinedBy = this.explainDecline(best, det, detections, frameArea, timestamp, bestDist);
|
|
34961
35165
|
const spawnCount = this.spawnBesideSampler.next(declinedBy);
|
|
34962
35166
|
if (spawnCount === void 0) return;
|
|
35167
|
+
const crowd = declinedBy === "crowd-guard" ? this.crowdAround(best, det, detections) : void 0;
|
|
34963
35168
|
observer({
|
|
34964
35169
|
spawnedTrackId: spawnedId,
|
|
34965
35170
|
spawnedClass: det.class,
|
|
@@ -34976,6 +35181,8 @@ var SortTracker = class SortTracker {
|
|
|
34976
35181
|
gapMs: timestamp - best.lastSeen,
|
|
34977
35182
|
trackAgeMs: timestamp - best.firstSeen,
|
|
34978
35183
|
declinedBy,
|
|
35184
|
+
...crowd === void 0 ? {} : { competitorCount: crowd.count },
|
|
35185
|
+
...crowd?.nearestPx === void 0 ? {} : { nearestCompetitorPx: crowd.nearestPx },
|
|
34979
35186
|
spawnCount,
|
|
34980
35187
|
sampledEvery: 10
|
|
34981
35188
|
});
|
|
@@ -34990,8 +35197,7 @@ var SortTracker = class SortTracker {
|
|
|
34990
35197
|
* `already-claimed` means.
|
|
34991
35198
|
*/
|
|
34992
35199
|
explainDecline(track, det, detections, frameArea, timestamp, centroidDist) {
|
|
34993
|
-
if (
|
|
34994
|
-
if (this.assocGroup(track.class) !== this.assocGroup(det.class)) return "assoc-group";
|
|
35200
|
+
if (track.class !== det.class) return det.nativeRecovery !== void 0 ? "native-measured-class" : "cross-class";
|
|
34995
35201
|
if (iou$3(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return "already-claimed";
|
|
34996
35202
|
if (centroidDist <= this.reacquireReach(track, timestamp, true)) {
|
|
34997
35203
|
if (!this.isPlausibleBirth(det, frameArea)) return "already-claimed";
|
|
@@ -35050,14 +35256,44 @@ var SortTracker = class SortTracker {
|
|
|
35050
35256
|
* come back — a graveyarded track may still resurrect, and a line about work
|
|
35051
35257
|
* that was not lost is worse than no line.
|
|
35052
35258
|
*/
|
|
35259
|
+
/**
|
|
35260
|
+
* Report one track that ended with nothing taking over (D438).
|
|
35261
|
+
*
|
|
35262
|
+
* Deliberately separate from {@link reportTrackLoss}, which speaks only for
|
|
35263
|
+
* spawns that never reached `minHits`. The population under investigation —
|
|
35264
|
+
* animal tracks dying mid-frame after six frames — had all EMITTED, so that
|
|
35265
|
+
* path never saw a single one of them.
|
|
35266
|
+
*
|
|
35267
|
+
* `timestamp` is the frame doing the sweeping, not the track's own clock: the
|
|
35268
|
+
* question is how much passed while nobody was looking.
|
|
35269
|
+
*/
|
|
35270
|
+
reportTrackDeath(track, cause, timestamp) {
|
|
35271
|
+
const observer = this.trackLossObserver;
|
|
35272
|
+
if (!observer) return;
|
|
35273
|
+
const lossCount = this.trackLossSampler.next(cause);
|
|
35274
|
+
if (lossCount === void 0) return;
|
|
35275
|
+
observer({
|
|
35276
|
+
trackId: track.id,
|
|
35277
|
+
trackClass: track.class,
|
|
35278
|
+
cause,
|
|
35279
|
+
framesSinceLastSeen: this.frameSeq - track.lastSeenFrameSeq,
|
|
35280
|
+
msSinceLastSeen: timestamp - track.lastSeen,
|
|
35281
|
+
lifeMs: track.lastSeen - track.firstSeen,
|
|
35282
|
+
positions: track.hits,
|
|
35283
|
+
emitted: track.emitted,
|
|
35284
|
+
lossCount,
|
|
35285
|
+
sampledEvery: 50
|
|
35286
|
+
});
|
|
35287
|
+
}
|
|
35053
35288
|
reportTrackLoss(track) {
|
|
35054
35289
|
if (track.emitted || track.lossReported) return;
|
|
35055
35290
|
track.lossReported = true;
|
|
35056
35291
|
this.reportRefusal("unconfirmed-spawn-lost", {
|
|
35057
|
-
class: track.
|
|
35292
|
+
class: track.frameClass,
|
|
35058
35293
|
originalClass: track.originalClass,
|
|
35059
35294
|
score: track.score,
|
|
35060
|
-
bbox: track.bbox
|
|
35295
|
+
bbox: track.bbox,
|
|
35296
|
+
...track.lastNativeRecovery !== void 0 ? { nativeRecovery: track.lastNativeRecovery } : {}
|
|
35061
35297
|
}, {
|
|
35062
35298
|
threshold: this.config.minHits,
|
|
35063
35299
|
measured: track.hits,
|
|
@@ -35112,43 +35348,73 @@ var SortTracker = class SortTracker {
|
|
|
35112
35348
|
static pairKey(a, b) {
|
|
35113
35349
|
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
|
35114
35350
|
}
|
|
35115
|
-
/**
|
|
35116
|
-
|
|
35117
|
-
|
|
35118
|
-
|
|
35119
|
-
|
|
35120
|
-
|
|
35351
|
+
/** The two macro-classes a coarse pass confuses on a non-upright person. */
|
|
35352
|
+
static isPersonAnimalPair(a, b) {
|
|
35353
|
+
return a === PERSON_CLASS && b === ANIMAL_CLASS || a === ANIMAL_CLASS && b === PERSON_CLASS;
|
|
35354
|
+
}
|
|
35355
|
+
/**
|
|
35356
|
+
* The family a spawn-beside-coasting line is reported AGAINST — {person,
|
|
35357
|
+
* animal} as one, every other class its own. Reporting only, never a binding
|
|
35358
|
+
* rule: a cross-class pair is exactly what that line exists to name (the
|
|
35359
|
+
* D378 veto, the D439 salvage rule), so a pair the tracker refused must still
|
|
35360
|
+
* be reportable. Independent of `classGroupAssoc` for the same reason.
|
|
35361
|
+
*/
|
|
35362
|
+
static spawnBesideFamily(cls) {
|
|
35363
|
+
if (cls === PERSON_CLASS || cls === ANIMAL_CLASS) return `${PERSON_CLASS}|${ANIMAL_CLASS}`;
|
|
35121
35364
|
return cls;
|
|
35122
35365
|
}
|
|
35123
|
-
/**
|
|
35124
|
-
*
|
|
35125
|
-
*
|
|
35126
|
-
*
|
|
35366
|
+
/**
|
|
35367
|
+
* A CROSS-CLASS BIND IS A FLIP CLAIM, AND A FLIP IS WEAK (D439).
|
|
35368
|
+
*
|
|
35369
|
+
* `classGroupAssoc` exists for one failure of the COARSE full-frame pass: a
|
|
35370
|
+
* bending person re-labelled `animal` for a frame, over the person's own
|
|
35371
|
+
* box, at the 0.4-0.6 the coarse pass gives a shape it half recognises. That
|
|
35372
|
+
* claim has three parts, and this predicate is all three:
|
|
35373
|
+
*
|
|
35374
|
+
* - the box was measured by the coarse pass — never `nativeRecovery`. The
|
|
35375
|
+
* runner's second pass measures the OPPOSITE thing: a 640-800 px native
|
|
35376
|
+
* square around a motion region the full-frame boxes do NOT account for,
|
|
35377
|
+
* which is by construction not the subject the full pass already has.
|
|
35378
|
+
* 2026-09-07 07:51:33, an `animal` at `nativeRecoveryScore=0.833`
|
|
35379
|
+
* absorbed into a person: the D378 half of this rule;
|
|
35380
|
+
* - it is a {person, animal} pair;
|
|
35381
|
+
* - it is WEAK — below {@link TrackerConfig.animalOverPersonMaxScore}, the
|
|
35382
|
+
* same bar the spawn gate uses for the same claim. A `person` at 0.86 or
|
|
35383
|
+
* an `animal` at 0.92 is not a half-recognised shape, it is a subject,
|
|
35384
|
+
* and binding it to the other class's track hands one subject's id to
|
|
35385
|
+
* another (camera 615, 17:11:01: person 0.8616 → animal track
|
|
35386
|
+
* `a28c1557`, which was then born as a person).
|
|
35387
|
+
*
|
|
35388
|
+
* Where the caller may use it is the fourth part, and it is the caller's:
|
|
35389
|
+
* only the predicted-box passes ({@link greedyAssociate}), where "same place"
|
|
35390
|
+
* is measured against a prediction on the strict `iouThreshold`. The salvage
|
|
35391
|
+
* passes ({@link looseMatch}) never cross classes — their whole premise is
|
|
35392
|
+
* weak overlap, and a weak-overlap cross-class bind is a dog beside its
|
|
35393
|
+
* owner, every time.
|
|
35394
|
+
*/
|
|
35395
|
+
crossClassFlip(trackClass, det) {
|
|
35396
|
+
if (!this.config.classGroupAssoc) return false;
|
|
35397
|
+
if (det.nativeRecovery !== void 0) return false;
|
|
35398
|
+
if (!SortTracker.isPersonAnimalPair(trackClass, det.class)) return false;
|
|
35399
|
+
return det.score < this.config.animalOverPersonMaxScore;
|
|
35400
|
+
}
|
|
35401
|
+
/** Whether a detection may associate to a track on a PREDICTED-BOX pass:
|
|
35402
|
+
* its own class, or — with `classGroupAssoc` — a weak coarse flip of the
|
|
35403
|
+
* {person, animal} pair ({@link crossClassFlip}). `classGating` off means no
|
|
35404
|
+
* gate at all, matching the legacy contract, except that a native-measured
|
|
35405
|
+
* box still binds only its own class (D378). */
|
|
35127
35406
|
associable(trackClass, det) {
|
|
35128
|
-
if (
|
|
35407
|
+
if (trackClass === det.class) return true;
|
|
35408
|
+
if (det.nativeRecovery !== void 0) return false;
|
|
35129
35409
|
if (!this.config.classGating) return true;
|
|
35130
|
-
return this.
|
|
35410
|
+
return this.crossClassFlip(trackClass, det);
|
|
35131
35411
|
}
|
|
35132
35412
|
/**
|
|
35133
|
-
* A NATIVE-MEASURED BOX IS ITS OWN SUBJECT ACROSS CLASSES (D378)
|
|
35134
|
-
*
|
|
35135
|
-
*
|
|
35136
|
-
*
|
|
35137
|
-
*
|
|
35138
|
-
* the person track is strictly better than minting a concurrent id.
|
|
35139
|
-
*
|
|
35140
|
-
* The runner's second pass makes the opposite measurement — the same detector
|
|
35141
|
-
* on a 640-800 px native square around a motion region the full-frame boxes
|
|
35142
|
-
* do NOT account for. Such a box is, by construction, not the subject the
|
|
35143
|
-
* full pass already has; absorbing it into a person track means the animal is
|
|
35144
|
-
* never seen at all, which on 2026-09-07 07:51:33 is exactly what happened to
|
|
35145
|
-
* an `animal` at `nativeRecoveryScore=0.833`.
|
|
35146
|
-
*
|
|
35147
|
-
* So the widening does not apply to a detection carrying `nativeRecovery`:
|
|
35148
|
-
* it associates to its OWN class or it spawns. Every other guard on that
|
|
35149
|
-
* spawn still runs — the per-class score floor, the area floor, the
|
|
35150
|
-
* `dedupSpawnIou` overlap, and `resolveDuplicates` afterwards, where a person
|
|
35151
|
-
* still beats a cross-class animal.
|
|
35413
|
+
* A NATIVE-MEASURED BOX IS ITS OWN SUBJECT ACROSS CLASSES (D378) — the
|
|
35414
|
+
* occlusion gate's copy of the first clause of {@link crossClassFlip}. A
|
|
35415
|
+
* cross-class occluder is not a same-subject claim for a box the native
|
|
35416
|
+
* pass measured: a dog at its owner's knee is 100% contained in the person's
|
|
35417
|
+
* box and is not the person.
|
|
35152
35418
|
*/
|
|
35153
35419
|
sameGroupAsMeasured(trackClass, det) {
|
|
35154
35420
|
if (det.nativeRecovery === void 0) return true;
|
|
@@ -35321,9 +35587,16 @@ var SortTracker = class SortTracker {
|
|
|
35321
35587
|
};
|
|
35322
35588
|
}
|
|
35323
35589
|
/**
|
|
35324
|
-
* How many
|
|
35325
|
-
*
|
|
35326
|
-
*
|
|
35590
|
+
* How many SAME-CLASS subjects OTHER than `corpse` share the scene with it:
|
|
35591
|
+
* every track LIVE on this frame, plus any other resurrectable corpse whose
|
|
35592
|
+
* lifetime OVERLAPPED the candidate's.
|
|
35593
|
+
*
|
|
35594
|
+
* Exact class, not the {person, animal} family (D439): a competitor is a
|
|
35595
|
+
* subject the id could actually be handed to, and since a salvage pass binds
|
|
35596
|
+
* its own class only, a person cannot take a dog's id there. Counting the
|
|
35597
|
+
* owner as the dog's competitor split one dog's id 12 times on camera 615 in
|
|
35598
|
+
* 6.6 h — every bind the reach clause accepted, the guard then refused
|
|
35599
|
+
* because the person was on screen.
|
|
35327
35600
|
*
|
|
35328
35601
|
* The overlap clause on the graveyard half is what keeps this guard off the
|
|
35329
35602
|
* case the resurrection pass exists for. A single flickering subject leaves a
|
|
@@ -35337,30 +35610,30 @@ var SortTracker = class SortTracker {
|
|
|
35337
35610
|
* steals an id is very often one whose OWN track was born after the corpse
|
|
35338
35611
|
* died (camera 618 — the toddler's track post-dates the older boy's).
|
|
35339
35612
|
*/
|
|
35340
|
-
|
|
35341
|
-
const group = this.assocGroup(corpse.class);
|
|
35613
|
+
sameClassPeerCount(corpse, live) {
|
|
35342
35614
|
let peers = 0;
|
|
35343
|
-
for (const t of live) if (t !== corpse &&
|
|
35615
|
+
for (const t of live) if (t !== corpse && t.class === corpse.class) peers++;
|
|
35344
35616
|
for (const other of this.lostTracks) {
|
|
35345
35617
|
if (other === corpse) continue;
|
|
35346
35618
|
if (!other.resurrectable) continue;
|
|
35347
|
-
if (
|
|
35619
|
+
if (other.class !== corpse.class) continue;
|
|
35348
35620
|
if (other.firstSeen <= corpse.lastSeen && corpse.firstSeen <= other.lastSeen) peers++;
|
|
35349
35621
|
}
|
|
35350
35622
|
return peers;
|
|
35351
35623
|
}
|
|
35352
35624
|
/**
|
|
35353
|
-
* Loose
|
|
35354
|
-
*
|
|
35355
|
-
*
|
|
35356
|
-
*
|
|
35357
|
-
*
|
|
35358
|
-
*
|
|
35359
|
-
*
|
|
35625
|
+
* Loose gate used by the rescue and resurrection passes: accept when the
|
|
35626
|
+
* detection overlaps the track's LAST-KNOWN bbox by `rescueIou`, or its
|
|
35627
|
+
* centroid is within reach. EXACT-class, always, independent of
|
|
35628
|
+
* `classGating` and of `classGroupAssoc` (D439): a salvage pass binds on
|
|
35629
|
+
* weak overlap by definition, and a weak-overlap bind across {person,
|
|
35630
|
+
* animal} is not a crouch flip — a flip sits on the subject's own box — it
|
|
35631
|
+
* is a dog beside its owner, or the owner beside the dog. Until 2026-09-10
|
|
35632
|
+
* this gate crossed the group, and it is where a `person` at 0.86 took over
|
|
35633
|
+
* an animal's id (camera 615, 17:11:01).
|
|
35360
35634
|
*/
|
|
35361
35635
|
looseMatch(track, det, timestamp, timeScaled = true) {
|
|
35362
|
-
if (
|
|
35363
|
-
if (this.assocGroup(track.class) !== this.assocGroup(det.class)) return null;
|
|
35636
|
+
if (track.class !== det.class) return null;
|
|
35364
35637
|
if (iou$3(track.bbox, det.bbox) >= this.config.rescueIouThreshold) return "iou";
|
|
35365
35638
|
const tc = bboxCentroid(track.bbox);
|
|
35366
35639
|
const dc = bboxCentroid(det.bbox);
|
|
@@ -35406,12 +35679,41 @@ var SortTracker = class SortTracker {
|
|
|
35406
35679
|
* bind time and at report time — the spawn-beside-coasting line has to be able
|
|
35407
35680
|
* to say which gate declined without re-running the greedy competition.
|
|
35408
35681
|
*/
|
|
35682
|
+
/**
|
|
35683
|
+
* WHO the crowd was, for the record only — this changes no decision.
|
|
35684
|
+
*
|
|
35685
|
+
* {@link isUnambiguousReach} refuses on PRESENCE, with no distance in the
|
|
35686
|
+
* question, so a person at the far corner of a room splits a track as surely
|
|
35687
|
+
* as one standing beside it. Counting them, and measuring the nearest, is
|
|
35688
|
+
* what makes narrowing that rule a decision rather than a guess (D436).
|
|
35689
|
+
*/
|
|
35690
|
+
crowdAround(track, det, detections) {
|
|
35691
|
+
const cx = det.bbox.x + det.bbox.w / 2;
|
|
35692
|
+
const cy = det.bbox.y + det.bbox.h / 2;
|
|
35693
|
+
const counted = [];
|
|
35694
|
+
let nearest;
|
|
35695
|
+
const consider = (bbox) => {
|
|
35696
|
+
counted.push(bbox);
|
|
35697
|
+
const d = Math.hypot(bbox.x + bbox.w / 2 - cx, bbox.y + bbox.h / 2 - cy);
|
|
35698
|
+
if (nearest === void 0 || d < nearest) nearest = d;
|
|
35699
|
+
};
|
|
35700
|
+
for (const t of this.tracks) if (t !== track && t.class === track.class) consider(t.bbox);
|
|
35701
|
+
for (const other of detections) {
|
|
35702
|
+
if (other === det) continue;
|
|
35703
|
+
if (other.class !== track.class) continue;
|
|
35704
|
+
if (counted.some((b) => iou$3(b, other.bbox) > 0)) continue;
|
|
35705
|
+
consider(other.bbox);
|
|
35706
|
+
}
|
|
35707
|
+
return {
|
|
35708
|
+
count: counted.length,
|
|
35709
|
+
nearestPx: nearest
|
|
35710
|
+
};
|
|
35711
|
+
}
|
|
35409
35712
|
isUnambiguousReach(track, det, detections) {
|
|
35410
|
-
if (this.
|
|
35411
|
-
const group = this.assocGroup(track.class);
|
|
35713
|
+
if (this.sameClassPeerCount(track, this.tracks) > 0) return false;
|
|
35412
35714
|
for (const other of detections) {
|
|
35413
35715
|
if (other === det) continue;
|
|
35414
|
-
if (
|
|
35716
|
+
if (other.class === track.class) return false;
|
|
35415
35717
|
}
|
|
35416
35718
|
return true;
|
|
35417
35719
|
}
|
|
@@ -35448,8 +35750,8 @@ var SortTracker = class SortTracker {
|
|
|
35448
35750
|
* weight; otherwise the latest-frame class (ambiguous vote or voting off).
|
|
35449
35751
|
*/
|
|
35450
35752
|
resolveReportedClass(t) {
|
|
35451
|
-
if (!this.config.classVotingEnabled || t.classVotes.size === 0) return t.
|
|
35452
|
-
let bestClass = t.
|
|
35753
|
+
if (!this.config.classVotingEnabled || t.classVotes.size === 0) return t.frameClass;
|
|
35754
|
+
let bestClass = t.frameClass;
|
|
35453
35755
|
let bestVote = -1;
|
|
35454
35756
|
let total = 0;
|
|
35455
35757
|
for (const [cls, v] of t.classVotes) {
|
|
@@ -35459,8 +35761,8 @@ var SortTracker = class SortTracker {
|
|
|
35459
35761
|
bestClass = cls;
|
|
35460
35762
|
}
|
|
35461
35763
|
}
|
|
35462
|
-
if (total <= 0) return t.
|
|
35463
|
-
return bestVote / total >= this.config.classVoteMinFraction ? bestClass : t.
|
|
35764
|
+
if (total <= 0) return t.frameClass;
|
|
35765
|
+
return bestVote / total >= this.config.classVoteMinFraction ? bestClass : t.frameClass;
|
|
35464
35766
|
}
|
|
35465
35767
|
/** Whether a detection clears its per-class spawn score floor. Only gates NEW
|
|
35466
35768
|
* spawns — an established track still matches below its floor. */
|
|
@@ -35476,16 +35778,67 @@ var SortTracker = class SortTracker {
|
|
|
35476
35778
|
return this.config.confirmBypassEnabled && t.score >= this.config.confirmBypassScore;
|
|
35477
35779
|
}
|
|
35478
35780
|
/**
|
|
35781
|
+
* The live tracks split into the two tiers that claim a box in a fixed
|
|
35782
|
+
* order (D442, see {@link update}): those already confirmed for emission,
|
|
35783
|
+
* and the provisional ones still under `minHits`. Decided on the state at
|
|
35784
|
+
* the start of the frame, as ByteTrack does. With `minHits: 1` the second
|
|
35785
|
+
* tier is always empty and every pass over it is a no-op.
|
|
35786
|
+
*/
|
|
35787
|
+
associationTiers() {
|
|
35788
|
+
const established = [];
|
|
35789
|
+
const provisional = [];
|
|
35790
|
+
for (const track of this.tracks) if (this.isConfirmedForEmit(track)) established.push(track);
|
|
35791
|
+
else provisional.push(track);
|
|
35792
|
+
return [established, provisional];
|
|
35793
|
+
}
|
|
35794
|
+
/**
|
|
35795
|
+
* The rescue pass. HIGH-score detections the greedy pass left unmatched get
|
|
35796
|
+
* a second, looser chance against the still-unmatched (coasting) tracks of
|
|
35797
|
+
* `tracks` — measured on the track's LAST-KNOWN bbox, never the drifted
|
|
35798
|
+
* prediction. Recovers half-box re-acquisition and small-drift flicker
|
|
35799
|
+
* without spawning a duplicate. Best (highest last-known IoU) pairs first;
|
|
35800
|
+
* LOW detections are reserved for the ByteTrack low stage.
|
|
35801
|
+
*/
|
|
35802
|
+
rescueAssociate(tracks, gates, detections, lowSet, frameArea, timestamp, used, matchedTracks, matched, continuationOnly) {
|
|
35803
|
+
const rescuePairs = [];
|
|
35804
|
+
for (const track of tracks) {
|
|
35805
|
+
if (matchedTracks.has(track)) continue;
|
|
35806
|
+
for (let di = 0; di < detections.length; di++) {
|
|
35807
|
+
if (used.has(di) || lowSet.has(di) || continuationOnly.has(di)) continue;
|
|
35808
|
+
const det = detections[di];
|
|
35809
|
+
const gate = this.looseMatch(track, det, timestamp);
|
|
35810
|
+
if (gate === null || !gates.has(gate)) continue;
|
|
35811
|
+
if (gate === "centroid" && this.isPlausibleBirth(det, frameArea) && !(this.config.reacquireTimeScaled && this.isUnambiguousReach(track, det, detections))) continue;
|
|
35812
|
+
rescuePairs.push({
|
|
35813
|
+
track,
|
|
35814
|
+
detIdx: di,
|
|
35815
|
+
score: iou$3(track.bbox, det.bbox),
|
|
35816
|
+
gate
|
|
35817
|
+
});
|
|
35818
|
+
}
|
|
35819
|
+
}
|
|
35820
|
+
rescuePairs.sort((a, b) => b.score - a.score);
|
|
35821
|
+
for (const pair of rescuePairs) {
|
|
35822
|
+
if (matchedTracks.has(pair.track) || used.has(pair.detIdx)) continue;
|
|
35823
|
+
const det = detections[pair.detIdx];
|
|
35824
|
+
matched.set(pair.track, det);
|
|
35825
|
+
matchedTracks.add(pair.track);
|
|
35826
|
+
used.add(pair.detIdx);
|
|
35827
|
+
this.reportBind("rescue", pair.gate, pair.track, det, timestamp);
|
|
35828
|
+
}
|
|
35829
|
+
}
|
|
35830
|
+
/**
|
|
35479
35831
|
* One greedy IoU association pass over a candidate detection subset against the
|
|
35480
|
-
*
|
|
35832
|
+
* still-UNMATCHED tracks of `tracks` (one association tier — see
|
|
35833
|
+
* {@link associationTiers}), on each track's PREDICTED box. Highest-IoU pairs
|
|
35481
35834
|
* bind first; a track/detection binds at most once. Shared by the high-score
|
|
35482
35835
|
* first stage and the low-score second stage (different candidate sets + IoU
|
|
35483
35836
|
* gates) so the two-tier ByteTrack association stays a single implementation.
|
|
35484
35837
|
* Mutates `used` / `matchedTracks` / `matched` in place.
|
|
35485
35838
|
*/
|
|
35486
|
-
greedyAssociate(detections, candidateIdxs, iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly) {
|
|
35839
|
+
greedyAssociate(tracks, detections, candidateIdxs, iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly) {
|
|
35487
35840
|
const pairs = [];
|
|
35488
|
-
for (const track of
|
|
35841
|
+
for (const track of tracks) {
|
|
35489
35842
|
if (matchedTracks.has(track)) continue;
|
|
35490
35843
|
const pbox = this.predicted(track, timestamp);
|
|
35491
35844
|
const confirmed = this.isConfirmedForEmit(track);
|
|
@@ -35511,6 +35864,7 @@ var SortTracker = class SortTracker {
|
|
|
35511
35864
|
}
|
|
35512
35865
|
}
|
|
35513
35866
|
update(detections, timestamp, frameContext) {
|
|
35867
|
+
this.frameSeq++;
|
|
35514
35868
|
const frameArea = frameContext ? frameContext.frameWidth * frameContext.frameHeight : 0;
|
|
35515
35869
|
const continuationOnly = frameContext?.continuationOnly ?? EMPTY_INDEX_SET;
|
|
35516
35870
|
const resurrectedScene = /* @__PURE__ */ new Map();
|
|
@@ -35521,6 +35875,7 @@ var SortTracker = class SortTracker {
|
|
|
35521
35875
|
track.lostAt = timestamp;
|
|
35522
35876
|
track.resurrectable = false;
|
|
35523
35877
|
this.reportTrackLoss(track);
|
|
35878
|
+
this.reportTrackDeath(track, "lifetime-cap", timestamp);
|
|
35524
35879
|
this.lostTracks.push(track);
|
|
35525
35880
|
} else alive.push(track);
|
|
35526
35881
|
this.tracks = alive;
|
|
@@ -35533,13 +35888,17 @@ var SortTracker = class SortTracker {
|
|
|
35533
35888
|
track.lostAt = track.lastSeen;
|
|
35534
35889
|
track.resurrectable = false;
|
|
35535
35890
|
this.reportTrackLoss(track);
|
|
35891
|
+
this.reportTrackDeath(track, "stale-beyond-budgets", timestamp);
|
|
35536
35892
|
this.lostTracks.push(track);
|
|
35537
35893
|
} else alive.push(track);
|
|
35538
35894
|
this.tracks = alive;
|
|
35539
35895
|
}
|
|
35540
35896
|
const stillResurrectable = [];
|
|
35541
35897
|
for (const t of this.lostTracks) if (timestamp - t.lostAt <= this.config.resurrectionWindowMs) stillResurrectable.push(t);
|
|
35542
|
-
else
|
|
35898
|
+
else {
|
|
35899
|
+
this.reportTrackLoss(t);
|
|
35900
|
+
this.reportTrackDeath(t, "graveyard-expired", timestamp);
|
|
35901
|
+
}
|
|
35543
35902
|
this.lostTracks = stillResurrectable;
|
|
35544
35903
|
const used = /* @__PURE__ */ new Set();
|
|
35545
35904
|
const matchedTracks = /* @__PURE__ */ new Set();
|
|
@@ -35549,36 +35908,21 @@ var SortTracker = class SortTracker {
|
|
|
35549
35908
|
for (let di = 0; di < detections.length; di++) if (this.config.byteTrackEnabled && detections[di].score < this.config.byteTrackHighThreshold) lowIdxs.push(di);
|
|
35550
35909
|
else highIdxs.push(di);
|
|
35551
35910
|
const lowSet = new Set(lowIdxs);
|
|
35552
|
-
|
|
35553
|
-
const
|
|
35554
|
-
|
|
35555
|
-
|
|
35556
|
-
|
|
35557
|
-
|
|
35558
|
-
|
|
35559
|
-
|
|
35560
|
-
|
|
35561
|
-
if (gate === "centroid" && this.isPlausibleBirth(det, frameArea) && !(this.config.reacquireTimeScaled && this.isUnambiguousReach(track, det, detections))) continue;
|
|
35562
|
-
rescuePairs.push({
|
|
35563
|
-
track,
|
|
35564
|
-
detIdx: di,
|
|
35565
|
-
score: iou$3(track.bbox, det.bbox),
|
|
35566
|
-
gate
|
|
35567
|
-
});
|
|
35568
|
-
}
|
|
35569
|
-
}
|
|
35570
|
-
rescuePairs.sort((a, b) => b.score - a.score);
|
|
35571
|
-
for (const pair of rescuePairs) {
|
|
35572
|
-
if (matchedTracks.has(pair.track) || used.has(pair.detIdx)) continue;
|
|
35573
|
-
const det = detections[pair.detIdx];
|
|
35574
|
-
matched.set(pair.track, det);
|
|
35575
|
-
matchedTracks.add(pair.track);
|
|
35576
|
-
used.add(pair.detIdx);
|
|
35577
|
-
this.reportBind("rescue", pair.gate, pair.track, det, timestamp);
|
|
35578
|
-
}
|
|
35579
|
-
if (this.config.byteTrackEnabled && lowIdxs.length > 0) this.greedyAssociate(detections, lowIdxs, this.config.byteTrackLowIouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
|
|
35911
|
+
const [established, provisional] = this.associationTiers();
|
|
35912
|
+
const OVERLAP = new Set(["iou"]);
|
|
35913
|
+
const PROXIMITY = new Set(["centroid", "growth"]);
|
|
35914
|
+
this.greedyAssociate(established, detections, highIdxs, this.config.iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
|
|
35915
|
+
this.rescueAssociate(established, OVERLAP, detections, lowSet, frameArea, timestamp, used, matchedTracks, matched, continuationOnly);
|
|
35916
|
+
this.greedyAssociate(provisional, detections, highIdxs, this.config.iouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
|
|
35917
|
+
this.rescueAssociate(established, PROXIMITY, detections, lowSet, frameArea, timestamp, used, matchedTracks, matched, continuationOnly);
|
|
35918
|
+
this.rescueAssociate(provisional, new Set([...OVERLAP, ...PROXIMITY]), detections, lowSet, frameArea, timestamp, used, matchedTracks, matched, continuationOnly);
|
|
35919
|
+
if (this.config.byteTrackEnabled && lowIdxs.length > 0) for (const tier of [established, provisional]) this.greedyAssociate(tier, detections, lowIdxs, this.config.byteTrackLowIouThreshold, timestamp, used, matchedTracks, matched, continuationOnly);
|
|
35580
35920
|
for (const [track, det] of matched) {
|
|
35581
|
-
if (track.class !== det.class) this.reportRefusal("cross-class-absorbed", det, {
|
|
35921
|
+
if (track.class !== det.class) this.reportRefusal("cross-class-absorbed", det, {
|
|
35922
|
+
against: track,
|
|
35923
|
+
measured: iou$3(this.predicted(track, timestamp), det.bbox),
|
|
35924
|
+
threshold: this.config.animalOverPersonMaxScore
|
|
35925
|
+
});
|
|
35582
35926
|
const prevCenter = bboxCentroid({
|
|
35583
35927
|
x: track.bbox.x,
|
|
35584
35928
|
y: track.bbox.y,
|
|
@@ -35594,12 +35938,15 @@ var SortTracker = class SortTracker {
|
|
|
35594
35938
|
const frames = track.age + 1;
|
|
35595
35939
|
const dtMs = timestamp - track.lastSeen;
|
|
35596
35940
|
track.bbox = det.bbox;
|
|
35597
|
-
track.class = det.class;
|
|
35941
|
+
if (!this.crossClassFlip(track.class, det)) track.class = det.class;
|
|
35942
|
+
track.frameClass = det.class;
|
|
35598
35943
|
track.originalClass = det.originalClass;
|
|
35599
35944
|
track.score = det.score;
|
|
35945
|
+
track.lastNativeRecovery = det.nativeRecovery;
|
|
35600
35946
|
track.age = 0;
|
|
35601
35947
|
track.hits++;
|
|
35602
35948
|
track.lastSeen = timestamp;
|
|
35949
|
+
track.lastSeenFrameSeq = this.frameSeq;
|
|
35603
35950
|
if (dtMs > 0) {
|
|
35604
35951
|
track.velocity = {
|
|
35605
35952
|
dx: (newCenter.x - prevCenter.x) / dtMs,
|
|
@@ -35648,7 +35995,7 @@ var SortTracker = class SortTracker {
|
|
|
35648
35995
|
if (gate === null) continue;
|
|
35649
35996
|
if (gate === "centroid" && this.isPlausibleBirth(det, frameArea)) continue;
|
|
35650
35997
|
const score = iou$3(lost.bbox, det.bbox);
|
|
35651
|
-
const peers = this.
|
|
35998
|
+
const peers = this.sameClassPeerCount(lost, surviving);
|
|
35652
35999
|
if (this.config.resurrectionCrowdGuard && peers > 0 && score < this.config.resurrectionCrowdedIouThreshold) {
|
|
35653
36000
|
if (score > refusedScore) {
|
|
35654
36001
|
refused = lost;
|
|
@@ -35673,12 +36020,15 @@ var SortTracker = class SortTracker {
|
|
|
35673
36020
|
this.lostTracks = this.lostTracks.filter((t) => t !== best);
|
|
35674
36021
|
best.bbox = det.bbox;
|
|
35675
36022
|
best.class = det.class;
|
|
36023
|
+
best.frameClass = det.class;
|
|
35676
36024
|
best.originalClass = det.originalClass;
|
|
35677
36025
|
best.score = det.score;
|
|
36026
|
+
best.lastNativeRecovery = det.nativeRecovery;
|
|
35678
36027
|
best.age = 0;
|
|
35679
36028
|
best.hits++;
|
|
35680
36029
|
best.lost = false;
|
|
35681
36030
|
best.lastSeen = timestamp;
|
|
36031
|
+
best.lastSeenFrameSeq = this.frameSeq;
|
|
35682
36032
|
best.velocity = {
|
|
35683
36033
|
dx: 0,
|
|
35684
36034
|
dy: 0
|
|
@@ -35735,6 +36085,7 @@ var SortTracker = class SortTracker {
|
|
|
35735
36085
|
id: spawnedId,
|
|
35736
36086
|
bbox: det.bbox,
|
|
35737
36087
|
class: det.class,
|
|
36088
|
+
frameClass: det.class,
|
|
35738
36089
|
originalClass: det.originalClass,
|
|
35739
36090
|
score: det.score,
|
|
35740
36091
|
age: 0,
|
|
@@ -35742,6 +36093,7 @@ var SortTracker = class SortTracker {
|
|
|
35742
36093
|
path: [det.bbox],
|
|
35743
36094
|
firstSeen: timestamp,
|
|
35744
36095
|
lastSeen: timestamp,
|
|
36096
|
+
lastSeenFrameSeq: this.frameSeq,
|
|
35745
36097
|
velocity: {
|
|
35746
36098
|
dx: 0,
|
|
35747
36099
|
dy: 0
|
|
@@ -35752,7 +36104,8 @@ var SortTracker = class SortTracker {
|
|
|
35752
36104
|
resurrectable: true,
|
|
35753
36105
|
classVotes: new Map([[det.class, det.score > 0 ? det.score : Number.EPSILON]]),
|
|
35754
36106
|
emitted: false,
|
|
35755
|
-
lossReported: false
|
|
36107
|
+
lossReported: false,
|
|
36108
|
+
lastNativeRecovery: det.nativeRecovery
|
|
35756
36109
|
});
|
|
35757
36110
|
}
|
|
35758
36111
|
this.tracks = surviving;
|
|
@@ -36282,6 +36635,10 @@ var FrameProcessor = class {
|
|
|
36282
36635
|
/** Wire (or clear) the tracker's fragmentation sink — a new id minted beside a
|
|
36283
36636
|
* live coasting track that could have claimed it (D399). Same `deviceId`
|
|
36284
36637
|
* contract as the two sinks above. */
|
|
36638
|
+
/** Wire the sink for track deaths (D438) — pure passthrough to the tracker. */
|
|
36639
|
+
setTrackLossObserver(observer) {
|
|
36640
|
+
this.tracker.setTrackLossObserver(observer);
|
|
36641
|
+
}
|
|
36285
36642
|
setSpawnBesideCoastingObserver(observer) {
|
|
36286
36643
|
this.tracker.setSpawnBesideCoastingObserver(observer);
|
|
36287
36644
|
}
|
|
@@ -64961,6 +65318,44 @@ function buildAudioLabels(input) {
|
|
|
64961
65318
|
}];
|
|
64962
65319
|
}
|
|
64963
65320
|
//#endregion
|
|
65321
|
+
//#region src/pipeline-analytics/services/event-kinds-config.ts
|
|
65322
|
+
/** The honest answer for a pipeline that could not be read: nothing advertised. */
|
|
65323
|
+
var NO_EMITTABLE = {
|
|
65324
|
+
macroClasses: [],
|
|
65325
|
+
audioEnabled: false
|
|
65326
|
+
};
|
|
65327
|
+
/** Depth-first walk of the step tree; the detector is a root today, but the
|
|
65328
|
+
* question is "is it enabled anywhere", not "is it a root". */
|
|
65329
|
+
function* walkSteps(steps) {
|
|
65330
|
+
for (const step of steps) {
|
|
65331
|
+
yield step;
|
|
65332
|
+
if (step.children !== void 0) yield* walkSteps(step.children);
|
|
65333
|
+
}
|
|
65334
|
+
}
|
|
65335
|
+
/**
|
|
65336
|
+
* The macro classes an enabled `object-detection` step emits. Order follows
|
|
65337
|
+
* the authority, not the stored array, so two cameras narrowed to the same
|
|
65338
|
+
* set render the same lanes in the same order.
|
|
65339
|
+
*/
|
|
65340
|
+
function macroClassesOf(step) {
|
|
65341
|
+
const stored = step.settings?.["enabledMacroClasses"];
|
|
65342
|
+
if (!Array.isArray(stored) || stored.length === 0) return [...FIRST_LEVEL_MACRO_CLASSES];
|
|
65343
|
+
const chosen = new Set(stored.filter((v) => typeof v === "string"));
|
|
65344
|
+
return FIRST_LEVEL_MACRO_CLASSES.filter((macro) => chosen.has(macro));
|
|
65345
|
+
}
|
|
65346
|
+
function emittableFromPipelineConfig(config) {
|
|
65347
|
+
let macroClasses = [];
|
|
65348
|
+
for (const step of walkSteps(config.steps)) {
|
|
65349
|
+
if (step.addonId !== "object-detection" || step.enabled === false) continue;
|
|
65350
|
+
macroClasses = macroClassesOf(step);
|
|
65351
|
+
break;
|
|
65352
|
+
}
|
|
65353
|
+
return {
|
|
65354
|
+
macroClasses,
|
|
65355
|
+
audioEnabled: config.audio?.enabled === true
|
|
65356
|
+
};
|
|
65357
|
+
}
|
|
65358
|
+
//#endregion
|
|
64964
65359
|
//#region src/pipeline-analytics/services/event-kinds.ts
|
|
64965
65360
|
/**
|
|
64966
65361
|
* Extensible per-device event kinds (Part B).
|
|
@@ -66622,10 +67017,12 @@ var TrackingSettingsSchema = object({
|
|
|
66622
67017
|
minSpawnAreaFracAnimal: number().min(0).max(1).default(0),
|
|
66623
67018
|
/** Min spawn bbox area for `vehicle` as a fraction of the frame (0 = ungated). */
|
|
66624
67019
|
minSpawnAreaFracVehicle: number().min(0).max(1).default(0),
|
|
66625
|
-
/**
|
|
66626
|
-
*
|
|
66627
|
-
*
|
|
66628
|
-
*
|
|
67020
|
+
/** Let a person that flips to `animal` for a frame re-match its existing
|
|
67021
|
+
* track (no concurrent animal id). A flip is the same subject in the same
|
|
67022
|
+
* place, weakly read (D439): the bind is allowed only on the predicted-box
|
|
67023
|
+
* passes, only for a coarse box below `animalOverPersonMaxScore`, never on
|
|
67024
|
+
* the rescue / resurrection passes. Reported label stays via the class
|
|
67025
|
+
* vote. Off = exact per-frame class gating. */
|
|
66629
67026
|
classGroupAssoc: boolean().default(true)
|
|
66630
67027
|
});
|
|
66631
67028
|
var TRACKING_DEFAULTS = TrackingSettingsSchema.parse({});
|
|
@@ -67215,7 +67612,7 @@ function buildDetectionSettingsSections() {
|
|
|
67215
67612
|
type: "boolean",
|
|
67216
67613
|
key: "classGroupAssoc",
|
|
67217
67614
|
label: "Person/animal group matching",
|
|
67218
|
-
description: "Let a detection that flips between person and animal for a frame (a crouching / bending person is often read as an animal) re-match its existing track instead of spawning a concurrent animal track. The reported class is still decided by the per-track class vote.",
|
|
67615
|
+
description: "Let a detection that flips between person and animal for a frame (a crouching / bending person is often read as an animal) re-match its existing track instead of spawning a concurrent animal track. A flip is the same subject in the same place, weakly read: the cross-class match happens only on the strict predicted-box pass and only for a full-frame box below the \"animal over person max score\" bar — a confident animal beside a person, or a person beside an animal, is its own subject and keeps its own track. The reported class is still decided by the per-track class vote.",
|
|
67219
67616
|
default: TRACKING_DEFAULTS.classGroupAssoc
|
|
67220
67617
|
},
|
|
67221
67618
|
{
|
|
@@ -77686,6 +78083,15 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
77686
78083
|
}
|
|
77687
78084
|
});
|
|
77688
78085
|
});
|
|
78086
|
+
p.setTrackLossObserver((record) => {
|
|
78087
|
+
this.ctx.logger.info("tracker: a track ended and nothing took over", {
|
|
78088
|
+
tags: { deviceId },
|
|
78089
|
+
meta: {
|
|
78090
|
+
source,
|
|
78091
|
+
...record
|
|
78092
|
+
}
|
|
78093
|
+
});
|
|
78094
|
+
});
|
|
77689
78095
|
p.setSpawnBesideCoastingObserver((record) => {
|
|
77690
78096
|
this.ctx.logger.info("tracker: new id minted beside a live coasting track", {
|
|
77691
78097
|
tags: { deviceId },
|
|
@@ -77857,28 +78263,26 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
77857
78263
|
};
|
|
77858
78264
|
}
|
|
77859
78265
|
/**
|
|
77860
|
-
* Every event kind the device can produce: built-ins (motion + audio),
|
|
77861
|
-
* detection classes
|
|
77862
|
-
*
|
|
77863
|
-
*
|
|
78266
|
+
* Every event kind the device can produce: built-ins (motion + audio), the
|
|
78267
|
+
* detection classes its CONFIGURED pipeline can emit, and sensor kinds from
|
|
78268
|
+
* LINKED devices (device-manager `getLinkedDevices`, binding-driven per
|
|
78269
|
+
* linked device). Degrades to the built-ins on error.
|
|
78270
|
+
*
|
|
78271
|
+
* The detection and audio facts are read from `resolvePipeline` — the
|
|
78272
|
+
* configuration — and never from `getCameraStatus`, which is RUNTIME: an
|
|
78273
|
+
* on-motion camera holds no session between motion events
|
|
78274
|
+
* (`reasons.detection: 'armed:on-motion'`, `detection: null`) and was being
|
|
78275
|
+
* advertised as detecting nothing while its tracks said person / vehicle /
|
|
78276
|
+
* animal ([D449](../../../../docs/decisions/adr-0449-the-timeline-taxonomy-is-the-configured-one-raw-forward-has-no-switch-and-a-one-row-write-waits-on-the-disk.md)).
|
|
77864
78277
|
*/
|
|
77865
78278
|
async listEventKinds(input) {
|
|
77866
78279
|
const api = this.ctx.api;
|
|
77867
|
-
const
|
|
77868
|
-
this.ctx.logger.warn("listEventKinds: getCameraStatus failed", {
|
|
77869
|
-
tags: { deviceId: input.deviceId },
|
|
77870
|
-
meta: { error: errMsg$1(err) }
|
|
77871
|
-
});
|
|
77872
|
-
return null;
|
|
77873
|
-
});
|
|
77874
|
-
const detectionOn = status?.detection != null;
|
|
77875
|
-
const audioOn = status?.audio?.enabled === true;
|
|
77876
|
-
const detectionMacros = MACRO_LABELS.map((l) => l.id).filter((id) => id !== "package");
|
|
78280
|
+
const emittable = await this.emittableFromConfig(input.deviceId, "listEventKinds");
|
|
77877
78281
|
return composeEventKinds({
|
|
77878
78282
|
linkedDevices: { getLinkedDevices: (i) => api.deviceManager.getLinkedDevices.query(i) },
|
|
77879
78283
|
bindings: { getBindings: (i) => api.deviceManager.getBindings.query(i) },
|
|
77880
|
-
enabledMacroClasses: async () =>
|
|
77881
|
-
audioEnabled: async () =>
|
|
78284
|
+
enabledMacroClasses: async () => emittable.macroClasses,
|
|
78285
|
+
audioEnabled: async () => emittable.audioEnabled,
|
|
77882
78286
|
packageZonesEnabled: async (deviceId) => {
|
|
77883
78287
|
return (await this.resolveDevicePackageRules(deviceId)).some((r) => r.enabled !== false);
|
|
77884
78288
|
},
|
|
@@ -77892,6 +78296,24 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
77892
78296
|
}, input.deviceId);
|
|
77893
78297
|
}
|
|
77894
78298
|
/**
|
|
78299
|
+
* What one camera's configured pipeline can emit (`resolvePipeline`), or
|
|
78300
|
+
* the honest fallback when the orchestrator could not answer: nothing
|
|
78301
|
+
* advertised, and a WARN saying so. The cap's answer is a plain list, so it
|
|
78302
|
+
* has no room for "unknown" — that residual of D315 is named in D449; the
|
|
78303
|
+
* steady state (an armed camera with no session) is no longer in it.
|
|
78304
|
+
*/
|
|
78305
|
+
async emittableFromConfig(deviceId, scope) {
|
|
78306
|
+
try {
|
|
78307
|
+
return emittableFromPipelineConfig(await this.ctx.api.pipelineOrchestrator.resolvePipeline.query({ deviceId }));
|
|
78308
|
+
} catch (err) {
|
|
78309
|
+
this.ctx.logger.warn(`${scope}: resolvePipeline failed — no ML kinds advertised`, {
|
|
78310
|
+
tags: { deviceId },
|
|
78311
|
+
meta: { error: errMsg$1(err) }
|
|
78312
|
+
});
|
|
78313
|
+
return NO_EMITTABLE;
|
|
78314
|
+
}
|
|
78315
|
+
}
|
|
78316
|
+
/**
|
|
77895
78317
|
* `listEventKinds` for many cameras, in one call.
|
|
77896
78318
|
*
|
|
77897
78319
|
* The saving is not the round-trips the CALLER avoids — it is the fan-out
|
|
@@ -77903,11 +78325,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
77903
78325
|
* `getLinkedDevices` alone took 4110 ms — each one runs a full-fleet
|
|
77904
78326
|
* `listAll` inside device-manager and thirty of them QUEUE.
|
|
77905
78327
|
*
|
|
77906
|
-
* Now
|
|
77907
|
-
* `
|
|
77908
|
-
* `
|
|
77909
|
-
*
|
|
77910
|
-
* maps instead of RPC.
|
|
78328
|
+
* Now two calls for the whole topology, whatever N is —
|
|
78329
|
+
* `prefetchDeviceTopology`'s `getLinkedDevicesBatch` + `getBindingsBatch` —
|
|
78330
|
+
* plus one `resolvePipeline` per camera, all in flight at once (D449; there
|
|
78331
|
+
* is no batch form of it). `composeEventKinds` is unchanged — it is handed
|
|
78332
|
+
* the same per-device client interfaces, backed by maps instead of RPC.
|
|
77911
78333
|
*
|
|
77912
78334
|
* A camera whose composition fails yields an EMPTY list rather than dropping
|
|
77913
78335
|
* out of the response: a caller that asked for twelve and got eleven cannot
|
|
@@ -77916,15 +78338,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
77916
78338
|
async listEventKindsBatch(input) {
|
|
77917
78339
|
const api = this.ctx.api;
|
|
77918
78340
|
const deviceIds = [...new Set(input.deviceIds)];
|
|
77919
|
-
const
|
|
77920
|
-
this.ctx.logger.warn("listEventKindsBatch: getCameraStatuses failed", { meta: {
|
|
77921
|
-
deviceCount: deviceIds.length,
|
|
77922
|
-
error: errMsg$1(err)
|
|
77923
|
-
} });
|
|
77924
|
-
return [];
|
|
77925
|
-
});
|
|
77926
|
-
const statusByDevice = new Map(statuses.map((st) => [st.deviceId, st]));
|
|
77927
|
-
const detectionMacros = MACRO_LABELS.map((l) => l.id).filter((id) => id !== "package");
|
|
78341
|
+
const emittableByDevice = new Map(await Promise.all(deviceIds.map(async (deviceId) => [deviceId, await this.emittableFromConfig(deviceId, "listEventKindsBatch")])));
|
|
77928
78342
|
const topology = await prefetchDeviceTopology({
|
|
77929
78343
|
getLinkedDevicesBatch: (i) => api.deviceManager.getLinkedDevicesBatch.query(i),
|
|
77930
78344
|
getBindingsBatch: (i) => api.deviceManager.getBindingsBatch.query(i)
|
|
@@ -77936,14 +78350,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
77936
78350
|
return null;
|
|
77937
78351
|
});
|
|
77938
78352
|
return composeEventKindsForDevices(deviceIds, async (deviceId) => {
|
|
77939
|
-
const
|
|
77940
|
-
const detectionOn = status?.detection != null;
|
|
77941
|
-
const audioOn = status?.audio?.enabled === true;
|
|
78353
|
+
const emittable = emittableByDevice.get(deviceId) ?? NO_EMITTABLE;
|
|
77942
78354
|
return composeEventKinds({
|
|
77943
78355
|
linkedDevices: topology?.linkedDevices ?? { getLinkedDevices: (i) => api.deviceManager.getLinkedDevices.query(i) },
|
|
77944
78356
|
bindings: topology?.bindings ?? { getBindings: (i) => api.deviceManager.getBindings.query(i) },
|
|
77945
|
-
enabledMacroClasses: async () =>
|
|
77946
|
-
audioEnabled: async () =>
|
|
78357
|
+
enabledMacroClasses: async () => emittable.macroClasses,
|
|
78358
|
+
audioEnabled: async () => emittable.audioEnabled,
|
|
77947
78359
|
packageZonesEnabled: async (id) => {
|
|
77948
78360
|
return (await this.resolveDevicePackageRules(id)).some((r) => r.enabled !== false);
|
|
77949
78361
|
},
|