@camstack/addon-post-analysis 1.2.267 → 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.
@@ -38359,7 +38359,7 @@ var StateAnalyzer = class {
38359
38359
  }
38360
38360
  const velocity = track.velocity ? Math.sqrt(track.velocity.dx ** 2 + track.velocity.dy ** 2) : 0;
38361
38361
  const dwellTimeMs = timestamp - ts.enteredAt;
38362
- const settled = velocity <= this.config.velocityThreshold || this.windowSaysStill(ts, track.bbox, timestamp);
38362
+ const settled = this.speedSaysSettled(ts, track.bbox, velocity) || this.windowSaysStill(ts, track.bbox, timestamp);
38363
38363
  let state;
38364
38364
  if (ts.frameCount <= this.config.enteringFrames) state = "entering";
38365
38365
  else if (settled) {
@@ -38395,6 +38395,37 @@ var StateAnalyzer = class {
38395
38395
  return results;
38396
38396
  }
38397
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
+ /**
38398
38429
  * Whether the centroid has stayed inside a small span for the whole window.
38399
38430
  *
38400
38431
  * The span is measured across the samples themselves (their bounding box),
@@ -48054,6 +48085,336 @@ function makeViewerSettingsActionHandlers(store) {
48054
48085
  };
48055
48086
  }
48056
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
48057
48418
  //#region src/pipeline-analytics/viewer-settings-legacy-adoption.ts
48058
48419
  /**
48059
48420
  * @durable class=config owner=pipeline-analytics
@@ -71670,6 +72031,56 @@ var SyntheticTrackMaterializer = class {
71670
72031
  return track;
71671
72032
  }
71672
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
+ }
71673
72084
  var TrackingSettingsSchema = object({
71674
72085
  /**
71675
72086
  * How much of a detection's box must lie inside a zone (0-1 fraction of the
@@ -71870,56 +72281,6 @@ function resolveTrackingSettings(raw) {
71870
72281
  };
71871
72282
  }
71872
72283
  //#endregion
71873
- //#region src/pipeline-analytics/stationary-settings.ts
71874
- /**
71875
- * Per-device stationary-object (parked/idle suppression) settings. Cascade: a
71876
- * per-device override on top of the global default, resolved per field (an
71877
- * invalid/missing value falls back to its default — parse never throws).
71878
- * Mirrors `media-settings` / `tracking-settings`.
71879
- *
71880
- * The defaults are the SAME constants the registry uses at runtime
71881
- * (`stationary-types.ts`), imported (not copied) so an unset value and a reset
71882
- * value both resolve to exactly today's behaviour — "reset == unset == today",
71883
- * with no drift. Guarded by a unit test asserting the equality.
71884
- */
71885
- var StationarySettingsSchema = object({
71886
- /** Master switch. Off ⇒ this camera gathers no evidence and suppresses
71887
- * nothing (every parked object keeps spawning normal tracks). */
71888
- enabled: boolean().default(true),
71889
- /** IoU at/above which a detection is the same parked object, unmoved →
71890
- * suppress its spawn. `SUPPRESS_IOU`. */
71891
- suppressIou: number().min(.3).max(.9).default(SUPPRESS_IOU),
71892
- /** Minimum IoU for a detection to be ASSOCIATED with an entry (confirm or
71893
- * wake it) — the overlap gate that stops a different vehicle from waking a
71894
- * parked entry (the 617 flood fix). `WAKE_ASSOC_IOU`. */
71895
- wakeAssocIou: number().min(.02).max(.5).default(WAKE_ASSOC_IOU),
71896
- /** Recent-window stillness after which a track stops holding its motion
71897
- * session open (also the minimum track age). Creates nothing — birth is
71898
- * decided by repeated evidence. `SESSION_STILLNESS_WINDOW_MS`. */
71899
- sessionStillnessWindowMs: number().int().min(5e3).max(12e4).default(SESSION_STILLNESS_WINDOW_MS),
71900
- /** Observed-time TTL: retire an entry unconfirmed for this long (measured on
71901
- * frames-flowing time, not wall-clock). `ENTRY_TTL_MS`. */
71902
- entryTtlMs: number().int().min(6e4).max(18e5).default(ENTRY_TTL_MS)
71903
- });
71904
- var STATIONARY_DEFAULTS = StationarySettingsSchema.parse({});
71905
- /**
71906
- * Resolve a per-device store blob into typed stationary settings. Unknown/invalid
71907
- * fields fall back to the default for that field (never throws on a bad blob).
71908
- */
71909
- function resolveStationarySettings(raw) {
71910
- const pick = (key) => {
71911
- const parsed = StationarySettingsSchema.shape[key].safeParse(raw[key]);
71912
- return parsed.success ? parsed.data : STATIONARY_DEFAULTS[key];
71913
- };
71914
- return {
71915
- enabled: pick("enabled"),
71916
- suppressIou: pick("suppressIou"),
71917
- wakeAssocIou: pick("wakeAssocIou"),
71918
- sessionStillnessWindowMs: pick("sessionStillnessWindowMs"),
71919
- entryTtlMs: pick("entryTtlMs")
71920
- };
71921
- }
71922
- //#endregion
71923
72284
  //#region src/pipeline-analytics/settings-schema-sections.ts
71924
72285
  /** The detection-knob sections, in their original in-form order. */
71925
72286
  function buildDetectionSettingsSections() {
@@ -72465,10 +72826,45 @@ function buildDetectionSettingsSections() {
72465
72826
  displayScale: 1e3
72466
72827
  }
72467
72828
  ]
72468
- }
72829
+ },
72830
+ buildStillnessScaleSection()
72469
72831
  ];
72470
72832
  }
72471
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
+ /**
72472
72868
  * Re-home the global analytics sections into the per-device `Analytics`
72473
72869
  * top-tab. Every section defaults to the `Analytics` tab (so the
72474
72870
  * device-manager aggregator groups them) AND is marked
@@ -72494,6 +72890,7 @@ function toAnalyticsDeviceSections(sections) {
72494
72890
  * - `tracking` — the tracker-tuning form incl. the FP knobs (dedup, person↔
72495
72891
  * animal dedup, class voting, confirm-bypass, per-class min score).
72496
72892
  * - `stationary-objects` — stationary promotion + occupancy tuning.
72893
+ * - `stillness-scale` — the per-camera measured "how slow is settled" bar.
72497
72894
  * `DeviceDetail` folds `tab: 'detection-pipeline'` top-tab sections into the
72498
72895
  * structural Detection pipeline tab, so re-tagging is all that's needed —
72499
72896
  * there is no admin-ui change and no duplicate render (a section has one tab).
@@ -72502,7 +72899,8 @@ var DETECTION_PIPELINE_SECTION_IDS = new Set([
72502
72899
  "detection-sensitivity",
72503
72900
  "confirmation-gate",
72504
72901
  "tracking",
72505
- "stationary-objects"
72902
+ "stationary-objects",
72903
+ "stillness-scale"
72506
72904
  ]);
72507
72905
  /**
72508
72906
  * Re-home the detection-knob sections from the `Analytics` top-tab onto the
@@ -76234,7 +76632,8 @@ var customActions = {
76234
76632
  ...locationDiscardActions,
76235
76633
  ...viewerSettingsActions,
76236
76634
  ...photoEnrollActions,
76237
- ...faceRescoreActions
76635
+ ...faceRescoreActions,
76636
+ ...stillnessCalibrationActions
76238
76637
  };
76239
76638
  /**
76240
76639
  * Width cap on the ONE full frame a debug crop is cut from.
@@ -77074,6 +77473,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
77074
77473
  * the frame path like the stationary one and a cold miss answers the
77075
77474
  * measured default. */
77076
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);
77077
77482
  /** Turns stationary appear/depart into package-delivered/picked-up events. */
77078
77483
  packageDropDetector = null;
77079
77484
  /**
@@ -77778,7 +78183,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
77778
78183
  ...locationDiscardActions,
77779
78184
  ...viewerSettingsActions,
77780
78185
  ...photoEnrollActions,
77781
- ...faceRescoreActions
78186
+ ...faceRescoreActions,
78187
+ ...stillnessCalibrationActions
77782
78188
  },
77783
78189
  actionHandlers: {
77784
78190
  ...ncHandlers,
@@ -77786,6 +78192,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
77786
78192
  ...this.buildDebugActionHandlers(),
77787
78193
  ...this.buildPhotoEnrollActionHandlers(),
77788
78194
  ...this.buildFaceRescoreActionHandlers(),
78195
+ ...this.buildStillnessCalibrationActionHandlers(),
77789
78196
  ...this.viewerSettingsSnapshots ? makeViewerSettingsActionHandlers(this.viewerSettingsSnapshots) : {}
77790
78197
  }
77791
78198
  } : {}
@@ -81769,6 +82176,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
81769
82176
  async resolveDeviceStationarySettings(deviceId) {
81770
82177
  return this.stationarySettingsCache.get(deviceId, (id) => this.readDeviceSettings(id, resolveStationarySettings));
81771
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
+ }
81772
82185
  /**
81773
82186
  * Count SHORT + MOTIONLESS track closes per frame cell.
81774
82187
  *
@@ -84165,6 +84578,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
84165
84578
  if (!p) {
84166
84579
  const { minHits, stationaryThresholdSec, minTrackAge, cooldownSec } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
84167
84580
  const trk = await this.resolveDeviceTrackingSettings(deviceId);
84581
+ const stillSpeedFracPerSec = resolveStillSpeedFracPerSec(await this.resolveDeviceStillnessSettings(deviceId));
84168
84582
  p = new FrameProcessor(deviceId, {
84169
84583
  minHits,
84170
84584
  iouThreshold: trk.iouThreshold,
@@ -84206,7 +84620,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
84206
84620
  vehicle: trk.minSpawnAreaFracVehicle
84207
84621
  },
84208
84622
  classGroupAssoc: trk.classGroupAssoc
84209
- }, { stationaryThresholdSec }, {
84623
+ }, {
84624
+ stationaryThresholdSec,
84625
+ ...stillSpeedFracPerSec !== void 0 ? { stillSpeedFracPerSec } : {}
84626
+ }, {
84210
84627
  minTrackAge,
84211
84628
  cooldownSec,
84212
84629
  minTrackAgeMs: trk.minTrackAgeMs
@@ -85928,6 +86345,109 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
85928
86345
  buildFaceRescoreActionHandlers() {
85929
86346
  return { "face.rescoreTrack": (input) => this.rescoreFaceForTrack(input) };
85930
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
+ }
85931
86451
  buildPhotoEnrollActionHandlers() {
85932
86452
  return {
85933
86453
  "photo.analyze": async (input) => {
@@ -87010,6 +87530,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
87010
87530
  this.trackingSettingsCache.delete(input.deviceId);
87011
87531
  this.confirmationGateSettingsCache.delete(input.deviceId);
87012
87532
  this.mediaSettingsCache.delete(input.deviceId);
87533
+ this.stillnessSettingsCache.delete(input.deviceId);
87013
87534
  this.forgetDeviceProcessors(input.deviceId);
87014
87535
  return { success: true };
87015
87536
  }