@camstack/addon-post-analysis 1.2.18 → 1.2.19
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/pipeline-analytics/index.js +267 -42
- package/dist/pipeline-analytics/index.mjs +267 -42
- package/package.json +1 -1
|
@@ -2302,16 +2302,28 @@ function resolveRuleThreshold(rule) {
|
|
|
2302
2302
|
*/
|
|
2303
2303
|
var ZoneEngine = class {
|
|
2304
2304
|
/**
|
|
2305
|
-
* Annotate a single detection with its zone memberships
|
|
2306
|
-
*
|
|
2307
|
-
*
|
|
2305
|
+
* Annotate a single detection with its zone memberships — every zone whose
|
|
2306
|
+
* polygon the detection overlaps by MORE than `minOverlap` (0–1 fraction of
|
|
2307
|
+
* the detection's own area).
|
|
2308
|
+
*
|
|
2309
|
+
* The previous version of this comment claimed memberships were returned
|
|
2310
|
+
* "above any active rule's threshold"; they were not — the threshold was
|
|
2311
|
+
* hardcoded to {@link MEMBERSHIP_MIN_OVERLAP} (zero) and no rule was ever
|
|
2312
|
+
* consulted. Read the parameter, not this paragraph.
|
|
2313
|
+
*
|
|
2314
|
+
* `minOverlap` matters because membership is what lands on an event as
|
|
2315
|
+
* `zones`, and a notification rule's `zones` condition is a plain set test
|
|
2316
|
+
* over that field — so this, not the zone-RULE threshold, is what decides
|
|
2317
|
+
* whether a zone-scoped notification fires. At the default of 0 a subject
|
|
2318
|
+
* clipping a zone by one pixel counts as inside it (measured 2026-07-30: a
|
|
2319
|
+
* dog overlapping `Aiuola` by 4.8% would have been stamped as in it).
|
|
2308
2320
|
*/
|
|
2309
|
-
annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight) {
|
|
2321
|
+
annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap = MEMBERSHIP_MIN_OVERLAP) {
|
|
2310
2322
|
const memberships = [];
|
|
2311
2323
|
for (const zone of zones) {
|
|
2312
2324
|
const pixelPolygon = zone.polygon.map((p) => normalizeToPixel(p, frameWidth, frameHeight));
|
|
2313
2325
|
const overlap = mask && maskWidth && maskHeight ? maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, pixelPolygon, frameWidth, frameHeight) : bboxPolygonOverlap(bbox, pixelPolygon);
|
|
2314
|
-
if (overlap >
|
|
2326
|
+
if (overlap > minOverlap) memberships.push({
|
|
2315
2327
|
zoneId: zone.id,
|
|
2316
2328
|
zoneName: zone.name,
|
|
2317
2329
|
overlap
|
|
@@ -2411,6 +2423,10 @@ var FrameProcessor = class {
|
|
|
2411
2423
|
* runners that haven't picked up the new gating yet.
|
|
2412
2424
|
*/
|
|
2413
2425
|
detectionRules;
|
|
2426
|
+
/** See {@link setZoneMembershipMinOverlap}. 0 = any positive overlap. */
|
|
2427
|
+
zoneMembershipMinOverlap;
|
|
2428
|
+
/** See {@link getLastZoneOverlaps}. */
|
|
2429
|
+
lastZoneOverlaps;
|
|
2414
2430
|
zoneEngine = new ZoneEngine();
|
|
2415
2431
|
/** Optional stationary-object gate (parked-object suppression). Null until
|
|
2416
2432
|
* the addon wires it via {@link setStationaryGate}. */
|
|
@@ -2423,10 +2439,32 @@ var FrameProcessor = class {
|
|
|
2423
2439
|
this.eventEmitter = new DetectionEventEmitter(emitterConfig);
|
|
2424
2440
|
this.zones = [];
|
|
2425
2441
|
this.detectionRules = [];
|
|
2442
|
+
this.zoneMembershipMinOverlap = 0;
|
|
2443
|
+
this.lastZoneOverlaps = /* @__PURE__ */ new Map();
|
|
2426
2444
|
}
|
|
2427
2445
|
setZones(zones) {
|
|
2428
2446
|
this.zones = zones;
|
|
2429
2447
|
}
|
|
2448
|
+
/**
|
|
2449
|
+
* How much of a detection's box must lie inside a zone for the zone to be
|
|
2450
|
+
* stamped onto it (0–1 fraction of the box's own area).
|
|
2451
|
+
*
|
|
2452
|
+
* DEFAULT 0 — byte-identical to the behaviour before 2026-07-31, where any
|
|
2453
|
+
* positive overlap counted. Raising it is an operator decision and needs
|
|
2454
|
+
* evidence: a bar set blind removes notifications silently, which is the
|
|
2455
|
+
* failure mode this whole area keeps producing. {@link lastZoneOverlaps}
|
|
2456
|
+
* exists so the distribution can be read before a number is picked.
|
|
2457
|
+
*/
|
|
2458
|
+
setZoneMembershipMinOverlap(minOverlap) {
|
|
2459
|
+
this.zoneMembershipMinOverlap = Number.isFinite(minOverlap) && minOverlap >= 0 && minOverlap <= 1 ? minOverlap : 0;
|
|
2460
|
+
}
|
|
2461
|
+
/** Per-track zone memberships WITH their overlap fractions, from the most
|
|
2462
|
+
* recent frame. The engine computes these and the pipeline previously
|
|
2463
|
+
* discarded everything but the ids — which is why no amount of production
|
|
2464
|
+
* data could say how far inside the zone a notifying subject actually was. */
|
|
2465
|
+
getLastZoneOverlaps() {
|
|
2466
|
+
return this.lastZoneOverlaps;
|
|
2467
|
+
}
|
|
2430
2468
|
setDetectionRules(rules) {
|
|
2431
2469
|
this.detectionRules = rules;
|
|
2432
2470
|
}
|
|
@@ -2537,11 +2575,14 @@ var FrameProcessor = class {
|
|
|
2537
2575
|
const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
|
|
2538
2576
|
const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
|
|
2539
2577
|
const zonesByTrack = /* @__PURE__ */ new Map();
|
|
2578
|
+
const overlapsByTrack = /* @__PURE__ */ new Map();
|
|
2540
2579
|
for (const td of trackedDetections) {
|
|
2541
2580
|
const m = maskByBbox.get(td.bbox);
|
|
2542
|
-
const memberships = this.zoneEngine.annotateDetection(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height);
|
|
2581
|
+
const memberships = this.zoneEngine.annotateDetection(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height, this.zoneMembershipMinOverlap);
|
|
2543
2582
|
zonesByTrack.set(td.trackId, memberships.map((m2) => m2.zoneId));
|
|
2583
|
+
if (memberships.length > 0) overlapsByTrack.set(td.trackId, memberships);
|
|
2544
2584
|
}
|
|
2585
|
+
this.lastZoneOverlaps = overlapsByTrack;
|
|
2545
2586
|
const tracked = trackedDetections.map((td) => {
|
|
2546
2587
|
const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === td.trackId)?.state);
|
|
2547
2588
|
const label = resolveDetectionLabel({
|
|
@@ -4635,7 +4676,9 @@ var NcDispatcher = class {
|
|
|
4635
4676
|
ruleId: entry.ruleId,
|
|
4636
4677
|
target: target.name,
|
|
4637
4678
|
kind: target.kind,
|
|
4638
|
-
recordKind: entry.recordKind
|
|
4679
|
+
recordKind: entry.recordKind,
|
|
4680
|
+
eventId: entry.recordId,
|
|
4681
|
+
...entry.trackId !== void 0 ? { trackId: entry.trackId } : {}
|
|
4639
4682
|
}
|
|
4640
4683
|
});
|
|
4641
4684
|
return { ok: true };
|
|
@@ -4673,9 +4716,10 @@ var NcDispatcher = class {
|
|
|
4673
4716
|
async buildNotification(entry) {
|
|
4674
4717
|
const subject = entry.payload.subject;
|
|
4675
4718
|
const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
|
|
4676
|
-
const
|
|
4719
|
+
const zoneLabels = await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
|
|
4720
|
+
const vars = buildTemplateVars(entry, deviceName, zoneLabels);
|
|
4677
4721
|
const title = renderTemplate(entry.payload.template?.title, vars) ?? entry.payload.ruleName;
|
|
4678
|
-
const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName);
|
|
4722
|
+
const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName, zoneLabels);
|
|
4679
4723
|
const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
|
|
4680
4724
|
const params = pickParams(entry.payload.params);
|
|
4681
4725
|
return {
|
|
@@ -4874,15 +4918,37 @@ var NcDispatcher = class {
|
|
|
4874
4918
|
return null;
|
|
4875
4919
|
}
|
|
4876
4920
|
};
|
|
4877
|
-
|
|
4921
|
+
/**
|
|
4922
|
+
* Map admin zone IDs to their display names for rendering only.
|
|
4923
|
+
*
|
|
4924
|
+
* Order follows `zoneIds` (the order the track visited them), not the zone
|
|
4925
|
+
* catalog. Every failure mode degrades to the ID rather than dropping the
|
|
4926
|
+
* zone: an unknown id, a blank name, a throwing lookup, or no lookup wired at
|
|
4927
|
+
* all. A body that silently loses a zone is worse than one that shows a UUID.
|
|
4928
|
+
*/
|
|
4929
|
+
async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
|
|
4930
|
+
if (zoneIds.length === 0) return [];
|
|
4931
|
+
if (getZoneNames === void 0) return [...zoneIds];
|
|
4932
|
+
try {
|
|
4933
|
+
const zones = await getZoneNames(deviceId);
|
|
4934
|
+
const byId = new Map(zones.map((z) => [z.id, z.name]));
|
|
4935
|
+
return zoneIds.map((id) => {
|
|
4936
|
+
const name = byId.get(id);
|
|
4937
|
+
return name !== void 0 && name.trim().length > 0 ? name : id;
|
|
4938
|
+
});
|
|
4939
|
+
} catch {
|
|
4940
|
+
return [...zoneIds];
|
|
4941
|
+
}
|
|
4942
|
+
}
|
|
4943
|
+
function buildTemplateVars(entry, deviceName, zoneLabels) {
|
|
4878
4944
|
const subject = entry.payload.subject;
|
|
4879
4945
|
const occupancy = subject.occupancy;
|
|
4880
4946
|
return {
|
|
4881
4947
|
camera: deviceName,
|
|
4882
4948
|
class: subject.className,
|
|
4883
4949
|
label: subject.label ?? "",
|
|
4884
|
-
zones:
|
|
4885
|
-
zone: occupancy?.zone ??
|
|
4950
|
+
zones: zoneLabels.join(", "),
|
|
4951
|
+
zone: occupancy?.zone ?? zoneLabels[0] ?? "",
|
|
4886
4952
|
confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
|
|
4887
4953
|
time: new Date(subject.timestamp).toLocaleTimeString(),
|
|
4888
4954
|
rule: entry.payload.ruleName,
|
|
@@ -4900,12 +4966,12 @@ function renderTemplate(template, vars) {
|
|
|
4900
4966
|
if (template === void 0 || template.trim().length === 0) return null;
|
|
4901
4967
|
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name) => vars[name] ?? "");
|
|
4902
4968
|
}
|
|
4903
|
-
function defaultBody(entry, deviceName) {
|
|
4969
|
+
function defaultBody(entry, deviceName, zoneLabels) {
|
|
4904
4970
|
const subject = entry.payload.subject;
|
|
4905
4971
|
const occupancy = subject.occupancy;
|
|
4906
4972
|
if (occupancy !== void 0) return `${occupancy.zone ?? deviceName} ${occupancyOpWord(occupancy.occupied)} (${occupancy.count}/${occupancy.capacity})`;
|
|
4907
4973
|
const label = subject.label !== void 0 ? ` (${subject.label})` : "";
|
|
4908
|
-
const zones =
|
|
4974
|
+
const zones = zoneLabels.length > 0 ? ` in ${zoneLabels.join(", ")}` : "";
|
|
4909
4975
|
const suffix = entry.recordKind === "track-end" ? " — visit ended" : "";
|
|
4910
4976
|
return `${subject.className}${label} on ${deviceName}${zones}${suffix}`;
|
|
4911
4977
|
}
|
|
@@ -6683,7 +6749,9 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6683
6749
|
rule: rule.name,
|
|
6684
6750
|
kind,
|
|
6685
6751
|
failed: evaluation.failedCondition,
|
|
6686
|
-
classes: subject.classNames
|
|
6752
|
+
classes: subject.classNames,
|
|
6753
|
+
eventId: subject.recordId,
|
|
6754
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
|
|
6687
6755
|
}
|
|
6688
6756
|
});
|
|
6689
6757
|
continue;
|
|
@@ -6695,7 +6763,9 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6695
6763
|
meta: {
|
|
6696
6764
|
ruleId: rule.id,
|
|
6697
6765
|
rule: rule.name,
|
|
6698
|
-
key
|
|
6766
|
+
key,
|
|
6767
|
+
eventId: subject.recordId,
|
|
6768
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
|
|
6699
6769
|
}
|
|
6700
6770
|
});
|
|
6701
6771
|
continue;
|
|
@@ -6706,7 +6776,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6706
6776
|
ruleId: rule.id,
|
|
6707
6777
|
rule: rule.name,
|
|
6708
6778
|
kind,
|
|
6709
|
-
targets: rule.targets.length
|
|
6779
|
+
targets: rule.targets.length,
|
|
6780
|
+
eventId: subject.recordId,
|
|
6781
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
|
|
6782
|
+
...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
|
|
6783
|
+
...subject.zones.length > 0 ? { zones: subject.zones } : {}
|
|
6710
6784
|
}
|
|
6711
6785
|
});
|
|
6712
6786
|
const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
|
|
@@ -13853,6 +13927,23 @@ function resolveDetectionSensitivitySettings(raw) {
|
|
|
13853
13927
|
};
|
|
13854
13928
|
}
|
|
13855
13929
|
var TrackingSettingsSchema = require_dist.object({
|
|
13930
|
+
/**
|
|
13931
|
+
* How much of a detection's box must lie inside a zone (0-1 fraction of the
|
|
13932
|
+
* box's own area) for that zone to be STAMPED onto the detection.
|
|
13933
|
+
*
|
|
13934
|
+
* This is the field a zone-scoped notification rule ultimately depends on: a
|
|
13935
|
+
* rule's `zones` condition is a plain set test over the stamped zone ids, so
|
|
13936
|
+
* a subject that merely clips a zone edge satisfies it. Measured on
|
|
13937
|
+
* 2026-07-30: a dog overlapping `Aiuola` by 4.8% would have counted as
|
|
13938
|
+
* inside it.
|
|
13939
|
+
*
|
|
13940
|
+
* DEFAULT 0 — byte-identical to the behaviour before 2026-07-31. Raising it
|
|
13941
|
+
* is deliberately an operator decision: the overlap fractions are now logged
|
|
13942
|
+
* (`zone membership` lines), so the bar can be chosen from the distribution
|
|
13943
|
+
* instead of guessed. Distinct from a zone RULE's `bboxInclusionPct`, which
|
|
13944
|
+
* gates the DETECTION stage, not what gets stamped.
|
|
13945
|
+
*/
|
|
13946
|
+
zoneMembershipMinOverlap: require_dist.number().min(0).max(1).default(0),
|
|
13856
13947
|
/** IoU required to match a (predicted) track to a detection. */
|
|
13857
13948
|
iouThreshold: require_dist.number().min(0).max(1).default(.3),
|
|
13858
13949
|
/** Wall-clock coasting budget (ms) before a missed track is dropped. Time-
|
|
@@ -13989,6 +14080,7 @@ function resolveTrackingSettings(raw) {
|
|
|
13989
14080
|
const maxMissedMs = raw.maxMissedMs !== void 0 ? s.maxMissedMs.catch(TRACKING_DEFAULTS.maxMissedMs).parse(raw.maxMissedMs) : raw.maxMissedFrames !== void 0 ? Math.round(maxMissedFrames * 133) : TRACKING_DEFAULTS.maxMissedMs;
|
|
13990
14081
|
const occlusionMaxMissedMs = raw.occlusionMaxMissedMs !== void 0 ? s.occlusionMaxMissedMs.catch(TRACKING_DEFAULTS.occlusionMaxMissedMs).parse(raw.occlusionMaxMissedMs) : raw.occlusionMaxMissedFrames !== void 0 ? Math.round(occlusionMaxMissedFrames * 133) : TRACKING_DEFAULTS.occlusionMaxMissedMs;
|
|
13991
14082
|
return {
|
|
14083
|
+
zoneMembershipMinOverlap: s.zoneMembershipMinOverlap.catch(TRACKING_DEFAULTS.zoneMembershipMinOverlap).parse(raw.zoneMembershipMinOverlap),
|
|
13992
14084
|
iouThreshold: s.iouThreshold.catch(TRACKING_DEFAULTS.iouThreshold).parse(raw.iouThreshold),
|
|
13993
14085
|
maxMissedMs,
|
|
13994
14086
|
minTrackAgeMs: s.minTrackAgeMs.catch(TRACKING_DEFAULTS.minTrackAgeMs).parse(raw.minTrackAgeMs),
|
|
@@ -14043,7 +14135,11 @@ function resolveTrackingSettings(raw) {
|
|
|
14043
14135
|
* low-res detection frame (a static "person" phantom, a parked-truck ghost, a
|
|
14044
14136
|
* misclassified static object).
|
|
14045
14137
|
*
|
|
14046
|
-
*
|
|
14138
|
+
* ON by default (`enabled` defaults to TRUE). An earlier version of this line
|
|
14139
|
+
* said "Ships DORMANT: `enabled` defaults to `false`" — that was stale, and on
|
|
14140
|
+
* 2026-07-30 it nearly produced the conclusion that the gate was not running at
|
|
14141
|
+
* all. It is: it suppressed several phantom births on device 615 that same day.
|
|
14142
|
+
* Read the schema, not this paragraph. Historically the intent was byte-identical
|
|
14047
14143
|
* to today until an operator opts in per camera. The gate is fail-OPEN — any
|
|
14048
14144
|
* missing frame handle, unavailable inference cap, crop-fetch miss, re-detection
|
|
14049
14145
|
* error, or timeout ALLOWS the birth (a real track is never suppressed because
|
|
@@ -14139,10 +14235,11 @@ function isConfirmationCompatible(trackClassName, detectionMacroClass) {
|
|
|
14139
14235
|
if (track === "other" || det === "other") return true;
|
|
14140
14236
|
return track === det;
|
|
14141
14237
|
}
|
|
14142
|
-
var failOpen = (
|
|
14143
|
-
trackId,
|
|
14238
|
+
var failOpen = (candidate, reason) => ({
|
|
14239
|
+
trackId: candidate.trackId,
|
|
14144
14240
|
confirmed: true,
|
|
14145
|
-
reason
|
|
14241
|
+
reason,
|
|
14242
|
+
className: candidate.className
|
|
14146
14243
|
});
|
|
14147
14244
|
function withTimeout(promise, timeoutMs) {
|
|
14148
14245
|
return new Promise((resolve, reject) => {
|
|
@@ -14158,23 +14255,34 @@ function withTimeout(promise, timeoutMs) {
|
|
|
14158
14255
|
}
|
|
14159
14256
|
async function runConfirmation(candidate, config, deps) {
|
|
14160
14257
|
const crop = await deps.fetchCrop(candidate);
|
|
14161
|
-
if (!crop) return failOpen(candidate
|
|
14258
|
+
if (!crop) return failOpen(candidate, "no-crop");
|
|
14162
14259
|
const detections = await deps.redetect(crop);
|
|
14163
|
-
if (detections === null) return failOpen(candidate
|
|
14164
|
-
|
|
14260
|
+
if (detections === null) return failOpen(candidate, "redetect-error");
|
|
14261
|
+
let best;
|
|
14262
|
+
let bestIncompatible;
|
|
14263
|
+
for (const d of detections) if (isConfirmationCompatible(candidate.className, d.macroClass)) {
|
|
14264
|
+
if (!best || d.score > best.score) best = d;
|
|
14265
|
+
} else if (!bestIncompatible || d.score > bestIncompatible.score) bestIncompatible = d;
|
|
14266
|
+
const confirmed = best !== void 0 && best.score >= config.minConfidence;
|
|
14165
14267
|
return {
|
|
14166
14268
|
trackId: candidate.trackId,
|
|
14167
14269
|
confirmed,
|
|
14168
|
-
reason: confirmed ? "confirmed" : "suppressed"
|
|
14270
|
+
reason: confirmed ? "confirmed" : "suppressed",
|
|
14271
|
+
className: candidate.className,
|
|
14272
|
+
...best ? { bestScore: best.score } : {},
|
|
14273
|
+
...bestIncompatible ? {
|
|
14274
|
+
bestIncompatibleClass: bestIncompatible.macroClass,
|
|
14275
|
+
bestIncompatibleScore: bestIncompatible.score
|
|
14276
|
+
} : {}
|
|
14169
14277
|
};
|
|
14170
14278
|
}
|
|
14171
14279
|
async function confirmOne(candidate, config, deps) {
|
|
14172
14280
|
const cropPx = Math.max(candidate.bbox.w, candidate.bbox.h);
|
|
14173
|
-
if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate
|
|
14281
|
+
if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate, "below-min-crop");
|
|
14174
14282
|
try {
|
|
14175
14283
|
return await withTimeout(runConfirmation(candidate, config, deps), config.timeoutMs);
|
|
14176
14284
|
} catch {
|
|
14177
|
-
return failOpen(candidate
|
|
14285
|
+
return failOpen(candidate, "timeout");
|
|
14178
14286
|
}
|
|
14179
14287
|
}
|
|
14180
14288
|
/**
|
|
@@ -14357,8 +14465,23 @@ function resolveMediaSettings(raw) {
|
|
|
14357
14465
|
* §3.3 draft said 45s).
|
|
14358
14466
|
*/
|
|
14359
14467
|
var PackageDropSettingsSchema = require_dist.object({
|
|
14360
|
-
/**
|
|
14361
|
-
|
|
14468
|
+
/**
|
|
14469
|
+
* Explicit per-camera OFF. **On by default: the ZONE RULE is what enables
|
|
14470
|
+
* package detection**, not a second switch.
|
|
14471
|
+
*
|
|
14472
|
+
* As an opt-in default-false this was a parallel source of truth. Device 615
|
|
14473
|
+
* on 2026-07-30 had the zone (`Uscio`), an enabled `package`-stage zone rule
|
|
14474
|
+
* (`Pacchetti`, classFilter `['package']`), and a notification rule on
|
|
14475
|
+
* `delivery: 'package-event'` — three layers of operator intent, all defeated
|
|
14476
|
+
* silently by a boolean none of them mentions.
|
|
14477
|
+
*
|
|
14478
|
+
* Defaulting to true costs nothing on cameras nobody configured:
|
|
14479
|
+
* `PackageDropDetector.onAppeared` still returns early when the device has no
|
|
14480
|
+
* enabled `package`-stage rule, so the work is a class check plus one cached
|
|
14481
|
+
* lookup. Set this false to force the feature off on a camera that HAS a zone
|
|
14482
|
+
* rule.
|
|
14483
|
+
*/
|
|
14484
|
+
packageDropEnabled: require_dist.boolean().default(true),
|
|
14362
14485
|
/**
|
|
14363
14486
|
* Minimum OBSERVED dwell (seconds, since first-seen) before a newly
|
|
14364
14487
|
* promoted stationary package counts as a delivery. Kills a bag briefly
|
|
@@ -19577,7 +19700,21 @@ function decodeEmbeddingBase64(base64) {
|
|
|
19577
19700
|
const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
|
|
19578
19701
|
return Array.from(view);
|
|
19579
19702
|
}
|
|
19703
|
+
/** A track shorter than this is a candidate phantom, not a subject that came
|
|
19704
|
+
* and went. Brackets the observed plant tracks (3.8-19.0 s). */
|
|
19705
|
+
var SHORT_TRACK_MAX_MS = 25e3;
|
|
19706
|
+
/** Total displacement under this is "did not move at all" (observed: 0-2.5 px). */
|
|
19707
|
+
var MOTIONLESS_MAX_PX = 8;
|
|
19708
|
+
/** Grid the spawn point is quantised to, so respawns whose boxes never repeat
|
|
19709
|
+
* to the pixel still land in one cell. */
|
|
19710
|
+
var PHANTOM_CELL_PX = 32;
|
|
19711
|
+
/** How long a cell remembers its closes. */
|
|
19712
|
+
var PHANTOM_CELL_WINDOW_MS = 360 * 6e4;
|
|
19580
19713
|
var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
19714
|
+
/** Recent SHORT+MOTIONLESS track closes per `<device>:<class>:<cell>` —
|
|
19715
|
+
* see {@link noteShortMotionlessTrack}. Measurement only; each entry is
|
|
19716
|
+
* filtered against the 6-hour window on write, so it stays bounded. */
|
|
19717
|
+
shortMotionlessCells = /* @__PURE__ */ new Map();
|
|
19581
19718
|
processors = /* @__PURE__ */ new Map();
|
|
19582
19719
|
trackStore = null;
|
|
19583
19720
|
/** Parked-object registry: promotes a track that stopped moving into a
|
|
@@ -19887,7 +20024,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
19887
20024
|
});
|
|
19888
20025
|
},
|
|
19889
20026
|
emitTrackLifecycle: (payload, timestampMs) => this.emitTrackLifecycle(payload, timestampMs),
|
|
19890
|
-
onTrackClosed: (track, ownedMedia, info) =>
|
|
20027
|
+
onTrackClosed: (track, ownedMedia, info) => {
|
|
20028
|
+
this.noteShortMotionlessTrack(track);
|
|
20029
|
+
return this.notificationCenter?.onTrackClosed(track, ownedMedia, info);
|
|
20030
|
+
},
|
|
19891
20031
|
deriveThumbnailFromKeyFrame: async (input) => {
|
|
19892
20032
|
const derived = await deriveKeyFrameThumbnailJpeg({
|
|
19893
20033
|
...input,
|
|
@@ -20374,6 +20514,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
20374
20514
|
};
|
|
20375
20515
|
},
|
|
20376
20516
|
dispatcher: {
|
|
20517
|
+
getZoneNames: async (deviceId) => {
|
|
20518
|
+
return (await api.zones.listZones.query({ deviceId })).map((z) => ({
|
|
20519
|
+
id: z.id,
|
|
20520
|
+
name: z.name
|
|
20521
|
+
}));
|
|
20522
|
+
},
|
|
20377
20523
|
getZonePolygons: async (deviceId, zoneIds) => {
|
|
20378
20524
|
const zones = await api.zones.listZones.query({ deviceId });
|
|
20379
20525
|
const wanted = new Set(zoneIds);
|
|
@@ -20751,6 +20897,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
20751
20897
|
const liveRules = proxy?.state.zoneRules.value?.detection ?? [];
|
|
20752
20898
|
processor.setZones(liveZones);
|
|
20753
20899
|
processor.setDetectionRules(liveRules);
|
|
20900
|
+
processor.setZoneMembershipMinOverlap(trk.zoneMembershipMinOverlap);
|
|
20754
20901
|
const result = processor.process({
|
|
20755
20902
|
timestamp: frame.timestamp,
|
|
20756
20903
|
frame
|
|
@@ -20981,7 +21128,29 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
20981
21128
|
} });
|
|
20982
21129
|
}
|
|
20983
21130
|
await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
|
|
20984
|
-
if (this.notificationCenter !== null)
|
|
21131
|
+
if (this.notificationCenter !== null) {
|
|
21132
|
+
const overlaps = processor.getLastZoneOverlaps();
|
|
21133
|
+
for (const e of result.objectEvents) {
|
|
21134
|
+
if (e.zones && e.zones.length > 0) {
|
|
21135
|
+
const m = e.trackId ? overlaps.get(e.trackId) : void 0;
|
|
21136
|
+
this.ctx.logger.info("zone membership stamped on event", {
|
|
21137
|
+
tags: { deviceId },
|
|
21138
|
+
meta: {
|
|
21139
|
+
eventId: e.id,
|
|
21140
|
+
trackId: e.trackId,
|
|
21141
|
+
className: e.className,
|
|
21142
|
+
minOverlap: trk.zoneMembershipMinOverlap,
|
|
21143
|
+
zones: (m ?? []).map((z) => ({
|
|
21144
|
+
id: z.zoneId,
|
|
21145
|
+
name: z.zoneName,
|
|
21146
|
+
overlapPct: Math.round(z.overlap * 1e3) / 10
|
|
21147
|
+
}))
|
|
21148
|
+
}
|
|
21149
|
+
});
|
|
21150
|
+
}
|
|
21151
|
+
this.notificationCenter.onObjectEventPersisted(e);
|
|
21152
|
+
}
|
|
21153
|
+
}
|
|
20985
21154
|
const objectEmbeddingBests = [];
|
|
20986
21155
|
if (this.objectEmbeddingStore) for (const t of result.tracked) {
|
|
20987
21156
|
if (!isClipObjectEmbedding(t)) continue;
|
|
@@ -21333,19 +21502,28 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
21333
21502
|
}),
|
|
21334
21503
|
redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
|
|
21335
21504
|
onDecision: (decision) => {
|
|
21505
|
+
const meta = {
|
|
21506
|
+
trackId: decision.trackId,
|
|
21507
|
+
reason: decision.reason,
|
|
21508
|
+
className: decision.className,
|
|
21509
|
+
...decision.bestScore !== void 0 ? { bestScore: decision.bestScore } : {},
|
|
21510
|
+
...decision.bestIncompatibleClass !== void 0 ? {
|
|
21511
|
+
bestIncompatibleClass: decision.bestIncompatibleClass,
|
|
21512
|
+
bestIncompatibleScore: decision.bestIncompatibleScore
|
|
21513
|
+
} : {},
|
|
21514
|
+
minConfidence: config.minConfidence
|
|
21515
|
+
};
|
|
21336
21516
|
if (!decision.confirmed) this.ctx.logger.info("confirmation gate: birth suppressed (false positive)", {
|
|
21337
21517
|
tags: { deviceId },
|
|
21338
|
-
meta
|
|
21339
|
-
trackId: decision.trackId,
|
|
21340
|
-
reason: decision.reason
|
|
21341
|
-
}
|
|
21518
|
+
meta
|
|
21342
21519
|
});
|
|
21343
21520
|
else if (decision.reason !== "confirmed") this.ctx.logger.debug("confirmation gate: birth allowed (fail-open)", {
|
|
21344
21521
|
tags: { deviceId },
|
|
21345
|
-
meta
|
|
21346
|
-
|
|
21347
|
-
|
|
21348
|
-
}
|
|
21522
|
+
meta
|
|
21523
|
+
});
|
|
21524
|
+
else this.ctx.logger.info("confirmation gate: birth confirmed", {
|
|
21525
|
+
tags: { deviceId },
|
|
21526
|
+
meta
|
|
21349
21527
|
});
|
|
21350
21528
|
}
|
|
21351
21529
|
});
|
|
@@ -21362,6 +21540,48 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
21362
21540
|
async resolveDeviceStationarySettings(deviceId) {
|
|
21363
21541
|
return this.stationarySettingsCache.get(deviceId, (id) => this.readDeviceSettings(id, resolveStationarySettings));
|
|
21364
21542
|
}
|
|
21543
|
+
/**
|
|
21544
|
+
* Count SHORT + MOTIONLESS track closes per frame cell.
|
|
21545
|
+
*
|
|
21546
|
+
* The stationary registry cannot see this class of phantom. Promotion needs a
|
|
21547
|
+
* track that has EXISTED for `PROMOTION_WINDOW_MS` (30 s), and these die long
|
|
21548
|
+
* before: the dead ornamental grass on device 615 produced tracks of 3.8 s,
|
|
21549
|
+
* 4.1 s, 6.5 s and 19.0 s with 0-2.5 px of total displacement, roughly
|
|
21550
|
+
* fifteen of them in a day, all at the same spot. Each one is individually
|
|
21551
|
+
* innocent; the RECURRENCE is the signal, and nothing survives a track death
|
|
21552
|
+
* to notice it.
|
|
21553
|
+
*
|
|
21554
|
+
* Lowering the promotion window is not the fix — those 30 s exist so someone
|
|
21555
|
+
* standing still at a door is not declared scenery.
|
|
21556
|
+
*
|
|
21557
|
+
* This is the measurement half: it establishes how often a cell repeats
|
|
21558
|
+
* before any suppression is built, so "N closes in what window" comes from
|
|
21559
|
+
* data rather than intuition. It suppresses NOTHING.
|
|
21560
|
+
*/
|
|
21561
|
+
noteShortMotionlessTrack(track) {
|
|
21562
|
+
const lifeMs = track.lastSeen - track.firstSeen;
|
|
21563
|
+
const moved = track.totalDistance ?? 0;
|
|
21564
|
+
if (lifeMs > SHORT_TRACK_MAX_MS || moved > MOTIONLESS_MAX_PX) return;
|
|
21565
|
+
const first = track.positions?.[0];
|
|
21566
|
+
if (!first) return;
|
|
21567
|
+
const cell = `${Math.round(first.x / PHANTOM_CELL_PX)},${Math.round(first.y / PHANTOM_CELL_PX)}`;
|
|
21568
|
+
const key = `${track.deviceId}:${track.className}:${cell}`;
|
|
21569
|
+
const now = Date.now();
|
|
21570
|
+
const seen = this.shortMotionlessCells.get(key)?.filter((t) => now - t < PHANTOM_CELL_WINDOW_MS) ?? [];
|
|
21571
|
+
seen.push(now);
|
|
21572
|
+
this.shortMotionlessCells.set(key, seen);
|
|
21573
|
+
this.ctx.logger.info("short motionless track closed", {
|
|
21574
|
+
tags: { deviceId: track.deviceId },
|
|
21575
|
+
meta: {
|
|
21576
|
+
trackId: track.trackId,
|
|
21577
|
+
className: track.className,
|
|
21578
|
+
lifeMs,
|
|
21579
|
+
movedPx: Math.round(moved * 10) / 10,
|
|
21580
|
+
cell,
|
|
21581
|
+
repeatsInWindow: seen.length
|
|
21582
|
+
}
|
|
21583
|
+
});
|
|
21584
|
+
}
|
|
21365
21585
|
stationarySettingsFromCache(deviceId) {
|
|
21366
21586
|
return this.stationarySettingsCache.peek(deviceId) ?? STATIONARY_DEFAULTS;
|
|
21367
21587
|
}
|
|
@@ -21371,9 +21591,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
21371
21591
|
/**
|
|
21372
21592
|
* Resolve a device's ENABLED `package`-stage zone rules independent of the
|
|
21373
21593
|
* live frame path (mirrors `resolveDeviceZones`). Warms the proxy and,
|
|
21374
|
-
* when the cached slice is empty, forces one refresh.
|
|
21375
|
-
*
|
|
21376
|
-
*
|
|
21594
|
+
* when the cached slice is empty, forces one refresh.
|
|
21595
|
+
*
|
|
21596
|
+
* The `package` slice is written through the `zone-rules` capability
|
|
21597
|
+
* (`zoneRules.setRules({stage:'package'})`), which is live. An earlier
|
|
21598
|
+
* version of this comment claimed the provider did not exist yet and that
|
|
21599
|
+
* "no package events fire" — that was stale, and believing it produced a
|
|
21600
|
+
* confidently wrong diagnosis on 2026-07-30. An empty list here means the
|
|
21601
|
+
* operator has drawn no package zone rule, nothing more.
|
|
21377
21602
|
*/
|
|
21378
21603
|
async resolveDevicePackageRules(deviceId) {
|
|
21379
21604
|
const proxy = await this.ensureProxy(deviceId);
|
|
@@ -2296,16 +2296,28 @@ function resolveRuleThreshold(rule) {
|
|
|
2296
2296
|
*/
|
|
2297
2297
|
var ZoneEngine = class {
|
|
2298
2298
|
/**
|
|
2299
|
-
* Annotate a single detection with its zone memberships
|
|
2300
|
-
*
|
|
2301
|
-
*
|
|
2299
|
+
* Annotate a single detection with its zone memberships — every zone whose
|
|
2300
|
+
* polygon the detection overlaps by MORE than `minOverlap` (0–1 fraction of
|
|
2301
|
+
* the detection's own area).
|
|
2302
|
+
*
|
|
2303
|
+
* The previous version of this comment claimed memberships were returned
|
|
2304
|
+
* "above any active rule's threshold"; they were not — the threshold was
|
|
2305
|
+
* hardcoded to {@link MEMBERSHIP_MIN_OVERLAP} (zero) and no rule was ever
|
|
2306
|
+
* consulted. Read the parameter, not this paragraph.
|
|
2307
|
+
*
|
|
2308
|
+
* `minOverlap` matters because membership is what lands on an event as
|
|
2309
|
+
* `zones`, and a notification rule's `zones` condition is a plain set test
|
|
2310
|
+
* over that field — so this, not the zone-RULE threshold, is what decides
|
|
2311
|
+
* whether a zone-scoped notification fires. At the default of 0 a subject
|
|
2312
|
+
* clipping a zone by one pixel counts as inside it (measured 2026-07-30: a
|
|
2313
|
+
* dog overlapping `Aiuola` by 4.8% would have been stamped as in it).
|
|
2302
2314
|
*/
|
|
2303
|
-
annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight) {
|
|
2315
|
+
annotateDetection(bbox, zones, frameWidth, frameHeight, mask, maskWidth, maskHeight, minOverlap = MEMBERSHIP_MIN_OVERLAP) {
|
|
2304
2316
|
const memberships = [];
|
|
2305
2317
|
for (const zone of zones) {
|
|
2306
2318
|
const pixelPolygon = zone.polygon.map((p) => normalizeToPixel(p, frameWidth, frameHeight));
|
|
2307
2319
|
const overlap = mask && maskWidth && maskHeight ? maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, pixelPolygon, frameWidth, frameHeight) : bboxPolygonOverlap(bbox, pixelPolygon);
|
|
2308
|
-
if (overlap >
|
|
2320
|
+
if (overlap > minOverlap) memberships.push({
|
|
2309
2321
|
zoneId: zone.id,
|
|
2310
2322
|
zoneName: zone.name,
|
|
2311
2323
|
overlap
|
|
@@ -2405,6 +2417,10 @@ var FrameProcessor = class {
|
|
|
2405
2417
|
* runners that haven't picked up the new gating yet.
|
|
2406
2418
|
*/
|
|
2407
2419
|
detectionRules;
|
|
2420
|
+
/** See {@link setZoneMembershipMinOverlap}. 0 = any positive overlap. */
|
|
2421
|
+
zoneMembershipMinOverlap;
|
|
2422
|
+
/** See {@link getLastZoneOverlaps}. */
|
|
2423
|
+
lastZoneOverlaps;
|
|
2408
2424
|
zoneEngine = new ZoneEngine();
|
|
2409
2425
|
/** Optional stationary-object gate (parked-object suppression). Null until
|
|
2410
2426
|
* the addon wires it via {@link setStationaryGate}. */
|
|
@@ -2417,10 +2433,32 @@ var FrameProcessor = class {
|
|
|
2417
2433
|
this.eventEmitter = new DetectionEventEmitter(emitterConfig);
|
|
2418
2434
|
this.zones = [];
|
|
2419
2435
|
this.detectionRules = [];
|
|
2436
|
+
this.zoneMembershipMinOverlap = 0;
|
|
2437
|
+
this.lastZoneOverlaps = /* @__PURE__ */ new Map();
|
|
2420
2438
|
}
|
|
2421
2439
|
setZones(zones) {
|
|
2422
2440
|
this.zones = zones;
|
|
2423
2441
|
}
|
|
2442
|
+
/**
|
|
2443
|
+
* How much of a detection's box must lie inside a zone for the zone to be
|
|
2444
|
+
* stamped onto it (0–1 fraction of the box's own area).
|
|
2445
|
+
*
|
|
2446
|
+
* DEFAULT 0 — byte-identical to the behaviour before 2026-07-31, where any
|
|
2447
|
+
* positive overlap counted. Raising it is an operator decision and needs
|
|
2448
|
+
* evidence: a bar set blind removes notifications silently, which is the
|
|
2449
|
+
* failure mode this whole area keeps producing. {@link lastZoneOverlaps}
|
|
2450
|
+
* exists so the distribution can be read before a number is picked.
|
|
2451
|
+
*/
|
|
2452
|
+
setZoneMembershipMinOverlap(minOverlap) {
|
|
2453
|
+
this.zoneMembershipMinOverlap = Number.isFinite(minOverlap) && minOverlap >= 0 && minOverlap <= 1 ? minOverlap : 0;
|
|
2454
|
+
}
|
|
2455
|
+
/** Per-track zone memberships WITH their overlap fractions, from the most
|
|
2456
|
+
* recent frame. The engine computes these and the pipeline previously
|
|
2457
|
+
* discarded everything but the ids — which is why no amount of production
|
|
2458
|
+
* data could say how far inside the zone a notifying subject actually was. */
|
|
2459
|
+
getLastZoneOverlaps() {
|
|
2460
|
+
return this.lastZoneOverlaps;
|
|
2461
|
+
}
|
|
2424
2462
|
setDetectionRules(rules) {
|
|
2425
2463
|
this.detectionRules = rules;
|
|
2426
2464
|
}
|
|
@@ -2531,11 +2569,14 @@ var FrameProcessor = class {
|
|
|
2531
2569
|
const objectStates = this.stateAnalyzer.analyze(trackedDetections, timestamp);
|
|
2532
2570
|
const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, [], [], String(this.deviceId));
|
|
2533
2571
|
const zonesByTrack = /* @__PURE__ */ new Map();
|
|
2572
|
+
const overlapsByTrack = /* @__PURE__ */ new Map();
|
|
2534
2573
|
for (const td of trackedDetections) {
|
|
2535
2574
|
const m = maskByBbox.get(td.bbox);
|
|
2536
|
-
const memberships = this.zoneEngine.annotateDetection(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height);
|
|
2575
|
+
const memberships = this.zoneEngine.annotateDetection(td.bbox, this.zones, frameWidth, frameHeight, m?.mask, m?.width, m?.height, this.zoneMembershipMinOverlap);
|
|
2537
2576
|
zonesByTrack.set(td.trackId, memberships.map((m2) => m2.zoneId));
|
|
2577
|
+
if (memberships.length > 0) overlapsByTrack.set(td.trackId, memberships);
|
|
2538
2578
|
}
|
|
2579
|
+
this.lastZoneOverlaps = overlapsByTrack;
|
|
2539
2580
|
const tracked = trackedDetections.map((td) => {
|
|
2540
2581
|
const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === td.trackId)?.state);
|
|
2541
2582
|
const label = resolveDetectionLabel({
|
|
@@ -4629,7 +4670,9 @@ var NcDispatcher = class {
|
|
|
4629
4670
|
ruleId: entry.ruleId,
|
|
4630
4671
|
target: target.name,
|
|
4631
4672
|
kind: target.kind,
|
|
4632
|
-
recordKind: entry.recordKind
|
|
4673
|
+
recordKind: entry.recordKind,
|
|
4674
|
+
eventId: entry.recordId,
|
|
4675
|
+
...entry.trackId !== void 0 ? { trackId: entry.trackId } : {}
|
|
4633
4676
|
}
|
|
4634
4677
|
});
|
|
4635
4678
|
return { ok: true };
|
|
@@ -4667,9 +4710,10 @@ var NcDispatcher = class {
|
|
|
4667
4710
|
async buildNotification(entry) {
|
|
4668
4711
|
const subject = entry.payload.subject;
|
|
4669
4712
|
const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
|
|
4670
|
-
const
|
|
4713
|
+
const zoneLabels = await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
|
|
4714
|
+
const vars = buildTemplateVars(entry, deviceName, zoneLabels);
|
|
4671
4715
|
const title = renderTemplate(entry.payload.template?.title, vars) ?? entry.payload.ruleName;
|
|
4672
|
-
const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName);
|
|
4716
|
+
const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName, zoneLabels);
|
|
4673
4717
|
const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
|
|
4674
4718
|
const params = pickParams(entry.payload.params);
|
|
4675
4719
|
return {
|
|
@@ -4868,15 +4912,37 @@ var NcDispatcher = class {
|
|
|
4868
4912
|
return null;
|
|
4869
4913
|
}
|
|
4870
4914
|
};
|
|
4871
|
-
|
|
4915
|
+
/**
|
|
4916
|
+
* Map admin zone IDs to their display names for rendering only.
|
|
4917
|
+
*
|
|
4918
|
+
* Order follows `zoneIds` (the order the track visited them), not the zone
|
|
4919
|
+
* catalog. Every failure mode degrades to the ID rather than dropping the
|
|
4920
|
+
* zone: an unknown id, a blank name, a throwing lookup, or no lookup wired at
|
|
4921
|
+
* all. A body that silently loses a zone is worse than one that shows a UUID.
|
|
4922
|
+
*/
|
|
4923
|
+
async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
|
|
4924
|
+
if (zoneIds.length === 0) return [];
|
|
4925
|
+
if (getZoneNames === void 0) return [...zoneIds];
|
|
4926
|
+
try {
|
|
4927
|
+
const zones = await getZoneNames(deviceId);
|
|
4928
|
+
const byId = new Map(zones.map((z) => [z.id, z.name]));
|
|
4929
|
+
return zoneIds.map((id) => {
|
|
4930
|
+
const name = byId.get(id);
|
|
4931
|
+
return name !== void 0 && name.trim().length > 0 ? name : id;
|
|
4932
|
+
});
|
|
4933
|
+
} catch {
|
|
4934
|
+
return [...zoneIds];
|
|
4935
|
+
}
|
|
4936
|
+
}
|
|
4937
|
+
function buildTemplateVars(entry, deviceName, zoneLabels) {
|
|
4872
4938
|
const subject = entry.payload.subject;
|
|
4873
4939
|
const occupancy = subject.occupancy;
|
|
4874
4940
|
return {
|
|
4875
4941
|
camera: deviceName,
|
|
4876
4942
|
class: subject.className,
|
|
4877
4943
|
label: subject.label ?? "",
|
|
4878
|
-
zones:
|
|
4879
|
-
zone: occupancy?.zone ??
|
|
4944
|
+
zones: zoneLabels.join(", "),
|
|
4945
|
+
zone: occupancy?.zone ?? zoneLabels[0] ?? "",
|
|
4880
4946
|
confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
|
|
4881
4947
|
time: new Date(subject.timestamp).toLocaleTimeString(),
|
|
4882
4948
|
rule: entry.payload.ruleName,
|
|
@@ -4894,12 +4960,12 @@ function renderTemplate(template, vars) {
|
|
|
4894
4960
|
if (template === void 0 || template.trim().length === 0) return null;
|
|
4895
4961
|
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name) => vars[name] ?? "");
|
|
4896
4962
|
}
|
|
4897
|
-
function defaultBody(entry, deviceName) {
|
|
4963
|
+
function defaultBody(entry, deviceName, zoneLabels) {
|
|
4898
4964
|
const subject = entry.payload.subject;
|
|
4899
4965
|
const occupancy = subject.occupancy;
|
|
4900
4966
|
if (occupancy !== void 0) return `${occupancy.zone ?? deviceName} ${occupancyOpWord(occupancy.occupied)} (${occupancy.count}/${occupancy.capacity})`;
|
|
4901
4967
|
const label = subject.label !== void 0 ? ` (${subject.label})` : "";
|
|
4902
|
-
const zones =
|
|
4968
|
+
const zones = zoneLabels.length > 0 ? ` in ${zoneLabels.join(", ")}` : "";
|
|
4903
4969
|
const suffix = entry.recordKind === "track-end" ? " — visit ended" : "";
|
|
4904
4970
|
return `${subject.className}${label} on ${deviceName}${zones}${suffix}`;
|
|
4905
4971
|
}
|
|
@@ -6677,7 +6743,9 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6677
6743
|
rule: rule.name,
|
|
6678
6744
|
kind,
|
|
6679
6745
|
failed: evaluation.failedCondition,
|
|
6680
|
-
classes: subject.classNames
|
|
6746
|
+
classes: subject.classNames,
|
|
6747
|
+
eventId: subject.recordId,
|
|
6748
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
|
|
6681
6749
|
}
|
|
6682
6750
|
});
|
|
6683
6751
|
continue;
|
|
@@ -6689,7 +6757,9 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6689
6757
|
meta: {
|
|
6690
6758
|
ruleId: rule.id,
|
|
6691
6759
|
rule: rule.name,
|
|
6692
|
-
key
|
|
6760
|
+
key,
|
|
6761
|
+
eventId: subject.recordId,
|
|
6762
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
|
|
6693
6763
|
}
|
|
6694
6764
|
});
|
|
6695
6765
|
continue;
|
|
@@ -6700,7 +6770,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6700
6770
|
ruleId: rule.id,
|
|
6701
6771
|
rule: rule.name,
|
|
6702
6772
|
kind,
|
|
6703
|
-
targets: rule.targets.length
|
|
6773
|
+
targets: rule.targets.length,
|
|
6774
|
+
eventId: subject.recordId,
|
|
6775
|
+
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
|
|
6776
|
+
...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
|
|
6777
|
+
...subject.zones.length > 0 ? { zones: subject.zones } : {}
|
|
6704
6778
|
}
|
|
6705
6779
|
});
|
|
6706
6780
|
const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
|
|
@@ -13847,6 +13921,23 @@ function resolveDetectionSensitivitySettings(raw) {
|
|
|
13847
13921
|
};
|
|
13848
13922
|
}
|
|
13849
13923
|
var TrackingSettingsSchema = object({
|
|
13924
|
+
/**
|
|
13925
|
+
* How much of a detection's box must lie inside a zone (0-1 fraction of the
|
|
13926
|
+
* box's own area) for that zone to be STAMPED onto the detection.
|
|
13927
|
+
*
|
|
13928
|
+
* This is the field a zone-scoped notification rule ultimately depends on: a
|
|
13929
|
+
* rule's `zones` condition is a plain set test over the stamped zone ids, so
|
|
13930
|
+
* a subject that merely clips a zone edge satisfies it. Measured on
|
|
13931
|
+
* 2026-07-30: a dog overlapping `Aiuola` by 4.8% would have counted as
|
|
13932
|
+
* inside it.
|
|
13933
|
+
*
|
|
13934
|
+
* DEFAULT 0 — byte-identical to the behaviour before 2026-07-31. Raising it
|
|
13935
|
+
* is deliberately an operator decision: the overlap fractions are now logged
|
|
13936
|
+
* (`zone membership` lines), so the bar can be chosen from the distribution
|
|
13937
|
+
* instead of guessed. Distinct from a zone RULE's `bboxInclusionPct`, which
|
|
13938
|
+
* gates the DETECTION stage, not what gets stamped.
|
|
13939
|
+
*/
|
|
13940
|
+
zoneMembershipMinOverlap: number().min(0).max(1).default(0),
|
|
13850
13941
|
/** IoU required to match a (predicted) track to a detection. */
|
|
13851
13942
|
iouThreshold: number().min(0).max(1).default(.3),
|
|
13852
13943
|
/** Wall-clock coasting budget (ms) before a missed track is dropped. Time-
|
|
@@ -13983,6 +14074,7 @@ function resolveTrackingSettings(raw) {
|
|
|
13983
14074
|
const maxMissedMs = raw.maxMissedMs !== void 0 ? s.maxMissedMs.catch(TRACKING_DEFAULTS.maxMissedMs).parse(raw.maxMissedMs) : raw.maxMissedFrames !== void 0 ? Math.round(maxMissedFrames * 133) : TRACKING_DEFAULTS.maxMissedMs;
|
|
13984
14075
|
const occlusionMaxMissedMs = raw.occlusionMaxMissedMs !== void 0 ? s.occlusionMaxMissedMs.catch(TRACKING_DEFAULTS.occlusionMaxMissedMs).parse(raw.occlusionMaxMissedMs) : raw.occlusionMaxMissedFrames !== void 0 ? Math.round(occlusionMaxMissedFrames * 133) : TRACKING_DEFAULTS.occlusionMaxMissedMs;
|
|
13985
14076
|
return {
|
|
14077
|
+
zoneMembershipMinOverlap: s.zoneMembershipMinOverlap.catch(TRACKING_DEFAULTS.zoneMembershipMinOverlap).parse(raw.zoneMembershipMinOverlap),
|
|
13986
14078
|
iouThreshold: s.iouThreshold.catch(TRACKING_DEFAULTS.iouThreshold).parse(raw.iouThreshold),
|
|
13987
14079
|
maxMissedMs,
|
|
13988
14080
|
minTrackAgeMs: s.minTrackAgeMs.catch(TRACKING_DEFAULTS.minTrackAgeMs).parse(raw.minTrackAgeMs),
|
|
@@ -14037,7 +14129,11 @@ function resolveTrackingSettings(raw) {
|
|
|
14037
14129
|
* low-res detection frame (a static "person" phantom, a parked-truck ghost, a
|
|
14038
14130
|
* misclassified static object).
|
|
14039
14131
|
*
|
|
14040
|
-
*
|
|
14132
|
+
* ON by default (`enabled` defaults to TRUE). An earlier version of this line
|
|
14133
|
+
* said "Ships DORMANT: `enabled` defaults to `false`" — that was stale, and on
|
|
14134
|
+
* 2026-07-30 it nearly produced the conclusion that the gate was not running at
|
|
14135
|
+
* all. It is: it suppressed several phantom births on device 615 that same day.
|
|
14136
|
+
* Read the schema, not this paragraph. Historically the intent was byte-identical
|
|
14041
14137
|
* to today until an operator opts in per camera. The gate is fail-OPEN — any
|
|
14042
14138
|
* missing frame handle, unavailable inference cap, crop-fetch miss, re-detection
|
|
14043
14139
|
* error, or timeout ALLOWS the birth (a real track is never suppressed because
|
|
@@ -14133,10 +14229,11 @@ function isConfirmationCompatible(trackClassName, detectionMacroClass) {
|
|
|
14133
14229
|
if (track === "other" || det === "other") return true;
|
|
14134
14230
|
return track === det;
|
|
14135
14231
|
}
|
|
14136
|
-
var failOpen = (
|
|
14137
|
-
trackId,
|
|
14232
|
+
var failOpen = (candidate, reason) => ({
|
|
14233
|
+
trackId: candidate.trackId,
|
|
14138
14234
|
confirmed: true,
|
|
14139
|
-
reason
|
|
14235
|
+
reason,
|
|
14236
|
+
className: candidate.className
|
|
14140
14237
|
});
|
|
14141
14238
|
function withTimeout(promise, timeoutMs) {
|
|
14142
14239
|
return new Promise((resolve, reject) => {
|
|
@@ -14152,23 +14249,34 @@ function withTimeout(promise, timeoutMs) {
|
|
|
14152
14249
|
}
|
|
14153
14250
|
async function runConfirmation(candidate, config, deps) {
|
|
14154
14251
|
const crop = await deps.fetchCrop(candidate);
|
|
14155
|
-
if (!crop) return failOpen(candidate
|
|
14252
|
+
if (!crop) return failOpen(candidate, "no-crop");
|
|
14156
14253
|
const detections = await deps.redetect(crop);
|
|
14157
|
-
if (detections === null) return failOpen(candidate
|
|
14158
|
-
|
|
14254
|
+
if (detections === null) return failOpen(candidate, "redetect-error");
|
|
14255
|
+
let best;
|
|
14256
|
+
let bestIncompatible;
|
|
14257
|
+
for (const d of detections) if (isConfirmationCompatible(candidate.className, d.macroClass)) {
|
|
14258
|
+
if (!best || d.score > best.score) best = d;
|
|
14259
|
+
} else if (!bestIncompatible || d.score > bestIncompatible.score) bestIncompatible = d;
|
|
14260
|
+
const confirmed = best !== void 0 && best.score >= config.minConfidence;
|
|
14159
14261
|
return {
|
|
14160
14262
|
trackId: candidate.trackId,
|
|
14161
14263
|
confirmed,
|
|
14162
|
-
reason: confirmed ? "confirmed" : "suppressed"
|
|
14264
|
+
reason: confirmed ? "confirmed" : "suppressed",
|
|
14265
|
+
className: candidate.className,
|
|
14266
|
+
...best ? { bestScore: best.score } : {},
|
|
14267
|
+
...bestIncompatible ? {
|
|
14268
|
+
bestIncompatibleClass: bestIncompatible.macroClass,
|
|
14269
|
+
bestIncompatibleScore: bestIncompatible.score
|
|
14270
|
+
} : {}
|
|
14163
14271
|
};
|
|
14164
14272
|
}
|
|
14165
14273
|
async function confirmOne(candidate, config, deps) {
|
|
14166
14274
|
const cropPx = Math.max(candidate.bbox.w, candidate.bbox.h);
|
|
14167
|
-
if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate
|
|
14275
|
+
if (config.minCropPx > 0 && cropPx < config.minCropPx) return failOpen(candidate, "below-min-crop");
|
|
14168
14276
|
try {
|
|
14169
14277
|
return await withTimeout(runConfirmation(candidate, config, deps), config.timeoutMs);
|
|
14170
14278
|
} catch {
|
|
14171
|
-
return failOpen(candidate
|
|
14279
|
+
return failOpen(candidate, "timeout");
|
|
14172
14280
|
}
|
|
14173
14281
|
}
|
|
14174
14282
|
/**
|
|
@@ -14351,8 +14459,23 @@ function resolveMediaSettings(raw) {
|
|
|
14351
14459
|
* §3.3 draft said 45s).
|
|
14352
14460
|
*/
|
|
14353
14461
|
var PackageDropSettingsSchema = object({
|
|
14354
|
-
/**
|
|
14355
|
-
|
|
14462
|
+
/**
|
|
14463
|
+
* Explicit per-camera OFF. **On by default: the ZONE RULE is what enables
|
|
14464
|
+
* package detection**, not a second switch.
|
|
14465
|
+
*
|
|
14466
|
+
* As an opt-in default-false this was a parallel source of truth. Device 615
|
|
14467
|
+
* on 2026-07-30 had the zone (`Uscio`), an enabled `package`-stage zone rule
|
|
14468
|
+
* (`Pacchetti`, classFilter `['package']`), and a notification rule on
|
|
14469
|
+
* `delivery: 'package-event'` — three layers of operator intent, all defeated
|
|
14470
|
+
* silently by a boolean none of them mentions.
|
|
14471
|
+
*
|
|
14472
|
+
* Defaulting to true costs nothing on cameras nobody configured:
|
|
14473
|
+
* `PackageDropDetector.onAppeared` still returns early when the device has no
|
|
14474
|
+
* enabled `package`-stage rule, so the work is a class check plus one cached
|
|
14475
|
+
* lookup. Set this false to force the feature off on a camera that HAS a zone
|
|
14476
|
+
* rule.
|
|
14477
|
+
*/
|
|
14478
|
+
packageDropEnabled: boolean().default(true),
|
|
14356
14479
|
/**
|
|
14357
14480
|
* Minimum OBSERVED dwell (seconds, since first-seen) before a newly
|
|
14358
14481
|
* promoted stationary package counts as a delivery. Kills a bag briefly
|
|
@@ -19571,7 +19694,21 @@ function decodeEmbeddingBase64(base64) {
|
|
|
19571
19694
|
const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
|
|
19572
19695
|
return Array.from(view);
|
|
19573
19696
|
}
|
|
19697
|
+
/** A track shorter than this is a candidate phantom, not a subject that came
|
|
19698
|
+
* and went. Brackets the observed plant tracks (3.8-19.0 s). */
|
|
19699
|
+
var SHORT_TRACK_MAX_MS = 25e3;
|
|
19700
|
+
/** Total displacement under this is "did not move at all" (observed: 0-2.5 px). */
|
|
19701
|
+
var MOTIONLESS_MAX_PX = 8;
|
|
19702
|
+
/** Grid the spawn point is quantised to, so respawns whose boxes never repeat
|
|
19703
|
+
* to the pixel still land in one cell. */
|
|
19704
|
+
var PHANTOM_CELL_PX = 32;
|
|
19705
|
+
/** How long a cell remembers its closes. */
|
|
19706
|
+
var PHANTOM_CELL_WINDOW_MS = 360 * 6e4;
|
|
19574
19707
|
var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
19708
|
+
/** Recent SHORT+MOTIONLESS track closes per `<device>:<class>:<cell>` —
|
|
19709
|
+
* see {@link noteShortMotionlessTrack}. Measurement only; each entry is
|
|
19710
|
+
* filtered against the 6-hour window on write, so it stays bounded. */
|
|
19711
|
+
shortMotionlessCells = /* @__PURE__ */ new Map();
|
|
19575
19712
|
processors = /* @__PURE__ */ new Map();
|
|
19576
19713
|
trackStore = null;
|
|
19577
19714
|
/** Parked-object registry: promotes a track that stopped moving into a
|
|
@@ -19881,7 +20018,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
19881
20018
|
});
|
|
19882
20019
|
},
|
|
19883
20020
|
emitTrackLifecycle: (payload, timestampMs) => this.emitTrackLifecycle(payload, timestampMs),
|
|
19884
|
-
onTrackClosed: (track, ownedMedia, info) =>
|
|
20021
|
+
onTrackClosed: (track, ownedMedia, info) => {
|
|
20022
|
+
this.noteShortMotionlessTrack(track);
|
|
20023
|
+
return this.notificationCenter?.onTrackClosed(track, ownedMedia, info);
|
|
20024
|
+
},
|
|
19885
20025
|
deriveThumbnailFromKeyFrame: async (input) => {
|
|
19886
20026
|
const derived = await deriveKeyFrameThumbnailJpeg({
|
|
19887
20027
|
...input,
|
|
@@ -20368,6 +20508,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
20368
20508
|
};
|
|
20369
20509
|
},
|
|
20370
20510
|
dispatcher: {
|
|
20511
|
+
getZoneNames: async (deviceId) => {
|
|
20512
|
+
return (await api.zones.listZones.query({ deviceId })).map((z) => ({
|
|
20513
|
+
id: z.id,
|
|
20514
|
+
name: z.name
|
|
20515
|
+
}));
|
|
20516
|
+
},
|
|
20371
20517
|
getZonePolygons: async (deviceId, zoneIds) => {
|
|
20372
20518
|
const zones = await api.zones.listZones.query({ deviceId });
|
|
20373
20519
|
const wanted = new Set(zoneIds);
|
|
@@ -20745,6 +20891,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
20745
20891
|
const liveRules = proxy?.state.zoneRules.value?.detection ?? [];
|
|
20746
20892
|
processor.setZones(liveZones);
|
|
20747
20893
|
processor.setDetectionRules(liveRules);
|
|
20894
|
+
processor.setZoneMembershipMinOverlap(trk.zoneMembershipMinOverlap);
|
|
20748
20895
|
const result = processor.process({
|
|
20749
20896
|
timestamp: frame.timestamp,
|
|
20750
20897
|
frame
|
|
@@ -20975,7 +21122,29 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
20975
21122
|
} });
|
|
20976
21123
|
}
|
|
20977
21124
|
await Promise.all([...result.objectEvents, ...result.appearanceEvents].map((e) => this.eventStore.insertObject(e)));
|
|
20978
|
-
if (this.notificationCenter !== null)
|
|
21125
|
+
if (this.notificationCenter !== null) {
|
|
21126
|
+
const overlaps = processor.getLastZoneOverlaps();
|
|
21127
|
+
for (const e of result.objectEvents) {
|
|
21128
|
+
if (e.zones && e.zones.length > 0) {
|
|
21129
|
+
const m = e.trackId ? overlaps.get(e.trackId) : void 0;
|
|
21130
|
+
this.ctx.logger.info("zone membership stamped on event", {
|
|
21131
|
+
tags: { deviceId },
|
|
21132
|
+
meta: {
|
|
21133
|
+
eventId: e.id,
|
|
21134
|
+
trackId: e.trackId,
|
|
21135
|
+
className: e.className,
|
|
21136
|
+
minOverlap: trk.zoneMembershipMinOverlap,
|
|
21137
|
+
zones: (m ?? []).map((z) => ({
|
|
21138
|
+
id: z.zoneId,
|
|
21139
|
+
name: z.zoneName,
|
|
21140
|
+
overlapPct: Math.round(z.overlap * 1e3) / 10
|
|
21141
|
+
}))
|
|
21142
|
+
}
|
|
21143
|
+
});
|
|
21144
|
+
}
|
|
21145
|
+
this.notificationCenter.onObjectEventPersisted(e);
|
|
21146
|
+
}
|
|
21147
|
+
}
|
|
20979
21148
|
const objectEmbeddingBests = [];
|
|
20980
21149
|
if (this.objectEmbeddingStore) for (const t of result.tracked) {
|
|
20981
21150
|
if (!isClipObjectEmbedding(t)) continue;
|
|
@@ -21327,19 +21496,28 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21327
21496
|
}),
|
|
21328
21497
|
redetect: (cropJpeg) => this.redetectCropForConfirmation(nodeId, deviceId, cropJpeg),
|
|
21329
21498
|
onDecision: (decision) => {
|
|
21499
|
+
const meta = {
|
|
21500
|
+
trackId: decision.trackId,
|
|
21501
|
+
reason: decision.reason,
|
|
21502
|
+
className: decision.className,
|
|
21503
|
+
...decision.bestScore !== void 0 ? { bestScore: decision.bestScore } : {},
|
|
21504
|
+
...decision.bestIncompatibleClass !== void 0 ? {
|
|
21505
|
+
bestIncompatibleClass: decision.bestIncompatibleClass,
|
|
21506
|
+
bestIncompatibleScore: decision.bestIncompatibleScore
|
|
21507
|
+
} : {},
|
|
21508
|
+
minConfidence: config.minConfidence
|
|
21509
|
+
};
|
|
21330
21510
|
if (!decision.confirmed) this.ctx.logger.info("confirmation gate: birth suppressed (false positive)", {
|
|
21331
21511
|
tags: { deviceId },
|
|
21332
|
-
meta
|
|
21333
|
-
trackId: decision.trackId,
|
|
21334
|
-
reason: decision.reason
|
|
21335
|
-
}
|
|
21512
|
+
meta
|
|
21336
21513
|
});
|
|
21337
21514
|
else if (decision.reason !== "confirmed") this.ctx.logger.debug("confirmation gate: birth allowed (fail-open)", {
|
|
21338
21515
|
tags: { deviceId },
|
|
21339
|
-
meta
|
|
21340
|
-
|
|
21341
|
-
|
|
21342
|
-
}
|
|
21516
|
+
meta
|
|
21517
|
+
});
|
|
21518
|
+
else this.ctx.logger.info("confirmation gate: birth confirmed", {
|
|
21519
|
+
tags: { deviceId },
|
|
21520
|
+
meta
|
|
21343
21521
|
});
|
|
21344
21522
|
}
|
|
21345
21523
|
});
|
|
@@ -21356,6 +21534,48 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21356
21534
|
async resolveDeviceStationarySettings(deviceId) {
|
|
21357
21535
|
return this.stationarySettingsCache.get(deviceId, (id) => this.readDeviceSettings(id, resolveStationarySettings));
|
|
21358
21536
|
}
|
|
21537
|
+
/**
|
|
21538
|
+
* Count SHORT + MOTIONLESS track closes per frame cell.
|
|
21539
|
+
*
|
|
21540
|
+
* The stationary registry cannot see this class of phantom. Promotion needs a
|
|
21541
|
+
* track that has EXISTED for `PROMOTION_WINDOW_MS` (30 s), and these die long
|
|
21542
|
+
* before: the dead ornamental grass on device 615 produced tracks of 3.8 s,
|
|
21543
|
+
* 4.1 s, 6.5 s and 19.0 s with 0-2.5 px of total displacement, roughly
|
|
21544
|
+
* fifteen of them in a day, all at the same spot. Each one is individually
|
|
21545
|
+
* innocent; the RECURRENCE is the signal, and nothing survives a track death
|
|
21546
|
+
* to notice it.
|
|
21547
|
+
*
|
|
21548
|
+
* Lowering the promotion window is not the fix — those 30 s exist so someone
|
|
21549
|
+
* standing still at a door is not declared scenery.
|
|
21550
|
+
*
|
|
21551
|
+
* This is the measurement half: it establishes how often a cell repeats
|
|
21552
|
+
* before any suppression is built, so "N closes in what window" comes from
|
|
21553
|
+
* data rather than intuition. It suppresses NOTHING.
|
|
21554
|
+
*/
|
|
21555
|
+
noteShortMotionlessTrack(track) {
|
|
21556
|
+
const lifeMs = track.lastSeen - track.firstSeen;
|
|
21557
|
+
const moved = track.totalDistance ?? 0;
|
|
21558
|
+
if (lifeMs > SHORT_TRACK_MAX_MS || moved > MOTIONLESS_MAX_PX) return;
|
|
21559
|
+
const first = track.positions?.[0];
|
|
21560
|
+
if (!first) return;
|
|
21561
|
+
const cell = `${Math.round(first.x / PHANTOM_CELL_PX)},${Math.round(first.y / PHANTOM_CELL_PX)}`;
|
|
21562
|
+
const key = `${track.deviceId}:${track.className}:${cell}`;
|
|
21563
|
+
const now = Date.now();
|
|
21564
|
+
const seen = this.shortMotionlessCells.get(key)?.filter((t) => now - t < PHANTOM_CELL_WINDOW_MS) ?? [];
|
|
21565
|
+
seen.push(now);
|
|
21566
|
+
this.shortMotionlessCells.set(key, seen);
|
|
21567
|
+
this.ctx.logger.info("short motionless track closed", {
|
|
21568
|
+
tags: { deviceId: track.deviceId },
|
|
21569
|
+
meta: {
|
|
21570
|
+
trackId: track.trackId,
|
|
21571
|
+
className: track.className,
|
|
21572
|
+
lifeMs,
|
|
21573
|
+
movedPx: Math.round(moved * 10) / 10,
|
|
21574
|
+
cell,
|
|
21575
|
+
repeatsInWindow: seen.length
|
|
21576
|
+
}
|
|
21577
|
+
});
|
|
21578
|
+
}
|
|
21359
21579
|
stationarySettingsFromCache(deviceId) {
|
|
21360
21580
|
return this.stationarySettingsCache.peek(deviceId) ?? STATIONARY_DEFAULTS;
|
|
21361
21581
|
}
|
|
@@ -21365,9 +21585,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21365
21585
|
/**
|
|
21366
21586
|
* Resolve a device's ENABLED `package`-stage zone rules independent of the
|
|
21367
21587
|
* live frame path (mirrors `resolveDeviceZones`). Warms the proxy and,
|
|
21368
|
-
* when the cached slice is empty, forces one refresh.
|
|
21369
|
-
*
|
|
21370
|
-
*
|
|
21588
|
+
* when the cached slice is empty, forces one refresh.
|
|
21589
|
+
*
|
|
21590
|
+
* The `package` slice is written through the `zone-rules` capability
|
|
21591
|
+
* (`zoneRules.setRules({stage:'package'})`), which is live. An earlier
|
|
21592
|
+
* version of this comment claimed the provider did not exist yet and that
|
|
21593
|
+
* "no package events fire" — that was stale, and believing it produced a
|
|
21594
|
+
* confidently wrong diagnosis on 2026-07-30. An empty list here means the
|
|
21595
|
+
* operator has drawn no package zone rule, nothing more.
|
|
21371
21596
|
*/
|
|
21372
21597
|
async resolveDevicePackageRules(deviceId) {
|
|
21373
21598
|
const proxy = await this.ensureProxy(deviceId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-post-analysis",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.19",
|
|
4
4
|
"description": "CamStack Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|