@camstack/addon-post-analysis 1.1.26 → 1.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{dist-DytVmDZg.mjs → dist-BqOBYSWs.mjs} +45 -3
- package/dist/{dist-BEx5ST1W.js → dist-CtnFKuWh.js} +45 -3
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/enrichment-engine/index.js +2 -2
- package/dist/enrichment-engine/index.mjs +1 -1
- package/dist/{node-Cg_cGqs0.js → node-BN31iDiA.js} +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-p-r6oXq8.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Dvz_QBOD.mjs} +3 -3
- package/dist/pipeline-analytics/{_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-C6LbOCa9.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BXQHc7Tq.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-2UZIpU0W.mjs → hostInit-C7nUOwbi.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +588 -6
- package/dist/pipeline-analytics/index.mjs +586 -4
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/dist/{resolve-frame-Cbm_NFuq.js → resolve-frame-BAdpVnlx.js} +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as number, S as boolean, T as string, c as nodePin, d as videoclipsCapability, f as zoneAnalyticsCapability, g as EventCategory, h as DeviceType, i as cosineSimilarity, l as pipelineAnalyticsCapability, m as BaseAddon, n as addonWidgetsSourceCapability, o as faceGalleryCapability, p as errMsg, r as audioMetricsCapability, t as EVENT_PAD_MS, u as plateGalleryCapability, w as object, y as hydrateSchema } from "../dist-
|
|
1
|
+
import { C as number, S as boolean, T as string, c as nodePin, d as videoclipsCapability, f as zoneAnalyticsCapability, g as EventCategory, h as DeviceType, i as cosineSimilarity, l as pipelineAnalyticsCapability, m as BaseAddon, n as addonWidgetsSourceCapability, o as faceGalleryCapability, p as errMsg, r as audioMetricsCapability, t as EVENT_PAD_MS, u as plateGalleryCapability, w as object, y as hydrateSchema } from "../dist-BqOBYSWs.mjs";
|
|
2
2
|
import { n as extractCrop, t as resolveFrame } from "../resolve-frame-CT1T1tWy.mjs";
|
|
3
3
|
import { FrameRingReaderCache } from "@camstack/shm-ring";
|
|
4
4
|
import sharp from "sharp";
|
|
@@ -1522,6 +1522,7 @@ var FrameProcessor = class {
|
|
|
1522
1522
|
const embeddingByBbox = /* @__PURE__ */ new Map();
|
|
1523
1523
|
const firstLevelBboxById = /* @__PURE__ */ new Map();
|
|
1524
1524
|
const faceBboxByBbox = /* @__PURE__ */ new Map();
|
|
1525
|
+
const faceAlignedCropByBbox = /* @__PURE__ */ new Map();
|
|
1525
1526
|
const plateByBbox = /* @__PURE__ */ new Map();
|
|
1526
1527
|
const maskByBbox = /* @__PURE__ */ new Map();
|
|
1527
1528
|
const flatDetections = frame.detections.filter((d) => d.kind === "first-level").map((det) => {
|
|
@@ -1564,6 +1565,7 @@ var FrameProcessor = class {
|
|
|
1564
1565
|
w: det.bbox.width,
|
|
1565
1566
|
h: det.bbox.height
|
|
1566
1567
|
});
|
|
1568
|
+
if (det.faceAlignedCrop !== void 0) faceAlignedCropByBbox.set(parentBbox, det.faceAlignedCrop);
|
|
1567
1569
|
if (det.embedding !== void 0 && !embeddingByBbox.has(parentBbox)) embeddingByBbox.set(parentBbox, {
|
|
1568
1570
|
embedding: det.embedding,
|
|
1569
1571
|
...det.embeddingModelId !== void 0 ? { embeddingModelId: det.embeddingModelId } : {}
|
|
@@ -1606,6 +1608,7 @@ var FrameProcessor = class {
|
|
|
1606
1608
|
});
|
|
1607
1609
|
const emb = embeddingByBbox.get(td.bbox);
|
|
1608
1610
|
const faceBbox = faceBboxByBbox.get(td.bbox);
|
|
1611
|
+
const faceAlignedCrop = faceAlignedCropByBbox.get(td.bbox);
|
|
1609
1612
|
const plate = plateByBbox.get(td.bbox);
|
|
1610
1613
|
return {
|
|
1611
1614
|
trackId: td.trackId,
|
|
@@ -1620,6 +1623,7 @@ var FrameProcessor = class {
|
|
|
1620
1623
|
...emb.embeddingModelId !== void 0 ? { embeddingModelId: emb.embeddingModelId } : {}
|
|
1621
1624
|
} : {},
|
|
1622
1625
|
...faceBbox !== void 0 ? { faceBbox } : {},
|
|
1626
|
+
...faceAlignedCrop !== void 0 ? { faceAlignedCrop } : {},
|
|
1623
1627
|
...plate !== void 0 ? {
|
|
1624
1628
|
plateText: plate.text,
|
|
1625
1629
|
plateScore: plate.score,
|
|
@@ -5495,6 +5499,14 @@ function updateTrackAggregate(prev, match, opts) {
|
|
|
5495
5499
|
//#region src/pipeline-analytics/face-recognizer.ts
|
|
5496
5500
|
/** At most one "dropping imageless track" log per this interval, per recognizer. */
|
|
5497
5501
|
var FACE_IMAGELESS_LOG_THROTTLE_MS = 6e4;
|
|
5502
|
+
/**
|
|
5503
|
+
* arcface model id stamped on a detail-plane face candidate when the gallery is
|
|
5504
|
+
* empty (collect-only). The detail-subtree result carries no `embeddingModelId`
|
|
5505
|
+
* (the two-plane `DetailResult` schema omits it), so recognition uses the
|
|
5506
|
+
* gallery's own model id (all enrolled samples share one) and this constant is
|
|
5507
|
+
* only a placeholder for the collect-only case where the id is never compared.
|
|
5508
|
+
*/
|
|
5509
|
+
var FALLBACK_FACE_MODEL_ID = "arcface";
|
|
5498
5510
|
var FaceRecognizer = class {
|
|
5499
5511
|
deps;
|
|
5500
5512
|
gallery = [];
|
|
@@ -5522,6 +5534,38 @@ var FaceRecognizer = class {
|
|
|
5522
5534
|
this.deps.logger.warn("FaceRecognizer.refreshGallery failed", { meta: { error: String(err) } });
|
|
5523
5535
|
}
|
|
5524
5536
|
}
|
|
5537
|
+
/**
|
|
5538
|
+
* Two-plane detail feed: ingest ONE `runDetailSubtree` face result for a
|
|
5539
|
+
* track and run it through the SAME `processFrame` logic (candidate → best
|
|
5540
|
+
* face → gallery match → crop hold). The result is synthesized into a single
|
|
5541
|
+
* `TrackedDetectionOut` candidate so no recognizer logic changes — only the
|
|
5542
|
+
* input source moves from the per-frame plane to this per-track call.
|
|
5543
|
+
*/
|
|
5544
|
+
async ingestFaceDetail(input) {
|
|
5545
|
+
const modelId = this.gallery[0]?.modelId ?? FALLBACK_FACE_MODEL_ID;
|
|
5546
|
+
const candidate = {
|
|
5547
|
+
trackId: input.trackId,
|
|
5548
|
+
className: "face",
|
|
5549
|
+
confidence: input.score,
|
|
5550
|
+
bbox: input.parentBbox,
|
|
5551
|
+
zones: [],
|
|
5552
|
+
state: "moving",
|
|
5553
|
+
embedding: input.embedding,
|
|
5554
|
+
embeddingModelId: modelId,
|
|
5555
|
+
...input.faceBbox !== void 0 ? { faceBbox: input.faceBbox } : {},
|
|
5556
|
+
...input.alignedCropJpeg !== void 0 ? { faceAlignedCrop: input.alignedCropJpeg } : {}
|
|
5557
|
+
};
|
|
5558
|
+
await this.processFrame({
|
|
5559
|
+
deviceId: input.deviceId,
|
|
5560
|
+
timestamp: input.timestamp,
|
|
5561
|
+
frameWidth: input.frameWidth,
|
|
5562
|
+
frameHeight: input.frameHeight,
|
|
5563
|
+
tracked: [candidate],
|
|
5564
|
+
settings: input.settings,
|
|
5565
|
+
cropPadding: input.cropPadding,
|
|
5566
|
+
...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
|
|
5567
|
+
});
|
|
5568
|
+
}
|
|
5525
5569
|
async processFrame(input) {
|
|
5526
5570
|
const { settings } = input;
|
|
5527
5571
|
const candidates = input.tracked.filter((t) => Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.confidence >= settings.minFaceConfidence);
|
|
@@ -5559,7 +5603,8 @@ var FaceRecognizer = class {
|
|
|
5559
5603
|
if (isNewBest || needsCrop) {
|
|
5560
5604
|
const cropBbox = c.faceBbox ?? c.bbox;
|
|
5561
5605
|
let crop;
|
|
5562
|
-
if (c.
|
|
5606
|
+
if (c.faceAlignedCrop !== void 0) crop = Buffer.from(c.faceAlignedCrop, "base64");
|
|
5607
|
+
else if (c.faceBbox !== void 0 && input.frameHandle !== void 0) try {
|
|
5563
5608
|
crop = await this.deps.captureCrop(input.frameHandle, c.faceBbox, input.frameWidth, input.frameHeight, input.cropPadding) ?? void 0;
|
|
5564
5609
|
} catch (err) {
|
|
5565
5610
|
this.deps.logger.debug("FaceRecognizer crop capture failed", {
|
|
@@ -5750,6 +5795,346 @@ var FaceRecognizer = class {
|
|
|
5750
5795
|
}
|
|
5751
5796
|
};
|
|
5752
5797
|
//#endregion
|
|
5798
|
+
//#region src/pipeline-analytics/detail-scheduler.ts
|
|
5799
|
+
/** Default backoff/period when a step's cadence omits `minIntervalMs`. */
|
|
5800
|
+
var DEFAULT_MIN_INTERVAL_MS = 1e3;
|
|
5801
|
+
/** Default fire cap for a `once` step when its announce omits `maxPerTrack`. */
|
|
5802
|
+
var DEFAULT_ONCE_MAX_PER_TRACK = 3;
|
|
5803
|
+
/**
|
|
5804
|
+
* Pure per-(track, step) scheduling state machine for detail-subtree
|
|
5805
|
+
* dispatch. Given a camera's announced child steps (`DetailStepAnnounce[]`,
|
|
5806
|
+
* read off `PipelineInferenceResultPayload.detailSteps`), decides WHEN each
|
|
5807
|
+
* step should run for a given track — independent of transport, I/O, or
|
|
5808
|
+
* timers. The caller drives it with wall-clock `nowMs` and dispatches the
|
|
5809
|
+
* returned `DetailRequest[]`.
|
|
5810
|
+
*/
|
|
5811
|
+
var DetailScheduler = class {
|
|
5812
|
+
tracks = /* @__PURE__ */ new Map();
|
|
5813
|
+
/** Track appeared with class + announce; returns immediate requests. */
|
|
5814
|
+
onTrackStarted(trackId, className, announce, nowMs) {
|
|
5815
|
+
const steps = /* @__PURE__ */ new Map();
|
|
5816
|
+
const requests = [];
|
|
5817
|
+
for (const stepAnnounce of announce) {
|
|
5818
|
+
if (!stepAnnounce.inputClasses.includes(className)) continue;
|
|
5819
|
+
const state = {
|
|
5820
|
+
announce: stepAnnounce,
|
|
5821
|
+
firedCount: 1,
|
|
5822
|
+
lastFiredAt: nowMs,
|
|
5823
|
+
sticky: false,
|
|
5824
|
+
retryPending: false
|
|
5825
|
+
};
|
|
5826
|
+
steps.set(stepAnnounce.stepId, state);
|
|
5827
|
+
requests.push({
|
|
5828
|
+
trackId,
|
|
5829
|
+
stepId: stepAnnounce.stepId,
|
|
5830
|
+
reason: "new-track"
|
|
5831
|
+
});
|
|
5832
|
+
}
|
|
5833
|
+
this.tracks.set(trackId, steps);
|
|
5834
|
+
return requests;
|
|
5835
|
+
}
|
|
5836
|
+
/** Better candidate crop observed for the track. */
|
|
5837
|
+
onCandidateImproved(trackId, nowMs) {
|
|
5838
|
+
const steps = this.tracks.get(trackId);
|
|
5839
|
+
if (!steps) return [];
|
|
5840
|
+
const requests = [];
|
|
5841
|
+
for (const state of steps.values()) {
|
|
5842
|
+
if (state.announce.cadence.trigger !== "improve") continue;
|
|
5843
|
+
if (!this.canFire(state, nowMs)) continue;
|
|
5844
|
+
this.markFired(state, nowMs);
|
|
5845
|
+
requests.push({
|
|
5846
|
+
trackId,
|
|
5847
|
+
stepId: state.announce.stepId,
|
|
5848
|
+
reason: "improve"
|
|
5849
|
+
});
|
|
5850
|
+
}
|
|
5851
|
+
return requests;
|
|
5852
|
+
}
|
|
5853
|
+
/** Periodic tick (call ~1/s). Also carries pending retries for any trigger kind. */
|
|
5854
|
+
tick(nowMs) {
|
|
5855
|
+
const requests = [];
|
|
5856
|
+
for (const [trackId, steps] of this.tracks) for (const state of steps.values()) {
|
|
5857
|
+
if (state.sticky) continue;
|
|
5858
|
+
if (state.retryPending) {
|
|
5859
|
+
if (!this.intervalElapsed(state, nowMs)) continue;
|
|
5860
|
+
if (!this.underMaxPerTrack(state)) {
|
|
5861
|
+
state.retryPending = false;
|
|
5862
|
+
continue;
|
|
5863
|
+
}
|
|
5864
|
+
this.markFired(state, nowMs);
|
|
5865
|
+
requests.push({
|
|
5866
|
+
trackId,
|
|
5867
|
+
stepId: state.announce.stepId,
|
|
5868
|
+
reason: "retry"
|
|
5869
|
+
});
|
|
5870
|
+
continue;
|
|
5871
|
+
}
|
|
5872
|
+
if (state.announce.cadence.trigger !== "periodic") continue;
|
|
5873
|
+
if (!this.canFire(state, nowMs)) continue;
|
|
5874
|
+
this.markFired(state, nowMs);
|
|
5875
|
+
requests.push({
|
|
5876
|
+
trackId,
|
|
5877
|
+
stepId: state.announce.stepId,
|
|
5878
|
+
reason: "periodic"
|
|
5879
|
+
});
|
|
5880
|
+
}
|
|
5881
|
+
return requests;
|
|
5882
|
+
}
|
|
5883
|
+
/**
|
|
5884
|
+
* Result arrived; confidence drives sticky/retry. null = failed (retry per
|
|
5885
|
+
* policy). `_nowMs` is part of the public signature for symmetry with the
|
|
5886
|
+
* other methods but isn't needed here — retry backoff is anchored to
|
|
5887
|
+
* `lastFiredAt` (set when the step was actually dispatched), not to when
|
|
5888
|
+
* its result came back.
|
|
5889
|
+
*/
|
|
5890
|
+
onResult(trackId, stepId, confidence, _nowMs) {
|
|
5891
|
+
const steps = this.tracks.get(trackId);
|
|
5892
|
+
if (!steps) return;
|
|
5893
|
+
const state = steps.get(stepId);
|
|
5894
|
+
if (!state) return;
|
|
5895
|
+
if (state.sticky) return;
|
|
5896
|
+
const { stickyOnConfidence } = state.announce.cadence;
|
|
5897
|
+
if (confidence !== null && stickyOnConfidence !== void 0 && confidence >= stickyOnConfidence) {
|
|
5898
|
+
state.sticky = true;
|
|
5899
|
+
state.retryPending = false;
|
|
5900
|
+
return;
|
|
5901
|
+
}
|
|
5902
|
+
if (confidence === null) {
|
|
5903
|
+
if (this.underMaxPerTrack(state)) state.retryPending = true;
|
|
5904
|
+
return;
|
|
5905
|
+
}
|
|
5906
|
+
if (state.announce.cadence.trigger === "once" && stickyOnConfidence !== void 0) {
|
|
5907
|
+
if (this.underMaxPerTrack(state)) state.retryPending = true;
|
|
5908
|
+
}
|
|
5909
|
+
}
|
|
5910
|
+
onTrackEnded(trackId) {
|
|
5911
|
+
this.tracks.delete(trackId);
|
|
5912
|
+
}
|
|
5913
|
+
canFire(state, nowMs) {
|
|
5914
|
+
if (state.sticky) return false;
|
|
5915
|
+
if (!this.underMaxPerTrack(state)) return false;
|
|
5916
|
+
return this.intervalElapsed(state, nowMs);
|
|
5917
|
+
}
|
|
5918
|
+
intervalElapsed(state, nowMs) {
|
|
5919
|
+
if (state.lastFiredAt === null) return true;
|
|
5920
|
+
const minIntervalMs = state.announce.cadence.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
|
|
5921
|
+
return nowMs - state.lastFiredAt >= minIntervalMs;
|
|
5922
|
+
}
|
|
5923
|
+
underMaxPerTrack(state) {
|
|
5924
|
+
const maxPerTrack = state.announce.cadence.maxPerTrack ?? (state.announce.cadence.trigger === "once" ? DEFAULT_ONCE_MAX_PER_TRACK : Infinity);
|
|
5925
|
+
return state.firedCount < maxPerTrack;
|
|
5926
|
+
}
|
|
5927
|
+
markFired(state, nowMs) {
|
|
5928
|
+
state.firedCount += 1;
|
|
5929
|
+
state.lastFiredAt = nowMs;
|
|
5930
|
+
state.retryPending = false;
|
|
5931
|
+
}
|
|
5932
|
+
};
|
|
5933
|
+
//#endregion
|
|
5934
|
+
//#region src/pipeline-analytics/detail-dispatcher.ts
|
|
5935
|
+
/** Throttle for the per-device "detail call failed" warn — one line / minute. */
|
|
5936
|
+
var FAIL_WARN_THROTTLE_MS = 6e4;
|
|
5937
|
+
var TrackDetailDispatcher = class {
|
|
5938
|
+
deps;
|
|
5939
|
+
devices = /* @__PURE__ */ new Map();
|
|
5940
|
+
maxInFlight;
|
|
5941
|
+
tickIntervalMs;
|
|
5942
|
+
disposed = false;
|
|
5943
|
+
constructor(deps) {
|
|
5944
|
+
this.deps = deps;
|
|
5945
|
+
this.maxInFlight = deps.maxInFlightPerDevice ?? 2;
|
|
5946
|
+
this.tickIntervalMs = deps.tickIntervalMs ?? 1e3;
|
|
5947
|
+
}
|
|
5948
|
+
/** A track appeared: record its frame, seed its best-confidence, and dispatch
|
|
5949
|
+
* the scheduler's immediate (new-track) requests. */
|
|
5950
|
+
onTrackStarted(deviceId, trackId, className, announce, frame, nowMs) {
|
|
5951
|
+
if (this.disposed) return;
|
|
5952
|
+
const dev = this.ensureDevice(deviceId);
|
|
5953
|
+
dev.tracks.set(trackId, frame);
|
|
5954
|
+
dev.candidateBest.observe(trackId, frame.confidence, frame.timestamp);
|
|
5955
|
+
const requests = dev.scheduler.onTrackStarted(trackId, className, announce, nowMs);
|
|
5956
|
+
this.enqueue(deviceId, dev, requests);
|
|
5957
|
+
this.ensureTimer(deviceId, dev);
|
|
5958
|
+
}
|
|
5959
|
+
/** A subsequent frame for a live track: refresh its frame + fire
|
|
5960
|
+
* `improve`-cadence steps when the detector confidence strictly improves.
|
|
5961
|
+
*
|
|
5962
|
+
* `announce` is the frame's currently-announced detail chain. When a track
|
|
5963
|
+
* is alive but has NO dispatcher state yet — it existed before `detailSteps`
|
|
5964
|
+
* first appeared (a mid-track redeploy / config change) — this adopts it as a
|
|
5965
|
+
* new track so it starts getting scheduled instead of starving for its whole
|
|
5966
|
+
* life. Idempotent: guarded by the per-track state check, so a track that
|
|
5967
|
+
* already has state is never reset. */
|
|
5968
|
+
onFrame(deviceId, trackId, announce, frame, nowMs) {
|
|
5969
|
+
if (this.disposed) return;
|
|
5970
|
+
const dev = this.devices.get(deviceId);
|
|
5971
|
+
if (!dev || !dev.tracks.has(trackId)) {
|
|
5972
|
+
if (announce.length > 0) this.onTrackStarted(deviceId, trackId, frame.className, announce, frame, nowMs);
|
|
5973
|
+
return;
|
|
5974
|
+
}
|
|
5975
|
+
dev.tracks.set(trackId, frame);
|
|
5976
|
+
if (!dev.candidateBest.observe(trackId, frame.confidence, frame.timestamp)) return;
|
|
5977
|
+
const requests = dev.scheduler.onCandidateImproved(trackId, nowMs);
|
|
5978
|
+
this.enqueue(deviceId, dev, requests);
|
|
5979
|
+
}
|
|
5980
|
+
/** A track ended (durable TTL expiry): drop its scheduler + frame state. Any
|
|
5981
|
+
* queued request for it is discarded at dequeue. */
|
|
5982
|
+
onTrackEnded(deviceId, trackId) {
|
|
5983
|
+
const dev = this.devices.get(deviceId);
|
|
5984
|
+
if (!dev) return;
|
|
5985
|
+
dev.scheduler.onTrackEnded(trackId);
|
|
5986
|
+
dev.tracks.delete(trackId);
|
|
5987
|
+
dev.candidateBest.delete(trackId);
|
|
5988
|
+
if (dev.tracks.size === 0) this.clearTimer(dev);
|
|
5989
|
+
}
|
|
5990
|
+
dispose() {
|
|
5991
|
+
this.disposed = true;
|
|
5992
|
+
for (const dev of this.devices.values()) {
|
|
5993
|
+
this.clearTimer(dev);
|
|
5994
|
+
dev.queue.length = 0;
|
|
5995
|
+
dev.tracks.clear();
|
|
5996
|
+
dev.candidateBest.clear();
|
|
5997
|
+
}
|
|
5998
|
+
this.devices.clear();
|
|
5999
|
+
}
|
|
6000
|
+
ensureDevice(deviceId) {
|
|
6001
|
+
let dev = this.devices.get(deviceId);
|
|
6002
|
+
if (!dev) {
|
|
6003
|
+
dev = {
|
|
6004
|
+
scheduler: new DetailScheduler(),
|
|
6005
|
+
tracks: /* @__PURE__ */ new Map(),
|
|
6006
|
+
candidateBest: new BestDetectionTracker(),
|
|
6007
|
+
queue: [],
|
|
6008
|
+
inFlight: 0,
|
|
6009
|
+
timer: null,
|
|
6010
|
+
lastFailWarnAt: 0
|
|
6011
|
+
};
|
|
6012
|
+
this.devices.set(deviceId, dev);
|
|
6013
|
+
}
|
|
6014
|
+
return dev;
|
|
6015
|
+
}
|
|
6016
|
+
ensureTimer(deviceId, dev) {
|
|
6017
|
+
if (dev.timer) return;
|
|
6018
|
+
const timer = setInterval(() => this.tick(deviceId, dev), this.tickIntervalMs);
|
|
6019
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
6020
|
+
dev.timer = timer;
|
|
6021
|
+
}
|
|
6022
|
+
clearTimer(dev) {
|
|
6023
|
+
if (dev.timer) {
|
|
6024
|
+
clearInterval(dev.timer);
|
|
6025
|
+
dev.timer = null;
|
|
6026
|
+
}
|
|
6027
|
+
}
|
|
6028
|
+
tick(deviceId, dev) {
|
|
6029
|
+
if (this.disposed) return;
|
|
6030
|
+
const requests = dev.scheduler.tick(Date.now());
|
|
6031
|
+
this.enqueue(deviceId, dev, requests);
|
|
6032
|
+
}
|
|
6033
|
+
enqueue(deviceId, dev, requests) {
|
|
6034
|
+
if (requests.length === 0) return;
|
|
6035
|
+
for (const r of requests) dev.queue.push(r);
|
|
6036
|
+
this.pump(deviceId, dev);
|
|
6037
|
+
}
|
|
6038
|
+
pump(deviceId, dev) {
|
|
6039
|
+
while (dev.inFlight < this.maxInFlight && dev.queue.length > 0) {
|
|
6040
|
+
const req = dev.queue.shift();
|
|
6041
|
+
if (req === void 0) break;
|
|
6042
|
+
const frame = dev.tracks.get(req.trackId);
|
|
6043
|
+
if (frame === void 0) continue;
|
|
6044
|
+
dev.inFlight += 1;
|
|
6045
|
+
this.dispatch(deviceId, dev, req, frame).finally(() => {
|
|
6046
|
+
dev.inFlight -= 1;
|
|
6047
|
+
this.pump(deviceId, dev);
|
|
6048
|
+
});
|
|
6049
|
+
}
|
|
6050
|
+
}
|
|
6051
|
+
async dispatch(deviceId, dev, req, frame) {
|
|
6052
|
+
const details = await this.runOnce(deviceId, dev, req, frame);
|
|
6053
|
+
let topScore = null;
|
|
6054
|
+
if (details !== null && details.length > 0) {
|
|
6055
|
+
topScore = details.reduce((max, d) => d.score > max ? d.score : max, Number.NEGATIVE_INFINITY);
|
|
6056
|
+
try {
|
|
6057
|
+
await this.deps.routeResults(deviceId, req.trackId, details, frame);
|
|
6058
|
+
} catch (err) {
|
|
6059
|
+
this.deps.logger.warn("detail result routing failed", {
|
|
6060
|
+
tags: { deviceId },
|
|
6061
|
+
meta: {
|
|
6062
|
+
trackId: req.trackId,
|
|
6063
|
+
stepId: req.stepId,
|
|
6064
|
+
error: String(err)
|
|
6065
|
+
}
|
|
6066
|
+
});
|
|
6067
|
+
}
|
|
6068
|
+
}
|
|
6069
|
+
dev.scheduler.onResult(req.trackId, req.stepId, topScore, Date.now());
|
|
6070
|
+
}
|
|
6071
|
+
/**
|
|
6072
|
+
* Run the request once via the frameHandle, and — on a miss (null OR throw)
|
|
6073
|
+
* — retry ONCE with a `cropJpeg` fallback when one can be captured. Returns
|
|
6074
|
+
* the detail list, or `null` when both attempts fail to produce a result.
|
|
6075
|
+
*/
|
|
6076
|
+
async runOnce(deviceId, dev, req, frame) {
|
|
6077
|
+
const parent = {
|
|
6078
|
+
bbox: { ...frame.bbox },
|
|
6079
|
+
className: frame.className
|
|
6080
|
+
};
|
|
6081
|
+
if (frame.frameHandle !== void 0) try {
|
|
6082
|
+
const primary = await this.deps.runDetailSubtree({
|
|
6083
|
+
deviceId,
|
|
6084
|
+
frameHandle: frame.frameHandle,
|
|
6085
|
+
parent,
|
|
6086
|
+
steps: [req.stepId]
|
|
6087
|
+
}, frame.nodeId);
|
|
6088
|
+
if (primary !== null) return primary.details;
|
|
6089
|
+
} catch (err) {
|
|
6090
|
+
this.deps.logger.debug("detail primary call failed — trying crop fallback", {
|
|
6091
|
+
tags: { deviceId },
|
|
6092
|
+
meta: {
|
|
6093
|
+
trackId: req.trackId,
|
|
6094
|
+
stepId: req.stepId,
|
|
6095
|
+
error: String(err)
|
|
6096
|
+
}
|
|
6097
|
+
});
|
|
6098
|
+
}
|
|
6099
|
+
if (this.deps.captureCropBase64 !== void 0) try {
|
|
6100
|
+
const cropJpeg = await this.deps.captureCropBase64(frame);
|
|
6101
|
+
if (cropJpeg !== null) {
|
|
6102
|
+
const retry = await this.deps.runDetailSubtree({
|
|
6103
|
+
deviceId,
|
|
6104
|
+
cropJpeg,
|
|
6105
|
+
parent,
|
|
6106
|
+
steps: [req.stepId]
|
|
6107
|
+
}, frame.nodeId);
|
|
6108
|
+
if (retry !== null) return retry.details;
|
|
6109
|
+
}
|
|
6110
|
+
} catch (err) {
|
|
6111
|
+
this.deps.logger.debug("detail crop-fallback call failed", {
|
|
6112
|
+
tags: { deviceId },
|
|
6113
|
+
meta: {
|
|
6114
|
+
trackId: req.trackId,
|
|
6115
|
+
stepId: req.stepId,
|
|
6116
|
+
error: String(err)
|
|
6117
|
+
}
|
|
6118
|
+
});
|
|
6119
|
+
}
|
|
6120
|
+
this.warnFailThrottled(deviceId, dev, req);
|
|
6121
|
+
return null;
|
|
6122
|
+
}
|
|
6123
|
+
warnFailThrottled(deviceId, dev, req) {
|
|
6124
|
+
const now = Date.now();
|
|
6125
|
+
if (now - dev.lastFailWarnAt < FAIL_WARN_THROTTLE_MS) return;
|
|
6126
|
+
dev.lastFailWarnAt = now;
|
|
6127
|
+
this.deps.logger.warn("runDetailSubtree produced no result (frame + crop both missed)", {
|
|
6128
|
+
tags: { deviceId },
|
|
6129
|
+
meta: {
|
|
6130
|
+
trackId: req.trackId,
|
|
6131
|
+
stepId: req.stepId,
|
|
6132
|
+
reason: req.reason
|
|
6133
|
+
}
|
|
6134
|
+
});
|
|
6135
|
+
}
|
|
6136
|
+
};
|
|
6137
|
+
//#endregion
|
|
5753
6138
|
//#region src/pipeline-analytics/store/plate-store.ts
|
|
5754
6139
|
var PLATES_COLLECTION = "pipeline-analytics:plates";
|
|
5755
6140
|
var PLATE_COLUMNS = [
|
|
@@ -6394,6 +6779,12 @@ function createEventMediaHandler(deps) {
|
|
|
6394
6779
|
* surface to turn the refinement pipeline on/off for a camera.
|
|
6395
6780
|
*/
|
|
6396
6781
|
var TTL_SWEEP_INTERVAL_MS = 5e3;
|
|
6782
|
+
/** Padding applied to the parent bbox for the `cropJpeg` retry fallback when a
|
|
6783
|
+
* scheduled detail call's frameHandle lease is already gone. */
|
|
6784
|
+
var DETAIL_FALLBACK_CROP_PADDING = .15;
|
|
6785
|
+
/** How long the active CLIP model id (from the embedding-encoder) is cached
|
|
6786
|
+
* before re-reading. */
|
|
6787
|
+
var CLIP_MODEL_ID_CACHE_TTL_MS = 6e4;
|
|
6397
6788
|
var SETTINGS_CACHE_TTL_MS = 5e3;
|
|
6398
6789
|
/** §5 best-frame: a track's `thumbnail` is overwritten only when the current
|
|
6399
6790
|
* detection confidence beats the held best by at least this margin (hysteresis
|
|
@@ -6426,6 +6817,15 @@ var RETENTION_SWEEP_INTERVAL_MS = 5 * 6e4;
|
|
|
6426
6817
|
var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
|
|
6427
6818
|
var MOTION_EVENT_HEARTBEAT_MS = 5e3;
|
|
6428
6819
|
/**
|
|
6820
|
+
* Decode a base64 little-endian float32 vector (the `DetailResult.embedding`
|
|
6821
|
+
* wire encoding produced by `runDetailSubtree`) back into a plain number[].
|
|
6822
|
+
*/
|
|
6823
|
+
function decodeEmbeddingBase64(base64) {
|
|
6824
|
+
const bytes = Buffer.from(base64, "base64");
|
|
6825
|
+
const view = new Float32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
|
|
6826
|
+
return Array.from(view);
|
|
6827
|
+
}
|
|
6828
|
+
/**
|
|
6429
6829
|
* Re-home the global analytics sections into the per-device `Analytics`
|
|
6430
6830
|
* top-tab. Every section defaults to the `Analytics` tab (so the
|
|
6431
6831
|
* device-manager aggregator groups them) AND is marked
|
|
@@ -6475,6 +6875,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6475
6875
|
plateStore = null;
|
|
6476
6876
|
plateRecognizer = null;
|
|
6477
6877
|
objectEmbeddingStore = null;
|
|
6878
|
+
/** Two-plane detail scheduler/dispatcher: per-track on-demand face/clip/
|
|
6879
|
+
* classifier/plate enrichment via `pipelineRunner.runDetailSubtree`. Replaces
|
|
6880
|
+
* the per-frame child consumption the executor no longer emits. */
|
|
6881
|
+
detailDispatcher = null;
|
|
6882
|
+
/** Active CLIP image-embedding model id (== the text encoder's), TTL-cached.
|
|
6883
|
+
* Stamped on object-embedding rows from the detail plane so semantic search's
|
|
6884
|
+
* same-model gate keeps matching. */
|
|
6885
|
+
clipModelIdCache = null;
|
|
6478
6886
|
/** Frame-based event/track media (crop + boxed full-frame) from the
|
|
6479
6887
|
* detection-pipeline DECODED frame — the ONLY image source (never the
|
|
6480
6888
|
* snapshot cap). Null when shm frame access is unavailable. */
|
|
@@ -6734,6 +7142,21 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6734
7142
|
store: api.settingsStore,
|
|
6735
7143
|
logger: logger.child("ObjectEmbeddingStore")
|
|
6736
7144
|
});
|
|
7145
|
+
const runnerApi = api.pipelineRunner;
|
|
7146
|
+
this.detailDispatcher = new TrackDetailDispatcher({
|
|
7147
|
+
logger: logger.child("DetailDispatcher"),
|
|
7148
|
+
runDetailSubtree: async (input, nodeId) => {
|
|
7149
|
+
if (!runnerApi?.runDetailSubtree) return null;
|
|
7150
|
+
if (nodeId !== void 0) return runnerApi.runDetailSubtree.mutate(input, nodePin(nodeId));
|
|
7151
|
+
return runnerApi.runDetailSubtree.mutate(input);
|
|
7152
|
+
},
|
|
7153
|
+
routeResults: (deviceId, trackId, details, frame) => this.routeDetailResults(deviceId, trackId, details, frame),
|
|
7154
|
+
captureCropBase64: async (frame) => {
|
|
7155
|
+
if (frame.frameHandle === void 0 || !this.captureCrop) return null;
|
|
7156
|
+
const buf = await this.captureCrop(frame.frameHandle, { ...frame.bbox }, frame.frameWidth, frame.frameHeight, DETAIL_FALLBACK_CROP_PADDING);
|
|
7157
|
+
return buf ? buf.toString("base64") : null;
|
|
7158
|
+
}
|
|
7159
|
+
});
|
|
6737
7160
|
this.bindingCache = new BindingCache({
|
|
6738
7161
|
api,
|
|
6739
7162
|
logger: logger.child("BindingCache")
|
|
@@ -7138,6 +7561,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7138
7561
|
}
|
|
7139
7562
|
this.zoneAnalytics?.destroy();
|
|
7140
7563
|
this.audioMetrics?.destroy();
|
|
7564
|
+
this.detailDispatcher?.dispose();
|
|
7565
|
+
this.detailDispatcher = null;
|
|
7141
7566
|
this.processors.clear();
|
|
7142
7567
|
this.lastActiveTrackIds.clear();
|
|
7143
7568
|
this.dropoutSkipsByKey.clear();
|
|
@@ -7162,7 +7587,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7162
7587
|
async handleInferenceResult(data) {
|
|
7163
7588
|
if (this.shuttingDown) return;
|
|
7164
7589
|
const { deviceId, frame } = data;
|
|
7165
|
-
await this.processFrame(deviceId, frame, "pipeline", data.frameHandle);
|
|
7590
|
+
await this.processFrame(deviceId, frame, "pipeline", data.frameHandle, data.detailSteps);
|
|
7166
7591
|
}
|
|
7167
7592
|
/**
|
|
7168
7593
|
* Run one detection frame through the analysis layers for a given
|
|
@@ -7172,7 +7597,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7172
7597
|
* tracking/zone/event state never crosses between sources. Emits the
|
|
7173
7598
|
* SAME canonical events, distinguished only by `source`.
|
|
7174
7599
|
*/
|
|
7175
|
-
async processFrame(deviceId, frame, source, frameHandle) {
|
|
7600
|
+
async processFrame(deviceId, frame, source, frameHandle, detailSteps) {
|
|
7176
7601
|
if (this.shuttingDown) return;
|
|
7177
7602
|
if (!await this.bindingCache.isActive(deviceId)) return;
|
|
7178
7603
|
const key = this.procKey(deviceId, source);
|
|
@@ -7284,6 +7709,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7284
7709
|
} });
|
|
7285
7710
|
}
|
|
7286
7711
|
this.lastActiveTrackIds.set(key, currentTrackIds);
|
|
7712
|
+
if (this.detailDispatcher && detailSteps && detailSteps.length > 0) {
|
|
7713
|
+
const dispatcher = this.detailDispatcher;
|
|
7714
|
+
const steps = detailSteps;
|
|
7715
|
+
for (const t of result.tracked) {
|
|
7716
|
+
const detailFrame = {
|
|
7717
|
+
bbox: { ...t.bbox },
|
|
7718
|
+
frameWidth: result.frameWidth,
|
|
7719
|
+
frameHeight: result.frameHeight,
|
|
7720
|
+
className: t.className,
|
|
7721
|
+
confidence: t.confidence,
|
|
7722
|
+
timestamp: result.timestamp,
|
|
7723
|
+
...frameHandle !== void 0 ? {
|
|
7724
|
+
frameHandle,
|
|
7725
|
+
nodeId: frameHandle.nodeId
|
|
7726
|
+
} : {}
|
|
7727
|
+
};
|
|
7728
|
+
if (prevIds.has(t.trackId)) dispatcher.onFrame(deviceId, t.trackId, steps, detailFrame, result.timestamp);
|
|
7729
|
+
else dispatcher.onTrackStarted(deviceId, t.trackId, t.className, steps, detailFrame, result.timestamp);
|
|
7730
|
+
}
|
|
7731
|
+
}
|
|
7287
7732
|
if (newTrackCount > 0 || lostTrackCount > 0 || result.objectEvents.length > 0) {
|
|
7288
7733
|
const byState = {};
|
|
7289
7734
|
for (const t of result.tracked) byState[t.state] = (byState[t.state] ?? 0) + 1;
|
|
@@ -7495,6 +7940,142 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7495
7940
|
return settings;
|
|
7496
7941
|
}
|
|
7497
7942
|
/**
|
|
7943
|
+
* Route one track's `runDetailSubtree` results (two-plane detail dispatch)
|
|
7944
|
+
* into the EXISTING per-track consumers, discriminated by payload SHAPE:
|
|
7945
|
+
* • embedding + alignedCropJpeg → FACE (arcface): the FaceRecognizer's
|
|
7946
|
+
* candidate/best-face/gallery/keyFrame path (input source cut-over);
|
|
7947
|
+
* • embedding only → CLIP object embedding → the object-embedding store
|
|
7948
|
+
* (semantic search);
|
|
7949
|
+
* • label only → classifier answer / plate OCR text → the track's
|
|
7950
|
+
* enrichment label the notifier/UI already read.
|
|
7951
|
+
* Best-effort (D8): a per-detail failure is logged and never propagated.
|
|
7952
|
+
*/
|
|
7953
|
+
async routeDetailResults(deviceId, trackId, details, frame) {
|
|
7954
|
+
for (const d of details) try {
|
|
7955
|
+
if (d.className === "face" && d.embedding !== void 0) await this.routeFaceDetail(deviceId, trackId, d, frame);
|
|
7956
|
+
else if (d.embedding !== void 0) await this.routeClipDetail(deviceId, trackId, d, frame.timestamp);
|
|
7957
|
+
else if (d.label !== void 0 && d.label.length > 0) await this.applyTrackEnrichmentLabel(deviceId, trackId, d.label);
|
|
7958
|
+
} catch (err) {
|
|
7959
|
+
this.ctx.logger.warn("detail result route failed", {
|
|
7960
|
+
tags: { deviceId },
|
|
7961
|
+
meta: {
|
|
7962
|
+
trackId,
|
|
7963
|
+
stepId: d.stepId,
|
|
7964
|
+
error: errMsg(err)
|
|
7965
|
+
}
|
|
7966
|
+
});
|
|
7967
|
+
}
|
|
7968
|
+
}
|
|
7969
|
+
/** Face-embedding detail → the FaceRecognizer (same gate + logic as the
|
|
7970
|
+
* former per-frame face path; only the input source moved). */
|
|
7971
|
+
async routeFaceDetail(deviceId, trackId, detail, frame) {
|
|
7972
|
+
if (!this.faceRecognizer || detail.embedding === void 0) return;
|
|
7973
|
+
if (!await this.resolveGlobalFaceEnabled()) return;
|
|
7974
|
+
const [settings, media] = await Promise.all([this.resolveDeviceFaceSettings(deviceId), this.resolveDeviceMediaSettings(deviceId)]);
|
|
7975
|
+
await this.faceRecognizer.ingestFaceDetail({
|
|
7976
|
+
deviceId,
|
|
7977
|
+
trackId,
|
|
7978
|
+
timestamp: frame.timestamp,
|
|
7979
|
+
frameWidth: frame.frameWidth,
|
|
7980
|
+
frameHeight: frame.frameHeight,
|
|
7981
|
+
score: detail.score,
|
|
7982
|
+
embedding: decodeEmbeddingBase64(detail.embedding),
|
|
7983
|
+
parentBbox: { ...frame.bbox },
|
|
7984
|
+
...detail.bbox !== void 0 ? { faceBbox: { ...detail.bbox } } : {},
|
|
7985
|
+
...detail.alignedCropJpeg !== void 0 ? { alignedCropJpeg: detail.alignedCropJpeg } : {},
|
|
7986
|
+
settings,
|
|
7987
|
+
cropPadding: media.cropPadding,
|
|
7988
|
+
...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {}
|
|
7989
|
+
});
|
|
7990
|
+
}
|
|
7991
|
+
/** CLIP object-embedding detail → the object-embedding store (semantic
|
|
7992
|
+
* search). Stamps the active encoder's model id so the same-model search
|
|
7993
|
+
* gate keeps matching. */
|
|
7994
|
+
async routeClipDetail(deviceId, trackId, detail, timestamp) {
|
|
7995
|
+
const store = this.objectEmbeddingStore;
|
|
7996
|
+
if (!store || detail.embedding === void 0) return;
|
|
7997
|
+
const modelId = await this.resolveClipModelId();
|
|
7998
|
+
if (modelId === null) {
|
|
7999
|
+
this.ctx.logger.debug("clip detail dropped — no active embedding model id", {
|
|
8000
|
+
tags: { deviceId },
|
|
8001
|
+
meta: {
|
|
8002
|
+
trackId,
|
|
8003
|
+
stepId: detail.stepId
|
|
8004
|
+
}
|
|
8005
|
+
});
|
|
8006
|
+
return;
|
|
8007
|
+
}
|
|
8008
|
+
await store.upsertIfBetter({
|
|
8009
|
+
trackId,
|
|
8010
|
+
deviceId,
|
|
8011
|
+
timestamp,
|
|
8012
|
+
className: detail.className,
|
|
8013
|
+
embedding: decodeEmbeddingBase64(detail.embedding),
|
|
8014
|
+
modelId,
|
|
8015
|
+
confidence: detail.score
|
|
8016
|
+
});
|
|
8017
|
+
}
|
|
8018
|
+
/** Classifier answer / plate OCR text → the track's enrichment label
|
|
8019
|
+
* (TrackStore + persisted events + importance), mirroring the FaceRecognizer
|
|
8020
|
+
* label-propagation path. */
|
|
8021
|
+
async applyTrackEnrichmentLabel(deviceId, trackId, label) {
|
|
8022
|
+
try {
|
|
8023
|
+
await this.trackStore?.setLabel(trackId, label);
|
|
8024
|
+
} catch (err) {
|
|
8025
|
+
this.ctx.logger.warn("detail label setLabel failed", {
|
|
8026
|
+
tags: { deviceId },
|
|
8027
|
+
meta: {
|
|
8028
|
+
trackId,
|
|
8029
|
+
error: errMsg(err)
|
|
8030
|
+
}
|
|
8031
|
+
});
|
|
8032
|
+
}
|
|
8033
|
+
try {
|
|
8034
|
+
await this.eventStore?.setLabelForTrack(trackId, label);
|
|
8035
|
+
} catch (err) {
|
|
8036
|
+
this.ctx.logger.warn("detail label setLabelForTrack failed", {
|
|
8037
|
+
tags: { deviceId },
|
|
8038
|
+
meta: {
|
|
8039
|
+
trackId,
|
|
8040
|
+
error: errMsg(err)
|
|
8041
|
+
}
|
|
8042
|
+
});
|
|
8043
|
+
}
|
|
8044
|
+
try {
|
|
8045
|
+
const trackStore = this.trackStore;
|
|
8046
|
+
const eventStore = this.eventStore;
|
|
8047
|
+
if (trackStore && eventStore) await recomputeTrackImportance({
|
|
8048
|
+
trackStore,
|
|
8049
|
+
eventStore
|
|
8050
|
+
}, trackId);
|
|
8051
|
+
} catch (err) {
|
|
8052
|
+
this.ctx.logger.debug("detail label recomputeImportance failed", {
|
|
8053
|
+
tags: { deviceId },
|
|
8054
|
+
meta: {
|
|
8055
|
+
trackId,
|
|
8056
|
+
error: errMsg(err)
|
|
8057
|
+
}
|
|
8058
|
+
});
|
|
8059
|
+
}
|
|
8060
|
+
}
|
|
8061
|
+
/** Active CLIP image-embedding model id (== the text encoder's), TTL-cached.
|
|
8062
|
+
* Returns null when the embedding-encoder cap is unavailable. */
|
|
8063
|
+
async resolveClipModelId() {
|
|
8064
|
+
const now = Date.now();
|
|
8065
|
+
if (this.clipModelIdCache && now < this.clipModelIdCache.expiresAt) return this.clipModelIdCache.value;
|
|
8066
|
+
let value = null;
|
|
8067
|
+
try {
|
|
8068
|
+
value = (await this.ctx.api.embeddingEncoder.getInfo.query())?.modelId ?? null;
|
|
8069
|
+
} catch (err) {
|
|
8070
|
+
this.ctx.logger.debug("resolveClipModelId: getInfo failed", { meta: { error: errMsg(err) } });
|
|
8071
|
+
}
|
|
8072
|
+
this.clipModelIdCache = {
|
|
8073
|
+
value,
|
|
8074
|
+
expiresAt: now + CLIP_MODEL_ID_CACHE_TTL_MS
|
|
8075
|
+
};
|
|
8076
|
+
return value;
|
|
8077
|
+
}
|
|
8078
|
+
/**
|
|
7498
8079
|
* §5 — decide which active tracks need periodic media THIS frame. Pure over
|
|
7499
8080
|
* TrackStore.lastSnapshotAt + the per-track best-confidence map:
|
|
7500
8081
|
* • `snapshot` (append) + `lastFrame` (rolling overwrite) fire together on
|
|
@@ -7882,6 +8463,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
7882
8463
|
}
|
|
7883
8464
|
this.bestFrameTracker.delete(t.trackId);
|
|
7884
8465
|
this.objectEmbeddingBestSelector.delete(t.trackId);
|
|
8466
|
+
this.detailDispatcher?.onTrackEnded(t.deviceId, t.trackId);
|
|
7885
8467
|
this.ctx.eventBus.emit({
|
|
7886
8468
|
id: `pa-end-${t.trackId}`,
|
|
7887
8469
|
timestamp: new Date(t.lastSeen),
|