@camstack/addon-post-analysis 1.2.266 → 1.2.268
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-ZhDqWNZF.js → dist-Cgy2NW9x.js} +138 -2
- package/dist/{dist-CxMzr0uA.mjs → dist-DO_99QlZ.mjs} +138 -2
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/_stub.js +2 -2
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Cpkxf2UJ.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CD926gMQ.mjs} +2 -2
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DtSv_IZa.mjs +26 -0
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-C5WB_ysd.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-BTMiHQhW.mjs → hostInit-CBvxxcUn.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +1117 -94
- package/dist/pipeline-analytics/index.mjs +1115 -94
- 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_types__loadShare__.js-uWlH4czX.mjs +0 -26
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DFi0Tnyo.mjs +0 -26
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as TimelapseRuleSchema, $t as array, A as NcConditionDescriptorSchema, At as readDeviceStateFrom, B as NcTaxonomySchema, Bt as zoneAnalyticsCapability, C as MOTION_CLOSE_AFTER_MS, Ct as mayWriteToLocation, D as NC_DEFAULT_SNOOZE_MINUTES, Dt as pickClusterStepModels, E as NC_CONDITION_CATALOG, F as NcScheduleSchema, Ft as storageOccupancyCapability, Gt as createEvent, Ht as BaseAddon, I as NcSnoozeInputSchema, It as subKindsOf, J as SCENE_DIVERGED, Jt as hydrateSchema, K as SCENE_DEFAULT_ANCHOR_THRESHOLD, Kt as customAction, L as NcSnoozeSchema, Lt as systemEventFilterApplies, M as NcRulePatchSchema, Mt as resolveLocationMode, N as NcRuleSchema, Ot as pipelineAnalyticsCapability, P as NcRuleTargetSchema, Pt as sceneMonitorCapability, Q as TimelapseRulePatchSchema, Qt as _enum, R as NcSnoozeSuppressedSchema, Rt as vectorDimFromBase64, St as kebabToCamel, T as NC_ALARM_SYSTEM_EVENT_KINDS, Tt as occupancyScope, U as RECORDING_EXPORT_MAX_READ_BYTES, Ut as CamProfileSchema, V as OpsLogEntrySchema, Vt as errMsg$1, W as RetrainStatusSchema, Wt as DeviceType, X as TIMELAPSE_DENSE_FLOOR_SEC, Xt as nodePin, Y as SceneMonitorSchema, Yt as isDeviceScopedCap, Z as TimelapseRuleInputSchema, Zt as sleep$1, _ as FULL_IMAGE_BBOX, _t as isDetectionMacroClass, a as CLUSTER_MODEL_SCOPED_STEPS, an as partialRecord, at as audioMetricsCapability, b as MAX_ARCHIVED_DEBUG_NOTES, bt as isScheduleActive, cn as unknown, ct as cosineSimilarity$1, dt as encodeVectorBase64, en as boolean, et as TrackSourceSchema, f as DeclaredDevices, ft as evaluateSensorEdge, g as FIRST_LEVEL_MACRO_CLASSES, h as EVENT_PAD_MS, ht as failureContributionCapability, i as BirthDecisionRecordSchema, in as object, it as assertTimelapseCadences, j as NcRuleInputSchema, jt as readTimelapseGeneratedAt, k as NC_TAXONOMY, kt as plateGalleryCapability, ln as EventCategory, lt as deriveRecordingMode, m as EVENT_OWNER_TYPES, mt as faceGalleryCapability, n as ArchivedDebugNoteSchema, nn as literal, nt as addonWidgetsSourceCapability, o as COCO_TO_MACRO, on as record, ot as audioModeOf, p as EVENT_KIND_BY_CAP, pt as evictionPolicyOfLocation, q as SCENE_DEFAULT_UNCOVERED_POLICY, qt as defineCustomActions, r as BaseDevice, rn as number, rt as alarmPanelCapability, s as DEFAULT_EVENT_COLOR, sn as string, st as buildEventKindDescriptor, t as AUDIO_MACRO_LABELS, tn as discriminatedUnion, tt as VISIT_MERGE_GAP_MS, u as DETECTION_MACRO_CLASSES, v as FailureCounters, vt as isEventOwnerType, w as MediaFileKindEnum, wt as notificationRulesCapability, x as MAX_BIRTH_DECISION_RECORDS, xt as isSourceCap, y as LabelAttributionSchema, yt as isMediaOwnerType, z as NcSystemEventKindSchema, zt as videoclipsCapability } from "../dist-
|
|
1
|
+
import { $ as TimelapseRuleSchema, $t as array, A as NcConditionDescriptorSchema, At as readDeviceStateFrom, B as NcTaxonomySchema, Bt as zoneAnalyticsCapability, C as MOTION_CLOSE_AFTER_MS, Ct as mayWriteToLocation, D as NC_DEFAULT_SNOOZE_MINUTES, Dt as pickClusterStepModels, E as NC_CONDITION_CATALOG, F as NcScheduleSchema, Ft as storageOccupancyCapability, Gt as createEvent, Ht as BaseAddon, I as NcSnoozeInputSchema, It as subKindsOf, J as SCENE_DIVERGED, Jt as hydrateSchema, K as SCENE_DEFAULT_ANCHOR_THRESHOLD, Kt as customAction, L as NcSnoozeSchema, Lt as systemEventFilterApplies, M as NcRulePatchSchema, Mt as resolveLocationMode, N as NcRuleSchema, Ot as pipelineAnalyticsCapability, P as NcRuleTargetSchema, Pt as sceneMonitorCapability, Q as TimelapseRulePatchSchema, Qt as _enum, R as NcSnoozeSuppressedSchema, Rt as vectorDimFromBase64, St as kebabToCamel, T as NC_ALARM_SYSTEM_EVENT_KINDS, Tt as occupancyScope, U as RECORDING_EXPORT_MAX_READ_BYTES, Ut as CamProfileSchema, V as OpsLogEntrySchema, Vt as errMsg$1, W as RetrainStatusSchema, Wt as DeviceType, X as TIMELAPSE_DENSE_FLOOR_SEC, Xt as nodePin, Y as SceneMonitorSchema, Yt as isDeviceScopedCap, Z as TimelapseRuleInputSchema, Zt as sleep$1, _ as FULL_IMAGE_BBOX, _t as isDetectionMacroClass, a as CLUSTER_MODEL_SCOPED_STEPS, an as partialRecord, at as audioMetricsCapability, b as MAX_ARCHIVED_DEBUG_NOTES, bt as isScheduleActive, cn as unknown, ct as cosineSimilarity$1, dt as encodeVectorBase64, en as boolean, et as TrackSourceSchema, f as DeclaredDevices, ft as evaluateSensorEdge, g as FIRST_LEVEL_MACRO_CLASSES, h as EVENT_PAD_MS, ht as failureContributionCapability, i as BirthDecisionRecordSchema, in as object, it as assertTimelapseCadences, j as NcRuleInputSchema, jt as readTimelapseGeneratedAt, k as NC_TAXONOMY, kt as plateGalleryCapability, ln as EventCategory, lt as deriveRecordingMode, m as EVENT_OWNER_TYPES, mt as faceGalleryCapability, n as ArchivedDebugNoteSchema, nn as literal, nt as addonWidgetsSourceCapability, o as COCO_TO_MACRO, on as record, ot as audioModeOf, p as EVENT_KIND_BY_CAP, pt as evictionPolicyOfLocation, q as SCENE_DEFAULT_UNCOVERED_POLICY, qt as defineCustomActions, r as BaseDevice, rn as number, rt as alarmPanelCapability, s as DEFAULT_EVENT_COLOR, sn as string, st as buildEventKindDescriptor, t as AUDIO_MACRO_LABELS, tn as discriminatedUnion, tt as VISIT_MERGE_GAP_MS, u as DETECTION_MACRO_CLASSES, v as FailureCounters, vt as isEventOwnerType, w as MediaFileKindEnum, wt as notificationRulesCapability, x as MAX_BIRTH_DECISION_RECORDS, xt as isSourceCap, y as LabelAttributionSchema, yt as isMediaOwnerType, z as NcSystemEventKindSchema, zt as videoclipsCapability } from "../dist-DO_99QlZ.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";
|
|
@@ -37210,6 +37210,11 @@ var SortTracker = class SortTracker {
|
|
|
37210
37210
|
* three call sites reach and only one of them could have been handed it.
|
|
37211
37211
|
*/
|
|
37212
37212
|
frameArea = 0;
|
|
37213
|
+
/** The frame DIMS of the pass, for the same reason `frameArea` is held: the
|
|
37214
|
+
* salvage-bind record carries both boxes, and a box without the frame it was
|
|
37215
|
+
* measured in cannot be put back on a picture. `0` = no frame context. */
|
|
37216
|
+
frameWidthPx = 0;
|
|
37217
|
+
frameHeightPx = 0;
|
|
37213
37218
|
/** Sink for track deaths — see {@link setTrackLossObserver}. */
|
|
37214
37219
|
trackLossObserver = null;
|
|
37215
37220
|
trackLossSampler = new TrackLossSampler();
|
|
@@ -37447,6 +37452,20 @@ var SortTracker = class SortTracker {
|
|
|
37447
37452
|
centroidDistPx: Math.hypot(tc.x - dc.x, tc.y - dc.y),
|
|
37448
37453
|
trackDiagPx: Math.hypot(track.bbox.w, track.bbox.h),
|
|
37449
37454
|
areaRatio: smaller > 0 ? Math.max(trackArea, detArea) / smaller : 0,
|
|
37455
|
+
trackBbox: {
|
|
37456
|
+
x: track.bbox.x,
|
|
37457
|
+
y: track.bbox.y,
|
|
37458
|
+
w: track.bbox.w,
|
|
37459
|
+
h: track.bbox.h
|
|
37460
|
+
},
|
|
37461
|
+
detBbox: {
|
|
37462
|
+
x: det.bbox.x,
|
|
37463
|
+
y: det.bbox.y,
|
|
37464
|
+
w: det.bbox.w,
|
|
37465
|
+
h: det.bbox.h
|
|
37466
|
+
},
|
|
37467
|
+
frameWidth: this.frameWidthPx,
|
|
37468
|
+
frameHeight: this.frameHeightPx,
|
|
37450
37469
|
dtMs: timestamp - track.lastSeen,
|
|
37451
37470
|
trackAgeMs: timestamp - track.firstSeen,
|
|
37452
37471
|
bindCount,
|
|
@@ -37991,6 +38010,8 @@ var SortTracker = class SortTracker {
|
|
|
37991
38010
|
this.frameSeq++;
|
|
37992
38011
|
const frameArea = frameContext ? frameContext.frameWidth * frameContext.frameHeight : 0;
|
|
37993
38012
|
this.frameArea = frameArea;
|
|
38013
|
+
this.frameWidthPx = frameContext?.frameWidth ?? 0;
|
|
38014
|
+
this.frameHeightPx = frameContext?.frameHeight ?? 0;
|
|
37994
38015
|
const continuationOnly = frameContext?.continuationOnly ?? EMPTY_INDEX_SET;
|
|
37995
38016
|
const resurrectedScene = /* @__PURE__ */ new Map();
|
|
37996
38017
|
if (this.config.maxTrackLifetimeMs > 0) {
|
|
@@ -38338,7 +38359,7 @@ var StateAnalyzer = class {
|
|
|
38338
38359
|
}
|
|
38339
38360
|
const velocity = track.velocity ? Math.sqrt(track.velocity.dx ** 2 + track.velocity.dy ** 2) : 0;
|
|
38340
38361
|
const dwellTimeMs = timestamp - ts.enteredAt;
|
|
38341
|
-
const settled =
|
|
38362
|
+
const settled = this.speedSaysSettled(ts, track.bbox, velocity) || this.windowSaysStill(ts, track.bbox, timestamp);
|
|
38342
38363
|
let state;
|
|
38343
38364
|
if (ts.frameCount <= this.config.enteringFrames) state = "entering";
|
|
38344
38365
|
else if (settled) {
|
|
@@ -38374,6 +38395,37 @@ var StateAnalyzer = class {
|
|
|
38374
38395
|
return results;
|
|
38375
38396
|
}
|
|
38376
38397
|
/**
|
|
38398
|
+
* The SPEED half of the settled test.
|
|
38399
|
+
*
|
|
38400
|
+
* Uncalibrated (`stillSpeedFracPerSec` undefined) this is the original line
|
|
38401
|
+
* verbatim — the tracker's px-per-update velocity against the flat
|
|
38402
|
+
* `velocityThreshold`. Nothing about an uncalibrated camera changes.
|
|
38403
|
+
*
|
|
38404
|
+
* Calibrated, the same question is asked in the camera's own units: how far
|
|
38405
|
+
* the centroid moved between the last two OBSERVED samples, over the
|
|
38406
|
+
* subject's own box diagonal, over the elapsed SECONDS. Per second and not
|
|
38407
|
+
* per update because these cameras are `armed:on-motion` and deliver a
|
|
38408
|
+
* settle burst plus a poll — 56 ms to 2135 ms between stored positions on
|
|
38409
|
+
* one subject (D399) — so a per-update figure says more about the cadence
|
|
38410
|
+
* than about the subject.
|
|
38411
|
+
*
|
|
38412
|
+
* An interval that cannot be measured (a single sample, a non-positive
|
|
38413
|
+
* elapsed time, a box with no size) is UNKNOWN, and unknown never reads as
|
|
38414
|
+
* settled. A track that young is still inside `enteringFrames` anyway.
|
|
38415
|
+
*/
|
|
38416
|
+
speedSaysSettled(ts, bbox, velocity) {
|
|
38417
|
+
const bar = this.config.stillSpeedFracPerSec;
|
|
38418
|
+
if (bar === void 0) return velocity <= this.config.velocityThreshold;
|
|
38419
|
+
const last = ts.recent[ts.recent.length - 1];
|
|
38420
|
+
const prev = ts.recent[ts.recent.length - 2];
|
|
38421
|
+
if (!last || !prev) return false;
|
|
38422
|
+
const elapsedSec = (last.at - prev.at) / 1e3;
|
|
38423
|
+
if (!(elapsedSec > 0)) return false;
|
|
38424
|
+
const diagonal = Math.hypot(bbox.w, bbox.h);
|
|
38425
|
+
if (!(diagonal > 0)) return false;
|
|
38426
|
+
return Math.hypot(last.x - prev.x, last.y - prev.y) / diagonal / elapsedSec <= bar;
|
|
38427
|
+
}
|
|
38428
|
+
/**
|
|
38377
38429
|
* Whether the centroid has stayed inside a small span for the whole window.
|
|
38378
38430
|
*
|
|
38379
38431
|
* The span is measured across the samples themselves (their bounding box),
|
|
@@ -41235,6 +41287,17 @@ var TrackResidentState = class {
|
|
|
41235
41287
|
objectEmbeddingBestSelector = new TrackBestSelector(BEST_OBJECT_EMBED_POLICY);
|
|
41236
41288
|
/** keyFrame keys of CLOSED tracks still readable until the recognizer-end
|
|
41237
41289
|
* deferred drop settles (see module docstring). */
|
|
41290
|
+
/**
|
|
41291
|
+
* How many LIVE tracks carry the operator's `debug` flag, across every camera
|
|
41292
|
+
* this runner holds.
|
|
41293
|
+
*
|
|
41294
|
+
* A counter rather than a scan because the frame path asks it every frame:
|
|
41295
|
+
* it is what lets the host skip maintaining a per-frame handle mirror
|
|
41296
|
+
* entirely while nothing is marked, which is the state of the fleet
|
|
41297
|
+
* essentially always. Kept in step in the three places a resident's flag can
|
|
41298
|
+
* change: {@link setDebugMarked}, {@link close} and {@link clearAll}.
|
|
41299
|
+
*/
|
|
41300
|
+
debugMarkedCount = 0;
|
|
41238
41301
|
deferredKeyFrameKeys = /* @__PURE__ */ new Map();
|
|
41239
41302
|
constructor(hooks = {}) {
|
|
41240
41303
|
this.hooks = hooks;
|
|
@@ -41256,7 +41319,8 @@ var TrackResidentState = class {
|
|
|
41256
41319
|
thumbnailLanded: false,
|
|
41257
41320
|
keyFrameLanded: false,
|
|
41258
41321
|
keyFrameRetries: 0,
|
|
41259
|
-
confirmed: false
|
|
41322
|
+
confirmed: false,
|
|
41323
|
+
debugMarked: false
|
|
41260
41324
|
};
|
|
41261
41325
|
this.residents.set(trackId, created);
|
|
41262
41326
|
return created;
|
|
@@ -41270,6 +41334,7 @@ var TrackResidentState = class {
|
|
|
41270
41334
|
*/
|
|
41271
41335
|
close(trackId, mode) {
|
|
41272
41336
|
const resident = this.residents.get(trackId);
|
|
41337
|
+
if (resident?.debugMarked === true) this.debugMarkedCount -= 1;
|
|
41273
41338
|
this.residents.delete(trackId);
|
|
41274
41339
|
this.bestFrameTracker.delete(trackId);
|
|
41275
41340
|
this.objectEmbeddingBestSelector.delete(trackId);
|
|
@@ -41289,6 +41354,7 @@ var TrackResidentState = class {
|
|
|
41289
41354
|
}
|
|
41290
41355
|
/** Drop everything (addon dispose). */
|
|
41291
41356
|
clearAll() {
|
|
41357
|
+
this.debugMarkedCount = 0;
|
|
41292
41358
|
this.residents.clear();
|
|
41293
41359
|
this.bestFrameTracker.clear();
|
|
41294
41360
|
this.objectEmbeddingBestSelector.clear();
|
|
@@ -41480,6 +41546,42 @@ var TrackResidentState = class {
|
|
|
41480
41546
|
markConfirmed(deviceId, trackId) {
|
|
41481
41547
|
this.ensure(deviceId, trackId).confirmed = true;
|
|
41482
41548
|
}
|
|
41549
|
+
/**
|
|
41550
|
+
* Is this track the one the operator is asking about?
|
|
41551
|
+
*
|
|
41552
|
+
* THE gate for every debug crop, and it must stay this cheap: one map lookup,
|
|
41553
|
+
* no store read, no allocation. An unknown track is not marked — a lookup
|
|
41554
|
+
* that finds nothing is the common case on every camera in the fleet.
|
|
41555
|
+
*/
|
|
41556
|
+
isDebugMarked(trackId) {
|
|
41557
|
+
return this.residents.get(trackId)?.debugMarked ?? false;
|
|
41558
|
+
}
|
|
41559
|
+
/**
|
|
41560
|
+
* Record the operator's `debug` flag for a LIVE track.
|
|
41561
|
+
*
|
|
41562
|
+
* Called from the `setTrackFlags` cap mutation, which is the only thing that
|
|
41563
|
+
* can turn the flag on — so this runs once per press, not per frame.
|
|
41564
|
+
* `ensure` rather than a lookup: the button can be pressed on a track whose
|
|
41565
|
+
* first frame is still in flight, and a mark that silently missed would be
|
|
41566
|
+
* the operator clicking and getting nothing, which is the whole complaint.
|
|
41567
|
+
*/
|
|
41568
|
+
setDebugMarked(deviceId, trackId, marked) {
|
|
41569
|
+
const resident = this.ensure(deviceId, trackId);
|
|
41570
|
+
if (resident.debugMarked === marked) return;
|
|
41571
|
+
resident.debugMarked = marked;
|
|
41572
|
+
this.debugMarkedCount += marked ? 1 : -1;
|
|
41573
|
+
}
|
|
41574
|
+
/**
|
|
41575
|
+
* Does ANY live track on this runner carry the flag?
|
|
41576
|
+
*
|
|
41577
|
+
* The frame path's own gate, one level above {@link isDebugMarked}: it lets
|
|
41578
|
+
* the host skip the per-frame bookkeeping a debug crop needs (the frame
|
|
41579
|
+
* handle mirror the salvage-bind observer reads) while nothing is marked —
|
|
41580
|
+
* which is the fleet's normal state. A counter read, no scan, no allocation.
|
|
41581
|
+
*/
|
|
41582
|
+
anyDebugMarked() {
|
|
41583
|
+
return this.debugMarkedCount > 0;
|
|
41584
|
+
}
|
|
41483
41585
|
/** Retain the FIRST detection-raster crop per track (zero-media fallback);
|
|
41484
41586
|
* a later frame never overwrites an existing one. */
|
|
41485
41587
|
retainRasterFallback(deviceId, trackId, fallback) {
|
|
@@ -47983,6 +48085,336 @@ function makeViewerSettingsActionHandlers(store) {
|
|
|
47983
48085
|
};
|
|
47984
48086
|
}
|
|
47985
48087
|
//#endregion
|
|
48088
|
+
//#region src/pipeline-analytics/stillness-calibration-actions.ts
|
|
48089
|
+
/**
|
|
48090
|
+
* stillness-calibration-actions — "measure this camera's own stillness scale".
|
|
48091
|
+
*
|
|
48092
|
+
* ## Why an `addons.custom` action and not a cap method
|
|
48093
|
+
*
|
|
48094
|
+
* Same reason as `face.rescoreTrack` beside it: a cap method's wire schema
|
|
48095
|
+
* lives in `@camstack/types`, which travels inside the `@camstack/server`
|
|
48096
|
+
* closure, so a new one is not callable until a server train ships. Here the
|
|
48097
|
+
* schema lives in this addon's own dist and the hub forwards an opaque
|
|
48098
|
+
* envelope, so `camstack deploy` is the whole delivery.
|
|
48099
|
+
*
|
|
48100
|
+
* ## Why `apply` is explicit and not a fallback
|
|
48101
|
+
*
|
|
48102
|
+
* A calibration run READS recorded tracks and does arithmetic. Applying it
|
|
48103
|
+
* WRITES the camera's settings and changes what the tracker calls settled from
|
|
48104
|
+
* the next frame on. Those are different enough in consequence that a button
|
|
48105
|
+
* labelled "calibrate" must not choose between them on the operator's behalf —
|
|
48106
|
+
* the run reports, the operator applies. A REFUSED run never writes the bar
|
|
48107
|
+
* whatever `apply` says; it writes only the note, so the refusal and its reason
|
|
48108
|
+
* are visible in the form instead of being a toast that scrolls away.
|
|
48109
|
+
*/
|
|
48110
|
+
/** Mirrors `StillnessCalibrationRefusal` — kept literal so the wire schema is
|
|
48111
|
+
* self-describing and a new reason is a deliberate edit in both places. */
|
|
48112
|
+
var StillnessRefusalSchema = _enum([
|
|
48113
|
+
"no-tracks",
|
|
48114
|
+
"too-few-tracks",
|
|
48115
|
+
"too-few-samples",
|
|
48116
|
+
"no-motion-evidence"
|
|
48117
|
+
]);
|
|
48118
|
+
var StillnessMeasurementSchema = object({
|
|
48119
|
+
/** Tracks that contributed at least one speed sample. */
|
|
48120
|
+
trackCount: number().int(),
|
|
48121
|
+
/** Tracks the pass actually read, after the cost bound. */
|
|
48122
|
+
tracksRead: number().int(),
|
|
48123
|
+
sampleCount: number().int(),
|
|
48124
|
+
/** Speed quantiles, subject-box-diagonals per second. `null` when there was
|
|
48125
|
+
* nothing to measure — never 0, which would read as a measured stillness
|
|
48126
|
+
* (D393). */
|
|
48127
|
+
p25: number().nullable(),
|
|
48128
|
+
p50: number().nullable(),
|
|
48129
|
+
p90: number().nullable(),
|
|
48130
|
+
/** Median gap between stored positions (s) — the camera's real cadence. */
|
|
48131
|
+
medianIntervalSec: number().nullable(),
|
|
48132
|
+
/** Median subject box diagonal (px) the speeds were normalised by. */
|
|
48133
|
+
medianSubjectDiagonalPx: number().nullable()
|
|
48134
|
+
});
|
|
48135
|
+
var stillnessCalibrationActions = defineCustomActions({
|
|
48136
|
+
/**
|
|
48137
|
+
* `admin` because it WRITES a per-camera detection threshold when `apply` is
|
|
48138
|
+
* set, and because the report describes how the camera's subjects move,
|
|
48139
|
+
* which is not a viewer-level fact.
|
|
48140
|
+
*/
|
|
48141
|
+
"stillness.calibrate": customAction(object({
|
|
48142
|
+
deviceId: number().int(),
|
|
48143
|
+
/** How far back to read. Bounded: this pass parses ~10 kB of trajectory a
|
|
48144
|
+
* row, so the window is a suggestion and `maxTracks` is the real bound. */
|
|
48145
|
+
sinceHours: number().min(.25).max(720).default(72),
|
|
48146
|
+
/** Hard cost bound on tracks read, newest first. */
|
|
48147
|
+
maxTracks: number().int().min(1).max(1e3).default(200),
|
|
48148
|
+
/**
|
|
48149
|
+
* Write the chosen bar onto the camera. Never implicit, and never done at
|
|
48150
|
+
* all for a refused run.
|
|
48151
|
+
*/
|
|
48152
|
+
apply: boolean().default(false)
|
|
48153
|
+
}), object({
|
|
48154
|
+
deviceId: number().int(),
|
|
48155
|
+
/** The window actually read (epoch ms). */
|
|
48156
|
+
since: number().int(),
|
|
48157
|
+
until: number().int(),
|
|
48158
|
+
accepted: boolean(),
|
|
48159
|
+
/** Why it refused. Absent on an accepted run. */
|
|
48160
|
+
reason: StillnessRefusalSchema.optional(),
|
|
48161
|
+
/** The chosen bar, subject-box-diagonals per second. Absent on a refusal —
|
|
48162
|
+
* never 0, which the settings layer reads as "uncalibrated". */
|
|
48163
|
+
bar: number().optional(),
|
|
48164
|
+
/** The raw quantile before the rails, so the clamp is arguable. */
|
|
48165
|
+
rawBar: number().optional(),
|
|
48166
|
+
clampedBy: _enum([
|
|
48167
|
+
"none",
|
|
48168
|
+
"floor",
|
|
48169
|
+
"ceiling",
|
|
48170
|
+
"min",
|
|
48171
|
+
"max"
|
|
48172
|
+
]).optional(),
|
|
48173
|
+
measurement: StillnessMeasurementSchema,
|
|
48174
|
+
/** The bar this camera was on BEFORE the run — `null` = uncalibrated. Read
|
|
48175
|
+
* before any write, so the operator sees both numbers. */
|
|
48176
|
+
previousBar: number().nullable(),
|
|
48177
|
+
/** Whether the camera's settings were actually written. */
|
|
48178
|
+
applied: boolean(),
|
|
48179
|
+
/** The one-line note stored on the camera (accepted or refused). */
|
|
48180
|
+
note: string()
|
|
48181
|
+
}), {
|
|
48182
|
+
kind: "mutation",
|
|
48183
|
+
auth: "admin"
|
|
48184
|
+
}) });
|
|
48185
|
+
/**
|
|
48186
|
+
* The shipped limits.
|
|
48187
|
+
*
|
|
48188
|
+
* `minTracks` 12 / `minSamples` 200: the four live cameras sampled above
|
|
48189
|
+
* produced 697–8739 samples from 42–51 tracks in a single 60-track read, so a
|
|
48190
|
+
* camera that cannot clear these has genuinely not been watched enough.
|
|
48191
|
+
* `maxTracks` 200 bounds the read: the `positions` blob averages ~10 kB a row
|
|
48192
|
+
* (`track-store.ts`), so this pass is ~2 MB of JSON at its worst.
|
|
48193
|
+
*/
|
|
48194
|
+
var DEFAULT_STILLNESS_CALIBRATION_LIMITS = {
|
|
48195
|
+
minTracks: 12,
|
|
48196
|
+
minSamples: 200,
|
|
48197
|
+
maxTracks: 200,
|
|
48198
|
+
minIntervalSec: .02,
|
|
48199
|
+
maxIntervalSec: 3,
|
|
48200
|
+
quantile: .25,
|
|
48201
|
+
floorFactor: .02,
|
|
48202
|
+
ceilFactor: .25,
|
|
48203
|
+
minBar: .001,
|
|
48204
|
+
maxBar: 1
|
|
48205
|
+
};
|
|
48206
|
+
/** Linear-interpolated quantile. `NaN` for an empty list — never 0, which
|
|
48207
|
+
* would read as a measured zero (D393). */
|
|
48208
|
+
function quantileOf(values, p) {
|
|
48209
|
+
if (values.length === 0) return NaN;
|
|
48210
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
48211
|
+
const idx = (sorted.length - 1) * Math.min(Math.max(p, 0), 1);
|
|
48212
|
+
const lo = Math.floor(idx);
|
|
48213
|
+
const hi = Math.ceil(idx);
|
|
48214
|
+
const at = sorted[lo];
|
|
48215
|
+
const next = sorted[hi];
|
|
48216
|
+
if (at === void 0 || next === void 0) return NaN;
|
|
48217
|
+
return at + (next - at) * (idx - lo);
|
|
48218
|
+
}
|
|
48219
|
+
/**
|
|
48220
|
+
* Per-sample centroid speeds for a camera, in subject-box-diagonals per second.
|
|
48221
|
+
*
|
|
48222
|
+
* A pair of consecutive positions contributes a sample only when the interval
|
|
48223
|
+
* between them is inside `[minIntervalSec, maxIntervalSec]` and the later box
|
|
48224
|
+
* has a positive diagonal. A gap outside that band is a session boundary or a
|
|
48225
|
+
* duplicate read, not a measurement of how fast anything travelled; a box with
|
|
48226
|
+
* no size is unknown geometry, and unknown is never a sample.
|
|
48227
|
+
*/
|
|
48228
|
+
function collectStillnessSamples(tracks, limits) {
|
|
48229
|
+
const speeds = [];
|
|
48230
|
+
const intervals = [];
|
|
48231
|
+
const diagonals = [];
|
|
48232
|
+
let trackCount = 0;
|
|
48233
|
+
const read = tracks.slice(0, limits.maxTracks);
|
|
48234
|
+
for (const track of read) {
|
|
48235
|
+
const positions = [...track.positions].sort((a, b) => a.timestamp - b.timestamp);
|
|
48236
|
+
let contributed = false;
|
|
48237
|
+
for (let i = 1; i < positions.length; i++) {
|
|
48238
|
+
const prev = positions[i - 1];
|
|
48239
|
+
const cur = positions[i];
|
|
48240
|
+
if (prev === void 0 || cur === void 0) continue;
|
|
48241
|
+
const dtSec = (cur.timestamp - prev.timestamp) / 1e3;
|
|
48242
|
+
if (dtSec < limits.minIntervalSec || dtSec > limits.maxIntervalSec) continue;
|
|
48243
|
+
const diagonal = Math.hypot(cur.bbox.w, cur.bbox.h);
|
|
48244
|
+
if (!(diagonal > 0)) continue;
|
|
48245
|
+
const travelled = Math.hypot(cur.x - prev.x, cur.y - prev.y);
|
|
48246
|
+
speeds.push(travelled / diagonal / dtSec);
|
|
48247
|
+
intervals.push(dtSec);
|
|
48248
|
+
diagonals.push(diagonal);
|
|
48249
|
+
contributed = true;
|
|
48250
|
+
}
|
|
48251
|
+
if (contributed) trackCount++;
|
|
48252
|
+
}
|
|
48253
|
+
return {
|
|
48254
|
+
speeds,
|
|
48255
|
+
intervals,
|
|
48256
|
+
diagonals,
|
|
48257
|
+
trackCount,
|
|
48258
|
+
tracksRead: read.length
|
|
48259
|
+
};
|
|
48260
|
+
}
|
|
48261
|
+
function measure$1(samples) {
|
|
48262
|
+
return {
|
|
48263
|
+
trackCount: samples.trackCount,
|
|
48264
|
+
sampleCount: samples.speeds.length,
|
|
48265
|
+
p25: quantileOf(samples.speeds, .25),
|
|
48266
|
+
p50: quantileOf(samples.speeds, .5),
|
|
48267
|
+
p90: quantileOf(samples.speeds, .9),
|
|
48268
|
+
medianIntervalSec: quantileOf(samples.intervals, .5),
|
|
48269
|
+
medianSubjectDiagonalPx: quantileOf(samples.diagonals, .5)
|
|
48270
|
+
};
|
|
48271
|
+
}
|
|
48272
|
+
/**
|
|
48273
|
+
* Choose this camera's stillness speed bar, or refuse with a named reason.
|
|
48274
|
+
*
|
|
48275
|
+
* The order of the gates is the order of the questions: is there anything at
|
|
48276
|
+
* all, is it enough tracks, is it enough samples, and did anything on this
|
|
48277
|
+
* camera ever actually move. The last one is not pedantry — the whole method
|
|
48278
|
+
* scales the bar against the camera's own travel, so with no travel there is
|
|
48279
|
+
* nothing to scale against and the default is the honest answer.
|
|
48280
|
+
*/
|
|
48281
|
+
function calibrateStillnessSpeed(samples, limits) {
|
|
48282
|
+
const measurement = measure$1(samples);
|
|
48283
|
+
if (samples.tracksRead === 0) return {
|
|
48284
|
+
ok: false,
|
|
48285
|
+
reason: "no-tracks",
|
|
48286
|
+
measurement
|
|
48287
|
+
};
|
|
48288
|
+
if (samples.trackCount < limits.minTracks) return {
|
|
48289
|
+
ok: false,
|
|
48290
|
+
reason: "too-few-tracks",
|
|
48291
|
+
measurement
|
|
48292
|
+
};
|
|
48293
|
+
if (samples.speeds.length < limits.minSamples) return {
|
|
48294
|
+
ok: false,
|
|
48295
|
+
reason: "too-few-samples",
|
|
48296
|
+
measurement
|
|
48297
|
+
};
|
|
48298
|
+
if (!(measurement.p90 > 0)) return {
|
|
48299
|
+
ok: false,
|
|
48300
|
+
reason: "no-motion-evidence",
|
|
48301
|
+
measurement
|
|
48302
|
+
};
|
|
48303
|
+
const rawBar = quantileOf(samples.speeds, limits.quantile);
|
|
48304
|
+
const floor = measurement.p90 * limits.floorFactor;
|
|
48305
|
+
const ceiling = measurement.p90 * limits.ceilFactor;
|
|
48306
|
+
let bar = rawBar;
|
|
48307
|
+
let clampedBy = "none";
|
|
48308
|
+
if (bar < floor) {
|
|
48309
|
+
bar = floor;
|
|
48310
|
+
clampedBy = "floor";
|
|
48311
|
+
} else if (bar > ceiling) {
|
|
48312
|
+
bar = ceiling;
|
|
48313
|
+
clampedBy = "ceiling";
|
|
48314
|
+
}
|
|
48315
|
+
if (bar < limits.minBar) {
|
|
48316
|
+
bar = limits.minBar;
|
|
48317
|
+
clampedBy = "min";
|
|
48318
|
+
} else if (bar > limits.maxBar) {
|
|
48319
|
+
bar = limits.maxBar;
|
|
48320
|
+
clampedBy = "max";
|
|
48321
|
+
}
|
|
48322
|
+
return {
|
|
48323
|
+
ok: true,
|
|
48324
|
+
bar,
|
|
48325
|
+
rawBar,
|
|
48326
|
+
clampedBy,
|
|
48327
|
+
measurement
|
|
48328
|
+
};
|
|
48329
|
+
}
|
|
48330
|
+
/** One line an operator can read: what was measured, from how much, and what
|
|
48331
|
+
* rule produced the stored number. Kept short — it rides the settings blob. */
|
|
48332
|
+
function describeStillnessCalibration(outcome) {
|
|
48333
|
+
const m = outcome.measurement;
|
|
48334
|
+
const n = (v) => Number.isFinite(v) ? v.toFixed(4) : "unknown";
|
|
48335
|
+
const evidence = `${m.sampleCount} samples / ${m.trackCount} tracks, p25 ${n(m.p25)}, p50 ${n(m.p50)}, p90 ${n(m.p90)} subject-diagonals/s`;
|
|
48336
|
+
if (!outcome.ok) return `refused (${outcome.reason}) — ${evidence}`;
|
|
48337
|
+
return `bar ${n(outcome.bar)} (raw ${n(outcome.rawBar)}, clamp ${outcome.clampedBy}) — ${evidence}`;
|
|
48338
|
+
}
|
|
48339
|
+
//#endregion
|
|
48340
|
+
//#region src/pipeline-analytics/stillness-settings.ts
|
|
48341
|
+
/**
|
|
48342
|
+
* Per-device STILLNESS SCALE — the one number that says how fast a subject on
|
|
48343
|
+
* THIS camera has to move before it is travelling.
|
|
48344
|
+
*
|
|
48345
|
+
* A threshold belongs to whoever GENERATES the event, per device, never baked
|
|
48346
|
+
* into a classifier default. The judgement this feeds is
|
|
48347
|
+
* `StateAnalyzer.speedSaysSettled`; the measurement behind the number is
|
|
48348
|
+
* `pipeline/tracker/stillness-scale.ts`, which also carries the live
|
|
48349
|
+
* measurement showing why one number cannot serve a 30 m driveway and a 2 m
|
|
48350
|
+
* doorway.
|
|
48351
|
+
*
|
|
48352
|
+
* Cascade and shape mirror `stationary-settings` / `media-settings`: a
|
|
48353
|
+
* per-device override on top of the default, resolved per FIELD, and a parse
|
|
48354
|
+
* that never throws on a bad blob.
|
|
48355
|
+
*
|
|
48356
|
+
* **Absent behaves exactly as today.** The device store is a flat numeric blob
|
|
48357
|
+
* with no room for "unset", so the stored form of absent is `0` — and `0` is
|
|
48358
|
+
* not a bar anyone could want (it would mean nothing on this camera is ever
|
|
48359
|
+
* still) and not a value the calibration can produce (`minBar`). The decision
|
|
48360
|
+
* layer never sees it: {@link resolveStillSpeedFracPerSec} maps it to
|
|
48361
|
+
* `undefined`, and `undefined` is what leaves `StateAnalyzer` on its original
|
|
48362
|
+
* absolute `velocityThreshold` path.
|
|
48363
|
+
*/
|
|
48364
|
+
var StillnessSettingsSchema = object({
|
|
48365
|
+
/**
|
|
48366
|
+
* The camera's "not travelling" bar: centroid speed as a fraction of the
|
|
48367
|
+
* SUBJECT's own box diagonal, per second. `0` = not calibrated (see above).
|
|
48368
|
+
*
|
|
48369
|
+
* Measured p50 on the live hub 2026-09-19, for scale: 0.0136 (device 592,
|
|
48370
|
+
* close view, mostly parked subjects) to 0.3081 (device 615, far view, only
|
|
48371
|
+
* transits).
|
|
48372
|
+
*/
|
|
48373
|
+
stillnessSpeedFracPerSec: number().min(0).max(1).default(0),
|
|
48374
|
+
/**
|
|
48375
|
+
* When the number above was last CHOSEN — by a calibration run or by the
|
|
48376
|
+
* operator. `0` = never. Purely a readout; nothing gates on it.
|
|
48377
|
+
*/
|
|
48378
|
+
stillnessCalibratedAt: number().int().min(0).default(0),
|
|
48379
|
+
/**
|
|
48380
|
+
* What the last calibration run measured and decided, in one line
|
|
48381
|
+
* (`describeStillnessCalibration`) — including a REFUSAL and its named
|
|
48382
|
+
* reason, because "why is this camera still on the default" has to be
|
|
48383
|
+
* answerable without re-running anything.
|
|
48384
|
+
*/
|
|
48385
|
+
stillnessCalibrationNote: string().max(400).default("")
|
|
48386
|
+
});
|
|
48387
|
+
var STILLNESS_DEFAULTS = StillnessSettingsSchema.parse({});
|
|
48388
|
+
/**
|
|
48389
|
+
* Resolve a per-device store blob into typed stillness settings. Unknown or
|
|
48390
|
+
* invalid fields fall back to the default for that field — including a bar
|
|
48391
|
+
* outside the calibration's own rails, which is refused rather than clamped:
|
|
48392
|
+
* a number the calibration could never have produced is not evidence about
|
|
48393
|
+
* this camera, and silently bending it into range would present it as if it
|
|
48394
|
+
* were.
|
|
48395
|
+
*/
|
|
48396
|
+
function resolveStillnessSettings(raw) {
|
|
48397
|
+
const pick = (key) => {
|
|
48398
|
+
const parsed = StillnessSettingsSchema.shape[key].safeParse(raw[key]);
|
|
48399
|
+
return parsed.success ? parsed.data : STILLNESS_DEFAULTS[key];
|
|
48400
|
+
};
|
|
48401
|
+
return {
|
|
48402
|
+
stillnessSpeedFracPerSec: pick("stillnessSpeedFracPerSec"),
|
|
48403
|
+
stillnessCalibratedAt: pick("stillnessCalibratedAt"),
|
|
48404
|
+
stillnessCalibrationNote: pick("stillnessCalibrationNote")
|
|
48405
|
+
};
|
|
48406
|
+
}
|
|
48407
|
+
/**
|
|
48408
|
+
* The bar the decision layer gets: a positive, in-rails number, or
|
|
48409
|
+
* `undefined` for "this camera is not calibrated — judge it exactly as
|
|
48410
|
+
* before". The ONE place the stored-zero convention is interpreted.
|
|
48411
|
+
*/
|
|
48412
|
+
function resolveStillSpeedFracPerSec(settings) {
|
|
48413
|
+
const bar = settings.stillnessSpeedFracPerSec;
|
|
48414
|
+
if (!(bar >= .001) || bar > 1) return void 0;
|
|
48415
|
+
return bar;
|
|
48416
|
+
}
|
|
48417
|
+
//#endregion
|
|
47986
48418
|
//#region src/pipeline-analytics/viewer-settings-legacy-adoption.ts
|
|
47987
48419
|
/**
|
|
47988
48420
|
* @durable class=config owner=pipeline-analytics
|
|
@@ -48691,6 +49123,298 @@ async function extractCropRaw(frameData, frameWidth, frameHeight, bbox) {
|
|
|
48691
49123
|
};
|
|
48692
49124
|
}
|
|
48693
49125
|
//#endregion
|
|
49126
|
+
//#region src/pipeline-analytics/pipeline/bbox-pad.ts
|
|
49127
|
+
/**
|
|
49128
|
+
* Pad a normalized bounding box by a fractional amount relative to its own
|
|
49129
|
+
* dimensions. All coordinates remain in the [0, 1] unit-square.
|
|
49130
|
+
*
|
|
49131
|
+
* Formula (before clamping):
|
|
49132
|
+
* newX = x - padding * w
|
|
49133
|
+
* newW = w + 2 * padding * w (i.e. w * (1 + 2 * padding))
|
|
49134
|
+
* newY = y - padding * h
|
|
49135
|
+
* newH = h + 2 * padding * h
|
|
49136
|
+
*
|
|
49137
|
+
* padding === 0 is the identity operation. Pure, immutable — always returns a
|
|
49138
|
+
* new object.
|
|
49139
|
+
*
|
|
49140
|
+
* NOTE: clamping SHIFTS, it does not truncate — a window that overflows a
|
|
49141
|
+
* bound slides inward and keeps the extent that was asked for, on either side.
|
|
49142
|
+
* It shrinks only when the padded window is larger than the frame itself
|
|
49143
|
+
* would (e.g. a left-hugging box gains a bit more right-side context). This is
|
|
49144
|
+
* acceptable for detection crops (more context, never out of [0,1]); the
|
|
49145
|
+
* downstream `extractCrop` clamps to pixel bounds again regardless.
|
|
49146
|
+
*/
|
|
49147
|
+
function padBbox(bbox, padding) {
|
|
49148
|
+
const rawX = bbox.x - padding * bbox.w;
|
|
49149
|
+
const rawY = bbox.y - padding * bbox.h;
|
|
49150
|
+
const rawW = bbox.w * (1 + 2 * padding);
|
|
49151
|
+
const rawH = bbox.h * (1 + 2 * padding);
|
|
49152
|
+
const w = Math.min(rawW, 1);
|
|
49153
|
+
const h = Math.min(rawH, 1);
|
|
49154
|
+
return {
|
|
49155
|
+
x: Math.min(Math.max(0, rawX), 1 - w),
|
|
49156
|
+
y: Math.min(Math.max(0, rawY), 1 - h),
|
|
49157
|
+
w,
|
|
49158
|
+
h
|
|
49159
|
+
};
|
|
49160
|
+
}
|
|
49161
|
+
/**
|
|
49162
|
+
* Legibility floor: the window's SHORT side is grown until it is at least this
|
|
49163
|
+
* fraction of the frame's short side.
|
|
49164
|
+
*
|
|
49165
|
+
* The 2026-09-18 traces included a 24x18 px subject. At the context ratio alone
|
|
49166
|
+
* that is a 72x54 window — and 72x54 source pixels are 72x54 delivered pixels,
|
|
49167
|
+
* because nothing in this repo may upscale. Growing the RECTANGLE is the only
|
|
49168
|
+
* honest way to make the picture bigger: more real pixels arrive, the subject
|
|
49169
|
+
* occupies less of the frame, and its drawn box says which part of it is the
|
|
49170
|
+
* subject. 0.18 of 1080 = 194 px, which renders a 24 px subject recognisably in
|
|
49171
|
+
* its surroundings.
|
|
49172
|
+
*/
|
|
49173
|
+
var DEBUG_CROP_MIN_SIDE_FRAC = .18;
|
|
49174
|
+
function unionBox(a, b) {
|
|
49175
|
+
const x = Math.min(a.x, b.x);
|
|
49176
|
+
const y = Math.min(a.y, b.y);
|
|
49177
|
+
const right = Math.max(a.x + a.w, b.x + b.w);
|
|
49178
|
+
const bottom = Math.max(a.y + a.h, b.y + b.h);
|
|
49179
|
+
return {
|
|
49180
|
+
x,
|
|
49181
|
+
y,
|
|
49182
|
+
w: right - x,
|
|
49183
|
+
h: bottom - y
|
|
49184
|
+
};
|
|
49185
|
+
}
|
|
49186
|
+
/**
|
|
49187
|
+
* Which borders a box touches, on `edge-clear.ts`'s tolerance band — the SAME
|
|
49188
|
+
* band `isEdgeClear` uses, so "clipped" here and "not edge-clear" there can
|
|
49189
|
+
* never disagree about one frame.
|
|
49190
|
+
*/
|
|
49191
|
+
function debugCropClipSides(bbox, frameWidth, frameHeight) {
|
|
49192
|
+
if (frameWidth <= 0 || frameHeight <= 0) return [];
|
|
49193
|
+
const tolX = DEFAULT_EDGE_TOLERANCE * frameWidth;
|
|
49194
|
+
const tolY = DEFAULT_EDGE_TOLERANCE * frameHeight;
|
|
49195
|
+
const sides = [];
|
|
49196
|
+
if (bbox.x <= tolX) sides.push("left");
|
|
49197
|
+
if (bbox.y <= tolY) sides.push("top");
|
|
49198
|
+
if (bbox.x + bbox.w >= frameWidth - tolX) sides.push("right");
|
|
49199
|
+
if (bbox.y + bbox.h >= frameHeight - tolY) sides.push("bottom");
|
|
49200
|
+
return sides;
|
|
49201
|
+
}
|
|
49202
|
+
/**
|
|
49203
|
+
* The ratio that makes `base` reach the legibility floor, or
|
|
49204
|
+
* {@link DEBUG_CROP_CONTEXT_RATIO} when it already does.
|
|
49205
|
+
*
|
|
49206
|
+
* Expressed as a PADDING RATIO rather than as a second rectangle so the window
|
|
49207
|
+
* still comes out of one {@link padBbox} call: there is exactly one place in
|
|
49208
|
+
* this file where a rectangle is grown, and it is not here.
|
|
49209
|
+
*/
|
|
49210
|
+
function contextRatioFor(base, frameWidth, frameHeight) {
|
|
49211
|
+
const minSide = DEBUG_CROP_MIN_SIDE_FRAC * Math.min(frameWidth, frameHeight);
|
|
49212
|
+
const ratioFor = (extent) => extent <= 0 ? 1 : (minSide / extent - 1) / 2;
|
|
49213
|
+
const needed = Math.max(ratioFor(base.w), ratioFor(base.h));
|
|
49214
|
+
return Math.max(1, needed);
|
|
49215
|
+
}
|
|
49216
|
+
/**
|
|
49217
|
+
* The one debug-crop rectangle. Pure.
|
|
49218
|
+
*
|
|
49219
|
+
* A refusal is a VALUE, not a `null`: the caller logs the reason, because a
|
|
49220
|
+
* branch that stores nothing and says nothing reads as "never happened".
|
|
49221
|
+
*/
|
|
49222
|
+
function planDebugCrop(input) {
|
|
49223
|
+
const { frameWidth, frameHeight, subject } = input;
|
|
49224
|
+
if (frameWidth <= 0 || frameHeight <= 0) return {
|
|
49225
|
+
kind: "refused",
|
|
49226
|
+
reason: "degenerate-frame"
|
|
49227
|
+
};
|
|
49228
|
+
if (subject.w <= 0 || subject.h <= 0) return {
|
|
49229
|
+
kind: "refused",
|
|
49230
|
+
reason: "degenerate-subject"
|
|
49231
|
+
};
|
|
49232
|
+
const base = input.rival === void 0 ? subject : unionBox(subject, input.rival);
|
|
49233
|
+
const contextRatio = contextRatioFor(base, frameWidth, frameHeight);
|
|
49234
|
+
const norm = padBbox({
|
|
49235
|
+
x: base.x / frameWidth,
|
|
49236
|
+
y: base.y / frameHeight,
|
|
49237
|
+
w: base.w / frameWidth,
|
|
49238
|
+
h: base.h / frameHeight
|
|
49239
|
+
}, contextRatio);
|
|
49240
|
+
const rect = {
|
|
49241
|
+
x: norm.x * frameWidth,
|
|
49242
|
+
y: norm.y * frameHeight,
|
|
49243
|
+
w: norm.w * frameWidth,
|
|
49244
|
+
h: norm.h * frameHeight
|
|
49245
|
+
};
|
|
49246
|
+
const relative = (role, box) => ({
|
|
49247
|
+
role,
|
|
49248
|
+
x: box.x - rect.x,
|
|
49249
|
+
y: box.y - rect.y,
|
|
49250
|
+
w: box.w,
|
|
49251
|
+
h: box.h
|
|
49252
|
+
});
|
|
49253
|
+
const boxes = input.rival === void 0 ? [relative("subject", subject)] : [relative("subject", subject), relative("rival", input.rival)];
|
|
49254
|
+
return {
|
|
49255
|
+
kind: "plan",
|
|
49256
|
+
decision: input.decision,
|
|
49257
|
+
rect,
|
|
49258
|
+
norm,
|
|
49259
|
+
boxes,
|
|
49260
|
+
contextRatio,
|
|
49261
|
+
floorApplied: contextRatio > 1,
|
|
49262
|
+
clippedSides: debugCropClipSides(subject, frameWidth, frameHeight),
|
|
49263
|
+
subjectCenterNorm: {
|
|
49264
|
+
x: (subject.x + subject.w / 2) / frameWidth,
|
|
49265
|
+
y: (subject.y + subject.h / 2) / frameHeight
|
|
49266
|
+
}
|
|
49267
|
+
};
|
|
49268
|
+
}
|
|
49269
|
+
function formatRect(rect) {
|
|
49270
|
+
return `${Math.round(rect.x)},${Math.round(rect.y)} ${Math.round(rect.w)}x${Math.round(rect.h)}`;
|
|
49271
|
+
}
|
|
49272
|
+
/**
|
|
49273
|
+
* The attribution bag: the deciding site's evidence FIRST, then the structural
|
|
49274
|
+
* facts — so no evidence key can rename the decision, the track or the
|
|
49275
|
+
* rectangle the crop belongs to. That ordering is the whole point: a crop whose
|
|
49276
|
+
* own label can be overwritten by a caller is back to being unattributable.
|
|
49277
|
+
*/
|
|
49278
|
+
function debugCropMeta(plan, attribution) {
|
|
49279
|
+
return {
|
|
49280
|
+
...attribution.evidence,
|
|
49281
|
+
decision: attribution.decision,
|
|
49282
|
+
trackId: attribution.trackId,
|
|
49283
|
+
...attribution.rivalTrackId === void 0 ? {} : { rivalTrackId: attribution.rivalTrackId },
|
|
49284
|
+
timestamp: attribution.timestamp,
|
|
49285
|
+
cropRect: formatRect(plan.rect),
|
|
49286
|
+
contextRatio: plan.contextRatio,
|
|
49287
|
+
floorApplied: plan.floorApplied,
|
|
49288
|
+
clippedSides: plan.clippedSides.join("|"),
|
|
49289
|
+
subjectCenter: `${plan.subjectCenterNorm.x.toFixed(3)},${plan.subjectCenterNorm.y.toFixed(3)}`
|
|
49290
|
+
};
|
|
49291
|
+
}
|
|
49292
|
+
/**
|
|
49293
|
+
* The caption burnt into the crop's own pixels.
|
|
49294
|
+
*
|
|
49295
|
+
* Burnt in, not stored beside it, because a debug crop travels: it is exported,
|
|
49296
|
+
* pasted into a note, and looked at months later next to five others. A blob
|
|
49297
|
+
* that cannot say what it is, on its own, is the thing that made the last
|
|
49298
|
+
* investigation slow — and this store has no column to say it in.
|
|
49299
|
+
*/
|
|
49300
|
+
function debugCropLabel(plan, attribution) {
|
|
49301
|
+
return `${`${attribution.decision} ${attribution.trackId}`}${attribution.rivalTrackId === void 0 ? "" : ` vs ${attribution.rivalTrackId}`}${plan.clippedSides.length === 0 ? "" : ` clipped:${plan.clippedSides.join("+")}`} ${Object.entries(attribution.evidence).map(([k, v]) => `${k}=${typeof v === "number" ? Math.round(v * 1e3) / 1e3 : String(v)}`).join(" ")}`.trim().slice(0, 160);
|
|
49302
|
+
}
|
|
49303
|
+
//#endregion
|
|
49304
|
+
//#region src/pipeline-analytics/pipeline/debug-crop-capture.ts
|
|
49305
|
+
/** Stroke colour of the box the decision was ABOUT. */
|
|
49306
|
+
var SUBJECT_COLOR = "#00e5ff";
|
|
49307
|
+
/** Stroke colour of the box it was weighed AGAINST — a different colour,
|
|
49308
|
+
* because "which one is which" is the first question a two-box crop raises. */
|
|
49309
|
+
var RIVAL_COLOR = "#ff9500";
|
|
49310
|
+
/** Encode quality: a debug crop is read by a person looking for a defect, so it
|
|
49311
|
+
* is encoded above the timeline-tile default (80) — a JPEG artefact that reads
|
|
49312
|
+
* as a mirror edge would waste the very investigation this exists for. */
|
|
49313
|
+
var DEBUG_CROP_QUALITY = 92;
|
|
49314
|
+
/** Bound the per-track counters so a long-lived process never leaks them. */
|
|
49315
|
+
var MAX_TRACKED_TRACKS = 2e3;
|
|
49316
|
+
function createDebugCropCapture(deps) {
|
|
49317
|
+
const storedPerTrack = /* @__PURE__ */ new Map();
|
|
49318
|
+
const cappedAnnounced = /* @__PURE__ */ new Set();
|
|
49319
|
+
const skip = (input, reason, extra) => {
|
|
49320
|
+
deps.logger.info("debug crop not stored", {
|
|
49321
|
+
tags: { deviceId: input.deviceId },
|
|
49322
|
+
meta: {
|
|
49323
|
+
reason,
|
|
49324
|
+
decision: input.decision,
|
|
49325
|
+
trackId: input.trackId,
|
|
49326
|
+
timestamp: input.timestamp,
|
|
49327
|
+
...extra
|
|
49328
|
+
}
|
|
49329
|
+
});
|
|
49330
|
+
return null;
|
|
49331
|
+
};
|
|
49332
|
+
return async (input) => {
|
|
49333
|
+
if (!deps.isDebugMarked(input.trackId)) return null;
|
|
49334
|
+
const already = storedPerTrack.get(input.trackId) ?? 0;
|
|
49335
|
+
if (already >= 6) {
|
|
49336
|
+
if (cappedAnnounced.has(input.trackId)) return null;
|
|
49337
|
+
if (cappedAnnounced.size >= MAX_TRACKED_TRACKS) cappedAnnounced.clear();
|
|
49338
|
+
cappedAnnounced.add(input.trackId);
|
|
49339
|
+
return skip(input, "per-track-cap", {
|
|
49340
|
+
stored: already,
|
|
49341
|
+
cap: 6
|
|
49342
|
+
});
|
|
49343
|
+
}
|
|
49344
|
+
const plan = planDebugCrop({
|
|
49345
|
+
decision: input.decision,
|
|
49346
|
+
subject: input.subject,
|
|
49347
|
+
...input.rival === void 0 ? {} : { rival: input.rival },
|
|
49348
|
+
frameWidth: input.frameWidth,
|
|
49349
|
+
frameHeight: input.frameHeight
|
|
49350
|
+
});
|
|
49351
|
+
if (plan.kind === "refused") return skip(input, plan.reason, {});
|
|
49352
|
+
const attribution = {
|
|
49353
|
+
decision: input.decision,
|
|
49354
|
+
trackId: input.trackId,
|
|
49355
|
+
...input.rivalTrackId === void 0 ? {} : { rivalTrackId: input.rivalTrackId },
|
|
49356
|
+
timestamp: input.timestamp,
|
|
49357
|
+
evidence: input.evidence
|
|
49358
|
+
};
|
|
49359
|
+
const meta = debugCropMeta(plan, attribution);
|
|
49360
|
+
const frame = await deps.fetchFrameRgb(input.frameHandle, input.deviceId, input.trackId);
|
|
49361
|
+
if (frame === null || frame.width <= 0 || frame.height <= 0) return skip(input, "frame-unavailable", { nodeId: input.frameHandle.nodeId });
|
|
49362
|
+
let jpeg;
|
|
49363
|
+
try {
|
|
49364
|
+
const cut = await deps.extractCropRaw(frame.bytes, frame.width, frame.height, plan.norm);
|
|
49365
|
+
const scale = plan.rect.w > 0 ? cut.width / plan.rect.w : 1;
|
|
49366
|
+
const boxes = plan.boxes.map((b) => {
|
|
49367
|
+
const geometry = {
|
|
49368
|
+
x: b.x * scale,
|
|
49369
|
+
y: b.y * scale,
|
|
49370
|
+
w: b.w * scale,
|
|
49371
|
+
h: b.h * scale
|
|
49372
|
+
};
|
|
49373
|
+
return b.role === "subject" ? {
|
|
49374
|
+
...geometry,
|
|
49375
|
+
color: SUBJECT_COLOR,
|
|
49376
|
+
label: debugCropLabel(plan, attribution)
|
|
49377
|
+
} : {
|
|
49378
|
+
...geometry,
|
|
49379
|
+
color: RIVAL_COLOR,
|
|
49380
|
+
label: "rival"
|
|
49381
|
+
};
|
|
49382
|
+
});
|
|
49383
|
+
jpeg = await deps.drawBoxedFrame(cut.data, cut.width, cut.height, boxes, { quality: DEBUG_CROP_QUALITY });
|
|
49384
|
+
} catch (err) {
|
|
49385
|
+
return skip(input, "render-failed", { error: err instanceof Error ? err.message : String(err) });
|
|
49386
|
+
}
|
|
49387
|
+
try {
|
|
49388
|
+
const mediaId = await deps.putDebugCrop({
|
|
49389
|
+
deviceId: input.deviceId,
|
|
49390
|
+
trackId: input.trackId,
|
|
49391
|
+
timestamp: input.timestamp,
|
|
49392
|
+
data: jpeg
|
|
49393
|
+
});
|
|
49394
|
+
if (storedPerTrack.size >= MAX_TRACKED_TRACKS) storedPerTrack.clear();
|
|
49395
|
+
storedPerTrack.set(input.trackId, already + 1);
|
|
49396
|
+
deps.logger.info("debug crop stored", {
|
|
49397
|
+
tags: { deviceId: input.deviceId },
|
|
49398
|
+
meta: {
|
|
49399
|
+
...meta,
|
|
49400
|
+
mediaId,
|
|
49401
|
+
deviceId: input.deviceId
|
|
49402
|
+
}
|
|
49403
|
+
});
|
|
49404
|
+
return mediaId;
|
|
49405
|
+
} catch (err) {
|
|
49406
|
+
deps.logger.warn("debug crop store failed", {
|
|
49407
|
+
tags: { deviceId: input.deviceId },
|
|
49408
|
+
meta: {
|
|
49409
|
+
...meta,
|
|
49410
|
+
error: err instanceof Error ? err.message : String(err)
|
|
49411
|
+
}
|
|
49412
|
+
});
|
|
49413
|
+
return null;
|
|
49414
|
+
}
|
|
49415
|
+
};
|
|
49416
|
+
}
|
|
49417
|
+
//#endregion
|
|
48694
49418
|
//#region src/shared/frame/encode-crop.ts
|
|
48695
49419
|
/**
|
|
48696
49420
|
* JPEG-encode an already-cropped raw RGB (24-bit) buffer. Used for the
|
|
@@ -53482,42 +54206,6 @@ var AnalyticsFailureReport = class {
|
|
|
53482
54206
|
}
|
|
53483
54207
|
};
|
|
53484
54208
|
//#endregion
|
|
53485
|
-
//#region src/pipeline-analytics/pipeline/bbox-pad.ts
|
|
53486
|
-
/**
|
|
53487
|
-
* Pad a normalized bounding box by a fractional amount relative to its own
|
|
53488
|
-
* dimensions. All coordinates remain in the [0, 1] unit-square.
|
|
53489
|
-
*
|
|
53490
|
-
* Formula (before clamping):
|
|
53491
|
-
* newX = x - padding * w
|
|
53492
|
-
* newW = w + 2 * padding * w (i.e. w * (1 + 2 * padding))
|
|
53493
|
-
* newY = y - padding * h
|
|
53494
|
-
* newH = h + 2 * padding * h
|
|
53495
|
-
*
|
|
53496
|
-
* padding === 0 is the identity operation. Pure, immutable — always returns a
|
|
53497
|
-
* new object.
|
|
53498
|
-
*
|
|
53499
|
-
* NOTE: clamping SHIFTS, it does not truncate — a window that overflows a
|
|
53500
|
-
* bound slides inward and keeps the extent that was asked for, on either side.
|
|
53501
|
-
* It shrinks only when the padded window is larger than the frame itself
|
|
53502
|
-
* would (e.g. a left-hugging box gains a bit more right-side context). This is
|
|
53503
|
-
* acceptable for detection crops (more context, never out of [0,1]); the
|
|
53504
|
-
* downstream `extractCrop` clamps to pixel bounds again regardless.
|
|
53505
|
-
*/
|
|
53506
|
-
function padBbox(bbox, padding) {
|
|
53507
|
-
const rawX = bbox.x - padding * bbox.w;
|
|
53508
|
-
const rawY = bbox.y - padding * bbox.h;
|
|
53509
|
-
const rawW = bbox.w * (1 + 2 * padding);
|
|
53510
|
-
const rawH = bbox.h * (1 + 2 * padding);
|
|
53511
|
-
const w = Math.min(rawW, 1);
|
|
53512
|
-
const h = Math.min(rawH, 1);
|
|
53513
|
-
return {
|
|
53514
|
-
x: Math.min(Math.max(0, rawX), 1 - w),
|
|
53515
|
-
y: Math.min(Math.max(0, rawY), 1 - h),
|
|
53516
|
-
w,
|
|
53517
|
-
h
|
|
53518
|
-
};
|
|
53519
|
-
}
|
|
53520
|
-
//#endregion
|
|
53521
54209
|
//#region src/pipeline-analytics/pipeline/capture-crop.ts
|
|
53522
54210
|
/**
|
|
53523
54211
|
* The normalised window a capture asks the runner for: the box padded by
|
|
@@ -71343,6 +72031,56 @@ var SyntheticTrackMaterializer = class {
|
|
|
71343
72031
|
return track;
|
|
71344
72032
|
}
|
|
71345
72033
|
};
|
|
72034
|
+
//#endregion
|
|
72035
|
+
//#region src/pipeline-analytics/stationary-settings.ts
|
|
72036
|
+
/**
|
|
72037
|
+
* Per-device stationary-object (parked/idle suppression) settings. Cascade: a
|
|
72038
|
+
* per-device override on top of the global default, resolved per field (an
|
|
72039
|
+
* invalid/missing value falls back to its default — parse never throws).
|
|
72040
|
+
* Mirrors `media-settings` / `tracking-settings`.
|
|
72041
|
+
*
|
|
72042
|
+
* The defaults are the SAME constants the registry uses at runtime
|
|
72043
|
+
* (`stationary-types.ts`), imported (not copied) so an unset value and a reset
|
|
72044
|
+
* value both resolve to exactly today's behaviour — "reset == unset == today",
|
|
72045
|
+
* with no drift. Guarded by a unit test asserting the equality.
|
|
72046
|
+
*/
|
|
72047
|
+
var StationarySettingsSchema = object({
|
|
72048
|
+
/** Master switch. Off ⇒ this camera gathers no evidence and suppresses
|
|
72049
|
+
* nothing (every parked object keeps spawning normal tracks). */
|
|
72050
|
+
enabled: boolean().default(true),
|
|
72051
|
+
/** IoU at/above which a detection is the same parked object, unmoved →
|
|
72052
|
+
* suppress its spawn. `SUPPRESS_IOU`. */
|
|
72053
|
+
suppressIou: number().min(.3).max(.9).default(SUPPRESS_IOU),
|
|
72054
|
+
/** Minimum IoU for a detection to be ASSOCIATED with an entry (confirm or
|
|
72055
|
+
* wake it) — the overlap gate that stops a different vehicle from waking a
|
|
72056
|
+
* parked entry (the 617 flood fix). `WAKE_ASSOC_IOU`. */
|
|
72057
|
+
wakeAssocIou: number().min(.02).max(.5).default(WAKE_ASSOC_IOU),
|
|
72058
|
+
/** Recent-window stillness after which a track stops holding its motion
|
|
72059
|
+
* session open (also the minimum track age). Creates nothing — birth is
|
|
72060
|
+
* decided by repeated evidence. `SESSION_STILLNESS_WINDOW_MS`. */
|
|
72061
|
+
sessionStillnessWindowMs: number().int().min(5e3).max(12e4).default(SESSION_STILLNESS_WINDOW_MS),
|
|
72062
|
+
/** Observed-time TTL: retire an entry unconfirmed for this long (measured on
|
|
72063
|
+
* frames-flowing time, not wall-clock). `ENTRY_TTL_MS`. */
|
|
72064
|
+
entryTtlMs: number().int().min(6e4).max(18e5).default(ENTRY_TTL_MS)
|
|
72065
|
+
});
|
|
72066
|
+
var STATIONARY_DEFAULTS = StationarySettingsSchema.parse({});
|
|
72067
|
+
/**
|
|
72068
|
+
* Resolve a per-device store blob into typed stationary settings. Unknown/invalid
|
|
72069
|
+
* fields fall back to the default for that field (never throws on a bad blob).
|
|
72070
|
+
*/
|
|
72071
|
+
function resolveStationarySettings(raw) {
|
|
72072
|
+
const pick = (key) => {
|
|
72073
|
+
const parsed = StationarySettingsSchema.shape[key].safeParse(raw[key]);
|
|
72074
|
+
return parsed.success ? parsed.data : STATIONARY_DEFAULTS[key];
|
|
72075
|
+
};
|
|
72076
|
+
return {
|
|
72077
|
+
enabled: pick("enabled"),
|
|
72078
|
+
suppressIou: pick("suppressIou"),
|
|
72079
|
+
wakeAssocIou: pick("wakeAssocIou"),
|
|
72080
|
+
sessionStillnessWindowMs: pick("sessionStillnessWindowMs"),
|
|
72081
|
+
entryTtlMs: pick("entryTtlMs")
|
|
72082
|
+
};
|
|
72083
|
+
}
|
|
71346
72084
|
var TrackingSettingsSchema = object({
|
|
71347
72085
|
/**
|
|
71348
72086
|
* How much of a detection's box must lie inside a zone (0-1 fraction of the
|
|
@@ -71543,56 +72281,6 @@ function resolveTrackingSettings(raw) {
|
|
|
71543
72281
|
};
|
|
71544
72282
|
}
|
|
71545
72283
|
//#endregion
|
|
71546
|
-
//#region src/pipeline-analytics/stationary-settings.ts
|
|
71547
|
-
/**
|
|
71548
|
-
* Per-device stationary-object (parked/idle suppression) settings. Cascade: a
|
|
71549
|
-
* per-device override on top of the global default, resolved per field (an
|
|
71550
|
-
* invalid/missing value falls back to its default — parse never throws).
|
|
71551
|
-
* Mirrors `media-settings` / `tracking-settings`.
|
|
71552
|
-
*
|
|
71553
|
-
* The defaults are the SAME constants the registry uses at runtime
|
|
71554
|
-
* (`stationary-types.ts`), imported (not copied) so an unset value and a reset
|
|
71555
|
-
* value both resolve to exactly today's behaviour — "reset == unset == today",
|
|
71556
|
-
* with no drift. Guarded by a unit test asserting the equality.
|
|
71557
|
-
*/
|
|
71558
|
-
var StationarySettingsSchema = object({
|
|
71559
|
-
/** Master switch. Off ⇒ this camera gathers no evidence and suppresses
|
|
71560
|
-
* nothing (every parked object keeps spawning normal tracks). */
|
|
71561
|
-
enabled: boolean().default(true),
|
|
71562
|
-
/** IoU at/above which a detection is the same parked object, unmoved →
|
|
71563
|
-
* suppress its spawn. `SUPPRESS_IOU`. */
|
|
71564
|
-
suppressIou: number().min(.3).max(.9).default(SUPPRESS_IOU),
|
|
71565
|
-
/** Minimum IoU for a detection to be ASSOCIATED with an entry (confirm or
|
|
71566
|
-
* wake it) — the overlap gate that stops a different vehicle from waking a
|
|
71567
|
-
* parked entry (the 617 flood fix). `WAKE_ASSOC_IOU`. */
|
|
71568
|
-
wakeAssocIou: number().min(.02).max(.5).default(WAKE_ASSOC_IOU),
|
|
71569
|
-
/** Recent-window stillness after which a track stops holding its motion
|
|
71570
|
-
* session open (also the minimum track age). Creates nothing — birth is
|
|
71571
|
-
* decided by repeated evidence. `SESSION_STILLNESS_WINDOW_MS`. */
|
|
71572
|
-
sessionStillnessWindowMs: number().int().min(5e3).max(12e4).default(SESSION_STILLNESS_WINDOW_MS),
|
|
71573
|
-
/** Observed-time TTL: retire an entry unconfirmed for this long (measured on
|
|
71574
|
-
* frames-flowing time, not wall-clock). `ENTRY_TTL_MS`. */
|
|
71575
|
-
entryTtlMs: number().int().min(6e4).max(18e5).default(ENTRY_TTL_MS)
|
|
71576
|
-
});
|
|
71577
|
-
var STATIONARY_DEFAULTS = StationarySettingsSchema.parse({});
|
|
71578
|
-
/**
|
|
71579
|
-
* Resolve a per-device store blob into typed stationary settings. Unknown/invalid
|
|
71580
|
-
* fields fall back to the default for that field (never throws on a bad blob).
|
|
71581
|
-
*/
|
|
71582
|
-
function resolveStationarySettings(raw) {
|
|
71583
|
-
const pick = (key) => {
|
|
71584
|
-
const parsed = StationarySettingsSchema.shape[key].safeParse(raw[key]);
|
|
71585
|
-
return parsed.success ? parsed.data : STATIONARY_DEFAULTS[key];
|
|
71586
|
-
};
|
|
71587
|
-
return {
|
|
71588
|
-
enabled: pick("enabled"),
|
|
71589
|
-
suppressIou: pick("suppressIou"),
|
|
71590
|
-
wakeAssocIou: pick("wakeAssocIou"),
|
|
71591
|
-
sessionStillnessWindowMs: pick("sessionStillnessWindowMs"),
|
|
71592
|
-
entryTtlMs: pick("entryTtlMs")
|
|
71593
|
-
};
|
|
71594
|
-
}
|
|
71595
|
-
//#endregion
|
|
71596
72284
|
//#region src/pipeline-analytics/settings-schema-sections.ts
|
|
71597
72285
|
/** The detection-knob sections, in their original in-form order. */
|
|
71598
72286
|
function buildDetectionSettingsSections() {
|
|
@@ -72138,10 +72826,45 @@ function buildDetectionSettingsSections() {
|
|
|
72138
72826
|
displayScale: 1e3
|
|
72139
72827
|
}
|
|
72140
72828
|
]
|
|
72141
|
-
}
|
|
72829
|
+
},
|
|
72830
|
+
buildStillnessScaleSection()
|
|
72142
72831
|
];
|
|
72143
72832
|
}
|
|
72144
72833
|
/**
|
|
72834
|
+
* The per-camera STILLNESS SCALE — one measured number plus its provenance.
|
|
72835
|
+
*
|
|
72836
|
+
* Its own section rather than a field on `detection-sensitivity` because the
|
|
72837
|
+
* two answer different questions: `stationaryThresholdSec` is HOW LONG a
|
|
72838
|
+
* settled subject must stay settled, which is an operator preference; this is
|
|
72839
|
+
* HOW SLOW counts as settled, which is a fact about the camera and is
|
|
72840
|
+
* measured. Mixing them is how a measurement ends up being guessed at.
|
|
72841
|
+
*/
|
|
72842
|
+
function buildStillnessScaleSection() {
|
|
72843
|
+
return {
|
|
72844
|
+
id: "stillness-scale",
|
|
72845
|
+
title: "Stillness scale (per camera)",
|
|
72846
|
+
description: "How slowly a subject has to move on THIS camera before it counts as settled. A camera looking down a 30 m driveway and one watching a doorway two metres away disagree about this by more than an order of magnitude, so one fleet-wide number cannot be right for both. Leave it at 0 and the camera behaves exactly as it always has (a flat 2 px-per-update bar). Run the `stillness.calibrate` action to measure it from the camera’s own recorded tracks — the run reports what it measured and REFUSES, with a reason, when the evidence is too thin.",
|
|
72847
|
+
columns: 1,
|
|
72848
|
+
fields: [{
|
|
72849
|
+
type: "number",
|
|
72850
|
+
key: "stillnessSpeedFracPerSec",
|
|
72851
|
+
label: "Stillness speed bar",
|
|
72852
|
+
description: "Centroid speed, as a fraction of the subject’s OWN box diagonal, per second, at or below which the subject is not travelling. Subject-relative, so perspective is already divided out; per second, so an irregular on-motion cadence cannot masquerade as speed. 0 = not calibrated (the shipped absolute bar decides, exactly as before). Live reference: 0.0136 on a close, mostly-parked view; 0.3081 on a far view whose subjects only transit.",
|
|
72853
|
+
min: 0,
|
|
72854
|
+
max: 1,
|
|
72855
|
+
step: .001,
|
|
72856
|
+
default: STILLNESS_DEFAULTS.stillnessSpeedFracPerSec
|
|
72857
|
+
}, {
|
|
72858
|
+
type: "text",
|
|
72859
|
+
key: "stillnessCalibrationNote",
|
|
72860
|
+
label: "Last calibration",
|
|
72861
|
+
description: "What the last run measured, from how many samples and tracks, and what it chose — or which named reason it refused for. Display only.",
|
|
72862
|
+
default: STILLNESS_DEFAULTS.stillnessCalibrationNote,
|
|
72863
|
+
readonlyField: true
|
|
72864
|
+
}]
|
|
72865
|
+
};
|
|
72866
|
+
}
|
|
72867
|
+
/**
|
|
72145
72868
|
* Re-home the global analytics sections into the per-device `Analytics`
|
|
72146
72869
|
* top-tab. Every section defaults to the `Analytics` tab (so the
|
|
72147
72870
|
* device-manager aggregator groups them) AND is marked
|
|
@@ -72167,6 +72890,7 @@ function toAnalyticsDeviceSections(sections) {
|
|
|
72167
72890
|
* - `tracking` — the tracker-tuning form incl. the FP knobs (dedup, person↔
|
|
72168
72891
|
* animal dedup, class voting, confirm-bypass, per-class min score).
|
|
72169
72892
|
* - `stationary-objects` — stationary promotion + occupancy tuning.
|
|
72893
|
+
* - `stillness-scale` — the per-camera measured "how slow is settled" bar.
|
|
72170
72894
|
* `DeviceDetail` folds `tab: 'detection-pipeline'` top-tab sections into the
|
|
72171
72895
|
* structural Detection pipeline tab, so re-tagging is all that's needed —
|
|
72172
72896
|
* there is no admin-ui change and no duplicate render (a section has one tab).
|
|
@@ -72175,7 +72899,8 @@ var DETECTION_PIPELINE_SECTION_IDS = new Set([
|
|
|
72175
72899
|
"detection-sensitivity",
|
|
72176
72900
|
"confirmation-gate",
|
|
72177
72901
|
"tracking",
|
|
72178
|
-
"stationary-objects"
|
|
72902
|
+
"stationary-objects",
|
|
72903
|
+
"stillness-scale"
|
|
72179
72904
|
]);
|
|
72180
72905
|
/**
|
|
72181
72906
|
* Re-home the detection-knob sections from the `Analytics` top-tab onto the
|
|
@@ -75907,9 +76632,23 @@ var customActions = {
|
|
|
75907
76632
|
...locationDiscardActions,
|
|
75908
76633
|
...viewerSettingsActions,
|
|
75909
76634
|
...photoEnrollActions,
|
|
75910
|
-
...faceRescoreActions
|
|
76635
|
+
...faceRescoreActions,
|
|
76636
|
+
...stillnessCalibrationActions
|
|
75911
76637
|
};
|
|
75912
76638
|
/**
|
|
76639
|
+
* Width cap on the ONE full frame a debug crop is cut from.
|
|
76640
|
+
*
|
|
76641
|
+
* A debug crop is a picture for a person, and the plan has already widened the
|
|
76642
|
+
* window to a neighbourhood — past this width the extra pixels buy nothing and
|
|
76643
|
+
* a 4K native surface would cross the transport for every marked track.
|
|
76644
|
+
*/
|
|
76645
|
+
var DEBUG_CROP_FETCH_MAX_WIDTH = 1280;
|
|
76646
|
+
/** How long a queued debug-crop frame fetch is still worth executing. Past
|
|
76647
|
+
* this the handle's frame has aged out of the runner's retention and the next
|
|
76648
|
+
* decision will ask again with a young one, so the queued entry is dead
|
|
76649
|
+
* weight in a lane real media is waiting in. */
|
|
76650
|
+
var DEBUG_CROP_FETCH_STALE_MS = 2e3;
|
|
76651
|
+
/**
|
|
75913
76652
|
* Assist threshold when the caller does not pin one. Packages have their own
|
|
75914
76653
|
* (0.35, on the zone crop only — `retrain/retrain-assist.ts`); everything else
|
|
75915
76654
|
* starts here and the operator drops the ones they do not want.
|
|
@@ -76734,6 +77473,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
76734
77473
|
* the frame path like the stationary one and a cold miss answers the
|
|
76735
77474
|
* measured default. */
|
|
76736
77475
|
summarySettingsCache = this.settingsCaches.registerDeviceCache("summary", SETTINGS_CACHE_TTL_MS);
|
|
77476
|
+
/** Per-device STILLNESS SCALE cache — the measured "how slow is settled" bar
|
|
77477
|
+
* (`stillness-settings.ts`). Read once per FrameProcessor build, so this is
|
|
77478
|
+
* a cold-path cache; it exists so a calibration applied through
|
|
77479
|
+
* `stillness.calibrate` reaches the tracker on the next processor rebuild
|
|
77480
|
+
* rather than on the next addon restart. */
|
|
77481
|
+
stillnessSettingsCache = this.settingsCaches.registerDeviceCache("stillness", SETTINGS_CACHE_TTL_MS);
|
|
76737
77482
|
/** Turns stationary appear/depart into package-delivered/picked-up events. */
|
|
76738
77483
|
packageDropDetector = null;
|
|
76739
77484
|
/**
|
|
@@ -76943,7 +77688,51 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
76943
77688
|
* `docs/decisions/` — the gate is a model input, so this is a deliberate
|
|
76944
77689
|
* exception to "model-input crops keep captureCrop".
|
|
76945
77690
|
*/
|
|
77691
|
+
/**
|
|
77692
|
+
* The operator-debug crop capture (`pipeline/debug-crop-capture.ts`).
|
|
77693
|
+
*
|
|
77694
|
+
* Null until the transport and the media store exist. Reached from three
|
|
77695
|
+
* decision sites and gated INSIDE the capture on the track's `debug` flag, so
|
|
77696
|
+
* a camera with no marked track pays one map lookup per decision — see the
|
|
77697
|
+
* capture's module doc for why the gate lives there and not at each site.
|
|
77698
|
+
*/
|
|
77699
|
+
captureDebugCropFn = null;
|
|
76946
77700
|
captureDisplayCropFn = null;
|
|
77701
|
+
/**
|
|
77702
|
+
* Offer one decision's pixels to the operator-debug crop capture.
|
|
77703
|
+
*
|
|
77704
|
+
* Fire-and-forget and NEVER throws: this is the addon's own bookkeeping, and
|
|
77705
|
+
* a frame path that can be slowed or broken by a diagnostic is worse than no
|
|
77706
|
+
* diagnostic. The gate, the per-track cap and all six refusal reasons live in
|
|
77707
|
+
* the capture (`pipeline/debug-crop-capture.ts`); this is only the seam.
|
|
77708
|
+
*/
|
|
77709
|
+
/**
|
|
77710
|
+
* The CURRENT frame's handle and dims, per camera — the one thing the
|
|
77711
|
+
* salvage-bind observer lacks.
|
|
77712
|
+
*
|
|
77713
|
+
* The tracker knows no `deviceId`, holds no handle and writes no media (that
|
|
77714
|
+
* is the point of `pipeline/tracker/**`), so a growth bind reports its two
|
|
77715
|
+
* boxes and stops. This mirror is what lets the host put those boxes back on
|
|
77716
|
+
* a picture. Written ONLY while some track on this runner is marked
|
|
77717
|
+
* (`residents.anyDebugMarked()`), so an ordinary fleet does not allocate one
|
|
77718
|
+
* object per frame per camera for a diagnostic nobody asked for.
|
|
77719
|
+
*/
|
|
77720
|
+
debugFrameContext = /* @__PURE__ */ new Map();
|
|
77721
|
+
offerDebugCrop(input) {
|
|
77722
|
+
const capture = this.captureDebugCropFn;
|
|
77723
|
+
if (capture === null) return;
|
|
77724
|
+
capture(input).catch((err) => {
|
|
77725
|
+
this.ctx.logger.debug("debug crop offer threw", {
|
|
77726
|
+
tags: { deviceId: input.deviceId },
|
|
77727
|
+
meta: {
|
|
77728
|
+
deviceId: input.deviceId,
|
|
77729
|
+
trackId: input.trackId,
|
|
77730
|
+
decision: input.decision,
|
|
77731
|
+
error: err instanceof Error ? err.message : String(err)
|
|
77732
|
+
}
|
|
77733
|
+
});
|
|
77734
|
+
});
|
|
77735
|
+
}
|
|
76947
77736
|
captureCrop = null;
|
|
76948
77737
|
/** Full NATIVE frame by handle (downscaled worker-side to `maxWidth`), routed
|
|
76949
77738
|
* to the owning runner, PLUS the source `tier`. Captured in the async init
|
|
@@ -77308,6 +78097,36 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
77308
78097
|
isNodeOffline: (nodeId) => this.nodeLiveness.isKnownOffline(nodeId, Date.now())
|
|
77309
78098
|
});
|
|
77310
78099
|
this.captureCrop = transport.captureCrop;
|
|
78100
|
+
const fetchDebugFullFrame = transport.getNativeFullFrameRgb;
|
|
78101
|
+
this.captureDebugCropFn = createDebugCropCapture({
|
|
78102
|
+
isDebugMarked: (trackId) => this.residents.isDebugMarked(trackId),
|
|
78103
|
+
fetchFrameRgb: async (handle, deviceId, trackId) => {
|
|
78104
|
+
const frame = await this.captureScheduler.request({
|
|
78105
|
+
deviceId,
|
|
78106
|
+
trackId,
|
|
78107
|
+
kind: "other",
|
|
78108
|
+
coalesceKey: captureCoalesceKey(deviceId, "other", trackId),
|
|
78109
|
+
staleAfterMs: DEBUG_CROP_FETCH_STALE_MS,
|
|
78110
|
+
exec: () => fetchDebugFullFrame(handle, DEBUG_CROP_FETCH_MAX_WIDTH)
|
|
78111
|
+
});
|
|
78112
|
+
return frame === null || frame === void 0 ? null : {
|
|
78113
|
+
bytes: frame.data,
|
|
78114
|
+
width: frame.width,
|
|
78115
|
+
height: frame.height
|
|
78116
|
+
};
|
|
78117
|
+
},
|
|
78118
|
+
extractCropRaw: (data, w, h, box) => extractCropRaw(data, w, h, box),
|
|
78119
|
+
drawBoxedFrame: (data, w, h, boxes, options) => drawBoxedFrame(data, w, h, boxes, options ?? {}),
|
|
78120
|
+
putDebugCrop: (put) => stores.mediaStore.put({
|
|
78121
|
+
deviceId: put.deviceId,
|
|
78122
|
+
ownerKind: "track",
|
|
78123
|
+
ownerId: put.trackId,
|
|
78124
|
+
kind: "debugCrop",
|
|
78125
|
+
timestamp: put.timestamp,
|
|
78126
|
+
data: put.data
|
|
78127
|
+
}),
|
|
78128
|
+
logger: logger.child("DebugCrop")
|
|
78129
|
+
});
|
|
77311
78130
|
this.eventMediaDispatcher = new EventMediaDispatcher({
|
|
77312
78131
|
getRemoteFrame: transport.getRemoteFrame,
|
|
77313
78132
|
getNativeCropJpeg: transport.getNativeCropJpeg,
|
|
@@ -77364,7 +78183,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
77364
78183
|
...locationDiscardActions,
|
|
77365
78184
|
...viewerSettingsActions,
|
|
77366
78185
|
...photoEnrollActions,
|
|
77367
|
-
...faceRescoreActions
|
|
78186
|
+
...faceRescoreActions,
|
|
78187
|
+
...stillnessCalibrationActions
|
|
77368
78188
|
},
|
|
77369
78189
|
actionHandlers: {
|
|
77370
78190
|
...ncHandlers,
|
|
@@ -77372,6 +78192,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
77372
78192
|
...this.buildDebugActionHandlers(),
|
|
77373
78193
|
...this.buildPhotoEnrollActionHandlers(),
|
|
77374
78194
|
...this.buildFaceRescoreActionHandlers(),
|
|
78195
|
+
...this.buildStillnessCalibrationActionHandlers(),
|
|
77375
78196
|
...this.viewerSettingsSnapshots ? makeViewerSettingsActionHandlers(this.viewerSettingsSnapshots) : {}
|
|
77376
78197
|
}
|
|
77377
78198
|
} : {}
|
|
@@ -80308,6 +81129,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
80308
81129
|
frameHeight: result.frameHeight
|
|
80309
81130
|
}, exhaustedIds);
|
|
80310
81131
|
const outcome = run.outcome;
|
|
81132
|
+
if (frameHandle !== void 0 && this.residents.anyDebugMarked()) this.debugFrameContext.set(deviceId, {
|
|
81133
|
+
handle: frameHandle,
|
|
81134
|
+
width: result.frameWidth,
|
|
81135
|
+
height: result.frameHeight,
|
|
81136
|
+
timestamp: result.timestamp
|
|
81137
|
+
});
|
|
80311
81138
|
const gateNowMs = Date.now();
|
|
80312
81139
|
for (const { id, t } of bornCandidates) {
|
|
80313
81140
|
if (outcome.undecided.has(id)) {
|
|
@@ -80366,6 +81193,32 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
80366
81193
|
error: String(err)
|
|
80367
81194
|
} });
|
|
80368
81195
|
});
|
|
81196
|
+
if (frameHandle !== void 0) {
|
|
81197
|
+
const decision = run.decisions.get(id);
|
|
81198
|
+
this.offerDebugCrop({
|
|
81199
|
+
deviceId,
|
|
81200
|
+
trackId: id,
|
|
81201
|
+
decision: "birth-confirmation",
|
|
81202
|
+
subject: {
|
|
81203
|
+
x: t.bbox.x,
|
|
81204
|
+
y: t.bbox.y,
|
|
81205
|
+
w: t.bbox.w,
|
|
81206
|
+
h: t.bbox.h
|
|
81207
|
+
},
|
|
81208
|
+
frameHandle,
|
|
81209
|
+
frameWidth: result.frameWidth,
|
|
81210
|
+
frameHeight: result.frameHeight,
|
|
81211
|
+
timestamp: gateNowMs,
|
|
81212
|
+
evidence: {
|
|
81213
|
+
className: t.className,
|
|
81214
|
+
source,
|
|
81215
|
+
verdict: "suppression-held",
|
|
81216
|
+
...decision?.cropSource !== void 0 ? { cropSource: decision.cropSource } : {},
|
|
81217
|
+
...decision?.bestScore !== void 0 ? { bestScore: decision.bestScore } : {},
|
|
81218
|
+
judgedCropKept: judged !== void 0
|
|
81219
|
+
}
|
|
81220
|
+
});
|
|
81221
|
+
}
|
|
80369
81222
|
log.info("birth suppression held — the gate is not this root detector", {
|
|
80370
81223
|
tags: { deviceId },
|
|
80371
81224
|
meta: {
|
|
@@ -81323,6 +82176,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
81323
82176
|
async resolveDeviceStationarySettings(deviceId) {
|
|
81324
82177
|
return this.stationarySettingsCache.get(deviceId, (id) => this.readDeviceSettings(id, resolveStationarySettings));
|
|
81325
82178
|
}
|
|
82179
|
+
/** Per-device stillness scale. An absent/zero bar resolves to `undefined`
|
|
82180
|
+
* ({@link resolveStillSpeedFracPerSec}) and leaves the state analyzer on
|
|
82181
|
+
* its original absolute threshold — uncalibrated is exactly today. */
|
|
82182
|
+
async resolveDeviceStillnessSettings(deviceId) {
|
|
82183
|
+
return this.stillnessSettingsCache.get(deviceId, (id) => this.readDeviceSettings(id, resolveStillnessSettings));
|
|
82184
|
+
}
|
|
81326
82185
|
/**
|
|
81327
82186
|
* Count SHORT + MOTIONLESS track closes per frame cell.
|
|
81328
82187
|
*
|
|
@@ -82394,6 +83253,32 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
82394
83253
|
meta: { ...bestShotPromotionMeta(promotionAttempt) }
|
|
82395
83254
|
});
|
|
82396
83255
|
}
|
|
83256
|
+
if (frameHandle !== void 0) {
|
|
83257
|
+
const heldBbox = this.residents.bestSeenBbox(t.trackId);
|
|
83258
|
+
const rival = heldBbox !== void 0 && heldBbox.frameWidth === frameWidth && heldBbox.frameHeight === frameHeight ? {
|
|
83259
|
+
x: heldBbox.x,
|
|
83260
|
+
y: heldBbox.y,
|
|
83261
|
+
w: heldBbox.w,
|
|
83262
|
+
h: heldBbox.h
|
|
83263
|
+
} : void 0;
|
|
83264
|
+
this.offerDebugCrop({
|
|
83265
|
+
deviceId,
|
|
83266
|
+
trackId: t.trackId,
|
|
83267
|
+
decision: "best-shot",
|
|
83268
|
+
subject: {
|
|
83269
|
+
x: t.bbox.x,
|
|
83270
|
+
y: t.bbox.y,
|
|
83271
|
+
w: t.bbox.w,
|
|
83272
|
+
h: t.bbox.h
|
|
83273
|
+
},
|
|
83274
|
+
...rival !== void 0 ? { rival } : {},
|
|
83275
|
+
frameHandle,
|
|
83276
|
+
frameWidth,
|
|
83277
|
+
frameHeight,
|
|
83278
|
+
timestamp,
|
|
83279
|
+
evidence: { ...bestShotPromotionMeta(promotionAttempt) }
|
|
83280
|
+
});
|
|
83281
|
+
}
|
|
82397
83282
|
if (!plan.appendSnapshot && !rollingLastFrame && !bestThumbnail && keyFrame === "skip") continue;
|
|
82398
83283
|
targets.push({
|
|
82399
83284
|
trackId: t.trackId,
|
|
@@ -83693,6 +84578,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
83693
84578
|
if (!p) {
|
|
83694
84579
|
const { minHits, stationaryThresholdSec, minTrackAge, cooldownSec } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
|
|
83695
84580
|
const trk = await this.resolveDeviceTrackingSettings(deviceId);
|
|
84581
|
+
const stillSpeedFracPerSec = resolveStillSpeedFracPerSec(await this.resolveDeviceStillnessSettings(deviceId));
|
|
83696
84582
|
p = new FrameProcessor(deviceId, {
|
|
83697
84583
|
minHits,
|
|
83698
84584
|
iouThreshold: trk.iouThreshold,
|
|
@@ -83734,7 +84620,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
83734
84620
|
vehicle: trk.minSpawnAreaFracVehicle
|
|
83735
84621
|
},
|
|
83736
84622
|
classGroupAssoc: trk.classGroupAssoc
|
|
83737
|
-
}, {
|
|
84623
|
+
}, {
|
|
84624
|
+
stationaryThresholdSec,
|
|
84625
|
+
...stillSpeedFracPerSec !== void 0 ? { stillSpeedFracPerSec } : {}
|
|
84626
|
+
}, {
|
|
83738
84627
|
minTrackAge,
|
|
83739
84628
|
cooldownSec,
|
|
83740
84629
|
minTrackAgeMs: trk.minTrackAgeMs
|
|
@@ -83747,6 +84636,33 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
83747
84636
|
...record
|
|
83748
84637
|
}
|
|
83749
84638
|
});
|
|
84639
|
+
if (record.gate !== "growth") return;
|
|
84640
|
+
const frame = this.debugFrameContext.get(deviceId);
|
|
84641
|
+
if (frame === void 0 || frame.width !== record.frameWidth) return;
|
|
84642
|
+
this.offerDebugCrop({
|
|
84643
|
+
deviceId,
|
|
84644
|
+
trackId: record.trackId,
|
|
84645
|
+
decision: "growth-rescue",
|
|
84646
|
+
subject: record.trackBbox,
|
|
84647
|
+
rival: record.detBbox,
|
|
84648
|
+
frameHandle: frame.handle,
|
|
84649
|
+
frameWidth: record.frameWidth,
|
|
84650
|
+
frameHeight: record.frameHeight,
|
|
84651
|
+
timestamp: frame.timestamp,
|
|
84652
|
+
evidence: {
|
|
84653
|
+
stage: record.stage,
|
|
84654
|
+
trackClass: record.trackClass,
|
|
84655
|
+
detClass: record.detClass,
|
|
84656
|
+
detScore: record.detScore,
|
|
84657
|
+
iou: record.iou,
|
|
84658
|
+
centroidDistPx: record.centroidDistPx,
|
|
84659
|
+
trackDiagPx: record.trackDiagPx,
|
|
84660
|
+
areaRatio: record.areaRatio,
|
|
84661
|
+
dtMs: record.dtMs,
|
|
84662
|
+
trackAgeMs: record.trackAgeMs,
|
|
84663
|
+
source
|
|
84664
|
+
}
|
|
84665
|
+
});
|
|
83750
84666
|
});
|
|
83751
84667
|
p.setSpawnRefusalObserver((record) => {
|
|
83752
84668
|
this.ctx.logger.info("tracker: detection dropped — nothing downstream received it", {
|
|
@@ -85429,6 +86345,109 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
85429
86345
|
buildFaceRescoreActionHandlers() {
|
|
85430
86346
|
return { "face.rescoreTrack": (input) => this.rescoreFaceForTrack(input) };
|
|
85431
86347
|
}
|
|
86348
|
+
buildStillnessCalibrationActionHandlers() {
|
|
86349
|
+
return { "stillness.calibrate": (input) => this.calibrateStillnessForDevice(input) };
|
|
86350
|
+
}
|
|
86351
|
+
/**
|
|
86352
|
+
* Measure ONE camera's stillness scale from its own recorded tracks.
|
|
86353
|
+
*
|
|
86354
|
+
* Cost-bounded by construction: one `queryRecent` page of at most
|
|
86355
|
+
* `maxTracks` rows over the requested window, `projection: 'full'` because
|
|
86356
|
+
* the trajectory IS the measurement, and nothing else is read. The arithmetic
|
|
86357
|
+
* is `stillness-scale.ts` and is pure — this method only fetches, reports and
|
|
86358
|
+
* (on `apply`) writes.
|
|
86359
|
+
*
|
|
86360
|
+
* A REFUSAL never writes the bar. It does write the note, because "why is
|
|
86361
|
+
* this camera still on the default" has to be answerable from the form
|
|
86362
|
+
* rather than from a toast nobody kept.
|
|
86363
|
+
*/
|
|
86364
|
+
async calibrateStillnessForDevice(input) {
|
|
86365
|
+
const trackStore = this.trackStore;
|
|
86366
|
+
if (!trackStore) throw new Error("stillness.calibrate: the track store is not ready");
|
|
86367
|
+
const until = Date.now();
|
|
86368
|
+
const since = until - Math.round(input.sinceHours * 36e5);
|
|
86369
|
+
const limits = {
|
|
86370
|
+
...DEFAULT_STILLNESS_CALIBRATION_LIMITS,
|
|
86371
|
+
maxTracks: input.maxTracks
|
|
86372
|
+
};
|
|
86373
|
+
const previous = resolveStillSpeedFracPerSec(await this.resolveDeviceStillnessSettings(input.deviceId));
|
|
86374
|
+
const samples = collectStillnessSamples((await trackStore.queryRecent({
|
|
86375
|
+
deviceIds: [input.deviceId],
|
|
86376
|
+
since,
|
|
86377
|
+
until,
|
|
86378
|
+
limit: limits.maxTracks,
|
|
86379
|
+
projection: "full"
|
|
86380
|
+
})).tracks.map((t) => ({
|
|
86381
|
+
trackId: t.trackId,
|
|
86382
|
+
className: t.className,
|
|
86383
|
+
positions: t.positions.map((pos) => ({
|
|
86384
|
+
x: pos.x,
|
|
86385
|
+
y: pos.y,
|
|
86386
|
+
timestamp: pos.timestamp,
|
|
86387
|
+
bbox: {
|
|
86388
|
+
w: pos.bbox.w,
|
|
86389
|
+
h: pos.bbox.h
|
|
86390
|
+
}
|
|
86391
|
+
}))
|
|
86392
|
+
})), limits);
|
|
86393
|
+
const outcome = calibrateStillnessSpeed(samples, limits);
|
|
86394
|
+
const note = describeStillnessCalibration(outcome);
|
|
86395
|
+
const finite = (v) => Number.isFinite(v) ? v : null;
|
|
86396
|
+
const measurement = {
|
|
86397
|
+
trackCount: outcome.measurement.trackCount,
|
|
86398
|
+
tracksRead: samples.tracksRead,
|
|
86399
|
+
sampleCount: outcome.measurement.sampleCount,
|
|
86400
|
+
p25: finite(outcome.measurement.p25),
|
|
86401
|
+
p50: finite(outcome.measurement.p50),
|
|
86402
|
+
p90: finite(outcome.measurement.p90),
|
|
86403
|
+
medianIntervalSec: finite(outcome.measurement.medianIntervalSec),
|
|
86404
|
+
medianSubjectDiagonalPx: finite(outcome.measurement.medianSubjectDiagonalPx)
|
|
86405
|
+
};
|
|
86406
|
+
let applied = false;
|
|
86407
|
+
if (input.apply) {
|
|
86408
|
+
const patch = {
|
|
86409
|
+
stillnessCalibrationNote: note,
|
|
86410
|
+
stillnessCalibratedAt: until
|
|
86411
|
+
};
|
|
86412
|
+
if (outcome.ok) patch["stillnessSpeedFracPerSec"] = outcome.bar;
|
|
86413
|
+
await this.applyDeviceSettingsPatch({
|
|
86414
|
+
deviceId: input.deviceId,
|
|
86415
|
+
patch
|
|
86416
|
+
});
|
|
86417
|
+
applied = outcome.ok;
|
|
86418
|
+
}
|
|
86419
|
+
this.ctx.logger.info("stillness calibration", {
|
|
86420
|
+
tags: { deviceId: input.deviceId },
|
|
86421
|
+
meta: {
|
|
86422
|
+
accepted: outcome.ok,
|
|
86423
|
+
...outcome.ok ? {} : { reason: outcome.reason },
|
|
86424
|
+
...outcome.ok ? {
|
|
86425
|
+
bar: outcome.bar,
|
|
86426
|
+
clampedBy: outcome.clampedBy
|
|
86427
|
+
} : {},
|
|
86428
|
+
previousBar: previous ?? null,
|
|
86429
|
+
applied,
|
|
86430
|
+
windowHours: input.sinceHours,
|
|
86431
|
+
...measurement
|
|
86432
|
+
}
|
|
86433
|
+
});
|
|
86434
|
+
return {
|
|
86435
|
+
deviceId: input.deviceId,
|
|
86436
|
+
since,
|
|
86437
|
+
until,
|
|
86438
|
+
accepted: outcome.ok,
|
|
86439
|
+
...outcome.ok ? {} : { reason: outcome.reason },
|
|
86440
|
+
...outcome.ok ? {
|
|
86441
|
+
bar: outcome.bar,
|
|
86442
|
+
rawBar: outcome.rawBar,
|
|
86443
|
+
clampedBy: outcome.clampedBy
|
|
86444
|
+
} : {},
|
|
86445
|
+
measurement,
|
|
86446
|
+
previousBar: previous ?? null,
|
|
86447
|
+
applied,
|
|
86448
|
+
note
|
|
86449
|
+
};
|
|
86450
|
+
}
|
|
85432
86451
|
buildPhotoEnrollActionHandlers() {
|
|
85433
86452
|
return {
|
|
85434
86453
|
"photo.analyze": async (input) => {
|
|
@@ -85893,6 +86912,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
85893
86912
|
logger: this.ctx.logger,
|
|
85894
86913
|
...this.debugNoteArchive !== null ? { noteArchive: this.debugNoteArchive } : {}
|
|
85895
86914
|
}, input);
|
|
86915
|
+
this.residents.setDebugMarked(input.deviceId, input.trackId, out.debug);
|
|
85896
86916
|
this.ctx.logger.info("track operator flags set", {
|
|
85897
86917
|
tags: { deviceId: input.deviceId },
|
|
85898
86918
|
meta: {
|
|
@@ -86510,6 +87530,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
86510
87530
|
this.trackingSettingsCache.delete(input.deviceId);
|
|
86511
87531
|
this.confirmationGateSettingsCache.delete(input.deviceId);
|
|
86512
87532
|
this.mediaSettingsCache.delete(input.deviceId);
|
|
87533
|
+
this.stillnessSettingsCache.delete(input.deviceId);
|
|
86513
87534
|
this.forgetDeviceProcessors(input.deviceId);
|
|
86514
87535
|
return { success: true };
|
|
86515
87536
|
}
|