@camstack/addon-post-analysis 1.1.3 → 1.1.5
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-BUE-vLMt.mjs → dist-BpGP9ago.mjs} +57 -12
- package/dist/{dist-BY1NQ5oi.js → dist-n-zJ0Uox.js} +62 -11
- package/dist/embedding-encoder/index.js +273 -97
- package/dist/embedding-encoder/index.mjs +271 -95
- package/dist/enrichment-engine/index.js +2 -2
- package/dist/enrichment-engine/index.mjs +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-D5LAGg5T.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DgAlWohT.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-BFIbMkkd.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DBkzwlqD.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-D8QpRf30.mjs → hostInit-D51b7QGW.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +316 -2
- package/dist/pipeline-analytics/index.mjs +315 -1
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/dist/{resolve-frame-B2Do-e7z.js → resolve-frame-DB2NdMu2.js} +1 -1
- package/package.json +2 -1
- package/python/raw_tensor_inference.py +73 -0
- package/python/requirements-embedding.txt +8 -0
- package/python/tensor_frames.py +70 -0
- package/python/test_text_encoder.py +48 -0
- package/python/text_encoder_inference.py +76 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as faceGalleryCapability, b as
|
|
1
|
+
import { S as object, _ as hydrateSchema, a as faceGalleryCapability, b as boolean, c as plateGalleryCapability, d as errMsg, f as BaseAddon, l as videoclipsCapability, m as EventCategory, n as audioMetricsCapability, p as DeviceType, r as cosineSimilarity, s as pipelineAnalyticsCapability, t as addonWidgetsSourceCapability, u as zoneAnalyticsCapability, x as number } from "../dist-BpGP9ago.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";
|
|
@@ -4475,6 +4475,178 @@ var FaceStore = class {
|
|
|
4475
4475
|
}
|
|
4476
4476
|
};
|
|
4477
4477
|
//#endregion
|
|
4478
|
+
//#region src/pipeline-analytics/store/object-embedding-store.ts
|
|
4479
|
+
var OBJECT_EMBEDDINGS_COLLECTION = "pipeline-analytics:object-embeddings";
|
|
4480
|
+
var OBJECT_EMBEDDING_COLUMNS = [
|
|
4481
|
+
{
|
|
4482
|
+
name: "id",
|
|
4483
|
+
type: "TEXT",
|
|
4484
|
+
primaryKey: true,
|
|
4485
|
+
notNull: true
|
|
4486
|
+
},
|
|
4487
|
+
{
|
|
4488
|
+
name: "deviceId",
|
|
4489
|
+
type: "INTEGER",
|
|
4490
|
+
notNull: true
|
|
4491
|
+
},
|
|
4492
|
+
{
|
|
4493
|
+
name: "trackId",
|
|
4494
|
+
type: "TEXT",
|
|
4495
|
+
notNull: true
|
|
4496
|
+
},
|
|
4497
|
+
{
|
|
4498
|
+
name: "timestamp",
|
|
4499
|
+
type: "INTEGER",
|
|
4500
|
+
notNull: true
|
|
4501
|
+
},
|
|
4502
|
+
{
|
|
4503
|
+
name: "className",
|
|
4504
|
+
type: "TEXT",
|
|
4505
|
+
notNull: true
|
|
4506
|
+
},
|
|
4507
|
+
{
|
|
4508
|
+
name: "embedding",
|
|
4509
|
+
type: "JSON",
|
|
4510
|
+
notNull: true
|
|
4511
|
+
},
|
|
4512
|
+
{
|
|
4513
|
+
name: "modelId",
|
|
4514
|
+
type: "TEXT",
|
|
4515
|
+
notNull: true
|
|
4516
|
+
},
|
|
4517
|
+
{
|
|
4518
|
+
name: "dim",
|
|
4519
|
+
type: "INTEGER",
|
|
4520
|
+
notNull: true
|
|
4521
|
+
},
|
|
4522
|
+
{
|
|
4523
|
+
name: "confidence",
|
|
4524
|
+
type: "REAL",
|
|
4525
|
+
notNull: true
|
|
4526
|
+
},
|
|
4527
|
+
{
|
|
4528
|
+
name: "mediaKey",
|
|
4529
|
+
type: "TEXT"
|
|
4530
|
+
}
|
|
4531
|
+
];
|
|
4532
|
+
var ObjectEmbeddingStore = class {
|
|
4533
|
+
store;
|
|
4534
|
+
logger;
|
|
4535
|
+
constructor(deps) {
|
|
4536
|
+
this.store = deps.store;
|
|
4537
|
+
this.logger = deps.logger;
|
|
4538
|
+
}
|
|
4539
|
+
static async declare(store) {
|
|
4540
|
+
await store.declareCollection.mutate({
|
|
4541
|
+
collection: OBJECT_EMBEDDINGS_COLLECTION,
|
|
4542
|
+
columns: [...OBJECT_EMBEDDING_COLUMNS],
|
|
4543
|
+
indexes: [{
|
|
4544
|
+
name: "idx_obj_emb_device_ts",
|
|
4545
|
+
columns: ["deviceId", "timestamp"]
|
|
4546
|
+
}, {
|
|
4547
|
+
name: "idx_obj_emb_track",
|
|
4548
|
+
columns: ["trackId"]
|
|
4549
|
+
}]
|
|
4550
|
+
});
|
|
4551
|
+
}
|
|
4552
|
+
/**
|
|
4553
|
+
* Upsert the best-confidence CLIP embedding for a track. The id is the
|
|
4554
|
+
* trackId so there is at most ONE row per track. An upsert only replaces
|
|
4555
|
+
* the row when the new confidence is strictly higher than the stored value,
|
|
4556
|
+
* keeping the best-crop embedding for the track over its lifetime.
|
|
4557
|
+
*/
|
|
4558
|
+
async upsertIfBetter(input) {
|
|
4559
|
+
const existing = await this.store.get.query({
|
|
4560
|
+
collection: OBJECT_EMBEDDINGS_COLLECTION,
|
|
4561
|
+
key: input.trackId
|
|
4562
|
+
});
|
|
4563
|
+
if (existing !== void 0 && existing !== null) {
|
|
4564
|
+
const storedConf = existing.confidence;
|
|
4565
|
+
if (typeof storedConf === "number" && input.confidence <= storedConf) return;
|
|
4566
|
+
}
|
|
4567
|
+
const record = {
|
|
4568
|
+
deviceId: input.deviceId,
|
|
4569
|
+
trackId: input.trackId,
|
|
4570
|
+
timestamp: input.timestamp,
|
|
4571
|
+
className: input.className,
|
|
4572
|
+
embedding: input.embedding,
|
|
4573
|
+
modelId: input.modelId,
|
|
4574
|
+
dim: input.embedding.length,
|
|
4575
|
+
confidence: input.confidence,
|
|
4576
|
+
...input.mediaKey !== void 0 ? { mediaKey: input.mediaKey } : {}
|
|
4577
|
+
};
|
|
4578
|
+
try {
|
|
4579
|
+
await this.store.set.mutate({
|
|
4580
|
+
collection: OBJECT_EMBEDDINGS_COLLECTION,
|
|
4581
|
+
key: input.trackId,
|
|
4582
|
+
value: record
|
|
4583
|
+
});
|
|
4584
|
+
} catch (err) {
|
|
4585
|
+
this.logger.warn("ObjectEmbeddingStore.upsertIfBetter failed", { meta: {
|
|
4586
|
+
trackId: input.trackId,
|
|
4587
|
+
error: String(err)
|
|
4588
|
+
} });
|
|
4589
|
+
}
|
|
4590
|
+
}
|
|
4591
|
+
/**
|
|
4592
|
+
* Load all embeddings for a device within a time range. Applies `modelId`
|
|
4593
|
+
* and `className` filters when provided. Used by `searchObjectEvents`.
|
|
4594
|
+
*/
|
|
4595
|
+
async query(input) {
|
|
4596
|
+
const where = {};
|
|
4597
|
+
if (input.deviceId !== void 0) where["deviceId"] = input.deviceId;
|
|
4598
|
+
if (input.className !== void 0) where["className"] = input.className;
|
|
4599
|
+
if (input.modelId !== void 0) where["modelId"] = input.modelId;
|
|
4600
|
+
const filter = { where };
|
|
4601
|
+
if (input.since !== void 0 || input.until !== void 0) filter["whereBetween"] = { timestamp: [input.since ?? 0, input.until ?? Date.now()] };
|
|
4602
|
+
if (input.limit !== void 0) filter["limit"] = input.limit;
|
|
4603
|
+
return (await this.store.query.query({
|
|
4604
|
+
collection: OBJECT_EMBEDDINGS_COLLECTION,
|
|
4605
|
+
filter
|
|
4606
|
+
})).map((r) => {
|
|
4607
|
+
const data = r.data;
|
|
4608
|
+
return {
|
|
4609
|
+
id: r.id,
|
|
4610
|
+
...data,
|
|
4611
|
+
mediaKey: data.mediaKey ?? void 0
|
|
4612
|
+
};
|
|
4613
|
+
});
|
|
4614
|
+
}
|
|
4615
|
+
/**
|
|
4616
|
+
* Retention sweep: delete all embeddings whose timestamp < cutoffMs across
|
|
4617
|
+
* every device. Drains fully: loops a page at a time until no stale rows
|
|
4618
|
+
* remain. Returns deleted row ids.
|
|
4619
|
+
*/
|
|
4620
|
+
async pruneAll(cutoffMs) {
|
|
4621
|
+
const ids = [];
|
|
4622
|
+
for (;;) {
|
|
4623
|
+
const rows = await this.store.query.query({
|
|
4624
|
+
collection: OBJECT_EMBEDDINGS_COLLECTION,
|
|
4625
|
+
filter: {
|
|
4626
|
+
whereBetween: { timestamp: [0, cutoffMs] },
|
|
4627
|
+
limit: 500
|
|
4628
|
+
}
|
|
4629
|
+
});
|
|
4630
|
+
if (rows.length === 0) break;
|
|
4631
|
+
let deletedInPage = 0;
|
|
4632
|
+
for (const row of rows) {
|
|
4633
|
+
const id = typeof row.id === "string" ? row.id : void 0;
|
|
4634
|
+
if (!id) continue;
|
|
4635
|
+
try {
|
|
4636
|
+
await this.store.delete.mutate({
|
|
4637
|
+
collection: OBJECT_EMBEDDINGS_COLLECTION,
|
|
4638
|
+
key: id
|
|
4639
|
+
});
|
|
4640
|
+
ids.push(id);
|
|
4641
|
+
deletedInPage++;
|
|
4642
|
+
} catch {}
|
|
4643
|
+
}
|
|
4644
|
+
if (deletedInPage === 0) break;
|
|
4645
|
+
}
|
|
4646
|
+
return ids;
|
|
4647
|
+
}
|
|
4648
|
+
};
|
|
4649
|
+
//#endregion
|
|
4478
4650
|
//#region src/pipeline-analytics/pipeline/face-matcher.ts
|
|
4479
4651
|
/**
|
|
4480
4652
|
* Assign at most one identity per track AND at most one track per identity for
|
|
@@ -5135,6 +5307,41 @@ function buildEventChildCrops(objectEvents, detections) {
|
|
|
5135
5307
|
return result;
|
|
5136
5308
|
}
|
|
5137
5309
|
//#endregion
|
|
5310
|
+
//#region src/pipeline-analytics/pipeline/embedding-search.ts
|
|
5311
|
+
/**
|
|
5312
|
+
* embedding-search — CLIP text→embedding cosine ranking helper.
|
|
5313
|
+
*
|
|
5314
|
+
* `rankByText` mirrors `face-matcher.matchEmbedding` with the identity-grouping
|
|
5315
|
+
* removed: it compares a single query vector against all rows, enforces the
|
|
5316
|
+
* same-modelId/dim gate, and returns the top-N rows above `minScore` sorted
|
|
5317
|
+
* descending by cosine similarity.
|
|
5318
|
+
*/
|
|
5319
|
+
/**
|
|
5320
|
+
* Brute-force cosine rank of `rows` against `queryVec`.
|
|
5321
|
+
*
|
|
5322
|
+
* Gate: same-modelId (when `opts.modelId` is set) AND same dimension as
|
|
5323
|
+
* `queryVec.length`. Rows that fail either gate are skipped.
|
|
5324
|
+
* Returns at most `opts.limit` rows with `score >= opts.minScore`,
|
|
5325
|
+
* ordered descending by score. Immutable — does not modify inputs.
|
|
5326
|
+
*/
|
|
5327
|
+
function rankByText(queryVec, rows, opts) {
|
|
5328
|
+
const queryDim = queryVec.length;
|
|
5329
|
+
const scored = [];
|
|
5330
|
+
for (const row of rows) {
|
|
5331
|
+
if (opts.modelId !== void 0 && row.modelId !== opts.modelId) continue;
|
|
5332
|
+
if (row.dim !== queryDim) continue;
|
|
5333
|
+
if (row.embedding.length !== queryDim) continue;
|
|
5334
|
+
const score = cosineSimilarity(queryVec, new Float32Array(row.embedding));
|
|
5335
|
+
if (score < opts.minScore) continue;
|
|
5336
|
+
scored.push({
|
|
5337
|
+
row,
|
|
5338
|
+
score
|
|
5339
|
+
});
|
|
5340
|
+
}
|
|
5341
|
+
scored.sort((a, b) => b.score - a.score);
|
|
5342
|
+
return scored.slice(0, opts.limit);
|
|
5343
|
+
}
|
|
5344
|
+
//#endregion
|
|
5138
5345
|
//#region src/pipeline-analytics/pipeline/bbox-pad.ts
|
|
5139
5346
|
/**
|
|
5140
5347
|
* Pad a normalized bounding box by a fractional amount relative to its own
|
|
@@ -5367,6 +5574,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
5367
5574
|
faceRecognizer = null;
|
|
5368
5575
|
plateStore = null;
|
|
5369
5576
|
plateRecognizer = null;
|
|
5577
|
+
objectEmbeddingStore = null;
|
|
5370
5578
|
/** Frame-based event/track media (crop + boxed full-frame) from the
|
|
5371
5579
|
* detection-pipeline DECODED frame — the ONLY image source (never the
|
|
5372
5580
|
* snapshot cap). Null when shm frame access is unavailable. */
|
|
@@ -5432,6 +5640,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
5432
5640
|
await IdentityStore.declare(api.settingsStore);
|
|
5433
5641
|
await FaceStore.declare(api.settingsStore);
|
|
5434
5642
|
await PlateStore.declare(api.settingsStore);
|
|
5643
|
+
await ObjectEmbeddingStore.declare(api.settingsStore);
|
|
5435
5644
|
const logger = this.ctx.logger;
|
|
5436
5645
|
const storage = this.ctx.kernel.storage;
|
|
5437
5646
|
if (!storage) throw new Error("pipeline-analytics requires ctx.kernel.storage");
|
|
@@ -5530,6 +5739,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
5530
5739
|
captureCrop,
|
|
5531
5740
|
logger: logger.child("PlateRecognizer")
|
|
5532
5741
|
});
|
|
5742
|
+
this.objectEmbeddingStore = new ObjectEmbeddingStore({
|
|
5743
|
+
store: api.settingsStore,
|
|
5744
|
+
logger: logger.child("ObjectEmbeddingStore")
|
|
5745
|
+
});
|
|
5533
5746
|
this.bindingCache = new BindingCache({
|
|
5534
5747
|
api,
|
|
5535
5748
|
logger: logger.child("BindingCache")
|
|
@@ -6092,6 +6305,17 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6092
6305
|
} });
|
|
6093
6306
|
}
|
|
6094
6307
|
await Promise.all(result.objectEvents.map((e) => this.eventStore.insertObject(e)));
|
|
6308
|
+
if (this.objectEmbeddingStore) {
|
|
6309
|
+
for (const t of result.tracked) if (t.embedding !== void 0 && t.embeddingModelId !== void 0 && t.embeddingModelId.startsWith("mobileclip-")) this.objectEmbeddingStore.upsertIfBetter({
|
|
6310
|
+
trackId: t.trackId,
|
|
6311
|
+
deviceId,
|
|
6312
|
+
timestamp: result.timestamp,
|
|
6313
|
+
className: t.className,
|
|
6314
|
+
embedding: t.embedding,
|
|
6315
|
+
modelId: t.embeddingModelId,
|
|
6316
|
+
confidence: t.confidence
|
|
6317
|
+
});
|
|
6318
|
+
}
|
|
6095
6319
|
const faceSettings = this.faceRecognizer ? await this.resolveDeviceFaceSettings(deviceId) : null;
|
|
6096
6320
|
const faceGloballyEnabled = this.faceRecognizer ? await this.resolveGlobalFaceEnabled() : false;
|
|
6097
6321
|
const mediaSettings = await this.resolveDeviceMediaSettings(deviceId);
|
|
@@ -6590,6 +6814,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6590
6814
|
} catch (err) {
|
|
6591
6815
|
this.ctx.logger.debug("plate buffer prune failed", { meta: { error: String(err) } });
|
|
6592
6816
|
}
|
|
6817
|
+
if (this.objectEmbeddingStore) try {
|
|
6818
|
+
const deletedEmbIds = await this.objectEmbeddingStore.pruneAll(objectCutoffMs);
|
|
6819
|
+
if (deletedEmbIds.length > 0) this.ctx.logger.info("object embedding retention prune", { meta: { deleted: deletedEmbIds.length } });
|
|
6820
|
+
} catch (err) {
|
|
6821
|
+
this.ctx.logger.debug("object embedding prune failed", { meta: { error: String(err) } });
|
|
6822
|
+
}
|
|
6593
6823
|
} catch (err) {
|
|
6594
6824
|
this.ctx.logger.debug("sweepRetention failed", { meta: { error: String(err) } });
|
|
6595
6825
|
}
|
|
@@ -6734,6 +6964,90 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
6734
6964
|
async getTrackMedia(input) {
|
|
6735
6965
|
return this.mediaStore?.listByOwner("track", input.trackId) ?? [];
|
|
6736
6966
|
}
|
|
6967
|
+
/**
|
|
6968
|
+
* Search object events by text using CLIP cosine similarity.
|
|
6969
|
+
*
|
|
6970
|
+
* Flow:
|
|
6971
|
+
* 1. Call `embedding-encoder.encodeText` to embed `text` → `queryVec`.
|
|
6972
|
+
* 2. Query `objectEmbeddingStore` with optional prefilters (deviceId,
|
|
6973
|
+
* since/until, classFilter, modelId from encoder).
|
|
6974
|
+
* 3. Rank candidates via `rankByText` (same-model/dim gate + cosine).
|
|
6975
|
+
* 4. Fetch the corresponding ObjectEvents by trackId (via getObjectEvents
|
|
6976
|
+
* with each winner's deviceId + trackId filter) and join score.
|
|
6977
|
+
* 5. Return at most `limit` ScoredObjectEvents sorted descending by score.
|
|
6978
|
+
*/
|
|
6979
|
+
async searchObjectEvents(input) {
|
|
6980
|
+
const store = this.objectEmbeddingStore;
|
|
6981
|
+
if (!store) return [];
|
|
6982
|
+
const api = this.ctx.api;
|
|
6983
|
+
if (!api) return [];
|
|
6984
|
+
const encodeResult = await api.embeddingEncoder.encodeText.query({ text: input.text }).catch((err) => {
|
|
6985
|
+
this.ctx.logger.warn("searchObjectEvents: encodeText failed", { meta: { error: String(err) } });
|
|
6986
|
+
return null;
|
|
6987
|
+
});
|
|
6988
|
+
if (!encodeResult) return [];
|
|
6989
|
+
const queryVec = new Float32Array(encodeResult.embedding);
|
|
6990
|
+
const embeddingRows = await store.query({
|
|
6991
|
+
deviceId: input.deviceId,
|
|
6992
|
+
since: input.since,
|
|
6993
|
+
until: input.until,
|
|
6994
|
+
className: input.classFilter
|
|
6995
|
+
});
|
|
6996
|
+
if (embeddingRows.length === 0) return [];
|
|
6997
|
+
const encoderInfo = await api.embeddingEncoder.getInfo.query().catch((err) => {
|
|
6998
|
+
this.ctx.logger.warn("searchObjectEvents: getInfo failed, proceeding without modelId gate", { meta: { error: String(err) } });
|
|
6999
|
+
return null;
|
|
7000
|
+
});
|
|
7001
|
+
const ranked = rankByText(queryVec, embeddingRows, {
|
|
7002
|
+
minScore: input.minScore,
|
|
7003
|
+
limit: input.limit,
|
|
7004
|
+
...encoderInfo !== null ? { modelId: encoderInfo.modelId } : {}
|
|
7005
|
+
});
|
|
7006
|
+
if (ranked.length === 0) return [];
|
|
7007
|
+
const byDevice = /* @__PURE__ */ new Map();
|
|
7008
|
+
for (const { row, score } of ranked) {
|
|
7009
|
+
const existing = byDevice.get(row.deviceId);
|
|
7010
|
+
if (existing !== void 0) existing.push({
|
|
7011
|
+
trackId: row.trackId,
|
|
7012
|
+
score
|
|
7013
|
+
});
|
|
7014
|
+
else byDevice.set(row.deviceId, [{
|
|
7015
|
+
trackId: row.trackId,
|
|
7016
|
+
score
|
|
7017
|
+
}]);
|
|
7018
|
+
}
|
|
7019
|
+
const scoreByTrackId = /* @__PURE__ */ new Map();
|
|
7020
|
+
for (const { row, score } of ranked) scoreByTrackId.set(row.trackId, score);
|
|
7021
|
+
const scored = [];
|
|
7022
|
+
for (const [deviceId, winners] of byDevice) {
|
|
7023
|
+
const trackIds = new Set(winners.map((w) => w.trackId));
|
|
7024
|
+
const events = await (this.eventStore?.queryObject({
|
|
7025
|
+
deviceId,
|
|
7026
|
+
since: input.since,
|
|
7027
|
+
until: input.until,
|
|
7028
|
+
classFilter: input.classFilter,
|
|
7029
|
+
limit: 5e3
|
|
7030
|
+
}) ?? Promise.resolve([]));
|
|
7031
|
+
const bestEventByTrackId = /* @__PURE__ */ new Map();
|
|
7032
|
+
for (const ev of events) {
|
|
7033
|
+
if (!ev.trackId || !trackIds.has(ev.trackId)) continue;
|
|
7034
|
+
const score = scoreByTrackId.get(ev.trackId) ?? 0;
|
|
7035
|
+
const existing = bestEventByTrackId.get(ev.trackId);
|
|
7036
|
+
const newConf = ev.confidence ?? 0;
|
|
7037
|
+
const prevConf = existing?.event.confidence ?? 0;
|
|
7038
|
+
if (existing === void 0 || newConf > prevConf) bestEventByTrackId.set(ev.trackId, {
|
|
7039
|
+
event: ev,
|
|
7040
|
+
score
|
|
7041
|
+
});
|
|
7042
|
+
}
|
|
7043
|
+
for (const { event, score } of bestEventByTrackId.values()) scored.push({
|
|
7044
|
+
...event,
|
|
7045
|
+
score
|
|
7046
|
+
});
|
|
7047
|
+
}
|
|
7048
|
+
scored.sort((a, b) => b.score - a.score);
|
|
7049
|
+
return scored.slice(0, input.limit);
|
|
7050
|
+
}
|
|
6737
7051
|
async pruneEventsBefore(input) {
|
|
6738
7052
|
if (!this.eventStore) return {
|
|
6739
7053
|
motion: 0,
|
|
@@ -30,7 +30,7 @@ async function d(e) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
async function f() {
|
|
33
|
-
return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-
|
|
33
|
+
return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DgAlWohT.mjs")).catch((e) => {
|
|
34
34
|
throw l = void 0, e;
|
|
35
35
|
}), l;
|
|
36
36
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-post-analysis",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.5",
|
|
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",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
36
|
"dist",
|
|
37
|
+
"python",
|
|
37
38
|
"assets"
|
|
38
39
|
],
|
|
39
40
|
"camstack": {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Raw-tensor ONNX inference subprocess for the embedding encoder.
|
|
3
|
+
|
|
4
|
+
Loads an ONNX model and runs raw, already-preprocessed tensors through
|
|
5
|
+
onnxruntime in the embedded portable Python — replacing the former Node
|
|
6
|
+
`onnxruntime-node` raw-tensor engine so the platform ships no Node ONNX
|
|
7
|
+
runtime. The caller (PythonRawTensorEngine) handles all preprocessing; this
|
|
8
|
+
script is a thin tensor in → tensor out bridge.
|
|
9
|
+
|
|
10
|
+
Wire protocol (both directions): [4B little-endian payload_len][payload]
|
|
11
|
+
ready (stdout, once): payload = b"\\x01"
|
|
12
|
+
request (stdin): payload = [1B ndims][ndims × 4B LE uint32 dims][float32 LE data]
|
|
13
|
+
response (stdout): payload = [1B ndims][ndims × 4B LE uint32 dims][float32 LE data]
|
|
14
|
+
|
|
15
|
+
The Node side always sends float32; this script casts to the model's declared
|
|
16
|
+
input dtype (e.g. int64 for CLIP text token ids) before running. Diagnostics go
|
|
17
|
+
to stderr (stdout is the binary framing channel only).
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
import onnxruntime as ort
|
|
26
|
+
|
|
27
|
+
from tensor_frames import (
|
|
28
|
+
READY_FRAME,
|
|
29
|
+
decode_tensor,
|
|
30
|
+
encode_tensor,
|
|
31
|
+
read_frame,
|
|
32
|
+
write_frame,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def numpy_dtype_for(onnx_type: str):
|
|
37
|
+
if "int64" in onnx_type:
|
|
38
|
+
return np.int64
|
|
39
|
+
if "int32" in onnx_type:
|
|
40
|
+
return np.int32
|
|
41
|
+
if "float16" in onnx_type:
|
|
42
|
+
return np.float16
|
|
43
|
+
return np.float32
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main() -> None:
|
|
47
|
+
ap = argparse.ArgumentParser()
|
|
48
|
+
ap.add_argument("model")
|
|
49
|
+
args = ap.parse_args()
|
|
50
|
+
|
|
51
|
+
print(f"raw_tensor_inference: loading model {args.model}", file=sys.stderr)
|
|
52
|
+
sess = ort.InferenceSession(args.model, providers=["CPUExecutionProvider"])
|
|
53
|
+
inp = sess.get_inputs()[0]
|
|
54
|
+
input_name = inp.name
|
|
55
|
+
input_dtype = numpy_dtype_for(inp.type)
|
|
56
|
+
output_name = sess.get_outputs()[0].name
|
|
57
|
+
|
|
58
|
+
write_frame(READY_FRAME) # ready
|
|
59
|
+
|
|
60
|
+
while True:
|
|
61
|
+
payload = read_frame()
|
|
62
|
+
if payload is None:
|
|
63
|
+
break
|
|
64
|
+
|
|
65
|
+
data = decode_tensor(payload)
|
|
66
|
+
feed = data.astype(input_dtype, copy=False)
|
|
67
|
+
|
|
68
|
+
out = sess.run([output_name], {input_name: feed})[0]
|
|
69
|
+
write_frame(encode_tensor(np.asarray(out)))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
main()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Raw-tensor ONNX inference for the embedding encoder (raw_tensor_inference.py),
|
|
2
|
+
# run in the embedded portable Python. Installed lazily via
|
|
3
|
+
# ctx.deps.installPythonRequirements the first time an embedding engine boots.
|
|
4
|
+
numpy>=1.26,<3
|
|
5
|
+
onnxruntime>=1.20,<2
|
|
6
|
+
# HF Rust tokenizers — exact CLIP BPE for text_encoder_inference.py (linux-x86_64
|
|
7
|
+
# + macOS-arm64 wheels). Replaces the former hand-rolled TS CLIP BPE.
|
|
8
|
+
tokenizers>=0.20,<1
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Length-prefixed binary tensor framing shared by the embedding-encoder
|
|
3
|
+
Python inference scripts (`raw_tensor_inference.py`, `text_encoder_inference.py`).
|
|
4
|
+
|
|
5
|
+
Wire protocol (both directions): [4B little-endian payload_len][payload]
|
|
6
|
+
ready (stdout, once): payload = b"\\x01"
|
|
7
|
+
tensor (either way): payload = [1B ndims][ndims × 4B LE uint32 dims][float32 LE data]
|
|
8
|
+
text (stdin): payload = UTF-8 encoded request text (text encoder only)
|
|
9
|
+
|
|
10
|
+
stdout is the binary framing channel ONLY — all diagnostics go to stderr.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import struct
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
READY_FRAME = b"\x01"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def write_frame(payload: bytes) -> None:
|
|
23
|
+
"""Write a single length-prefixed frame to stdout and flush."""
|
|
24
|
+
sys.stdout.buffer.write(struct.pack("<I", len(payload)))
|
|
25
|
+
sys.stdout.buffer.write(payload)
|
|
26
|
+
sys.stdout.buffer.flush()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def read_exact(n: int) -> bytes | None:
|
|
30
|
+
"""Read exactly n bytes from stdin, or None on EOF."""
|
|
31
|
+
buf = bytearray()
|
|
32
|
+
while len(buf) < n:
|
|
33
|
+
chunk = sys.stdin.buffer.read(n - len(buf))
|
|
34
|
+
if not chunk:
|
|
35
|
+
return None
|
|
36
|
+
buf.extend(chunk)
|
|
37
|
+
return bytes(buf)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def read_frame() -> bytes | None:
|
|
41
|
+
"""Read one length-prefixed frame payload from stdin, or None on EOF."""
|
|
42
|
+
header = read_exact(4)
|
|
43
|
+
if header is None:
|
|
44
|
+
return None
|
|
45
|
+
(plen,) = struct.unpack("<I", header)
|
|
46
|
+
payload = read_exact(plen)
|
|
47
|
+
return payload
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def encode_tensor(arr: np.ndarray) -> bytes:
|
|
51
|
+
"""Encode a numpy array as a float32 tensor frame payload."""
|
|
52
|
+
arr = np.ascontiguousarray(arr, dtype=np.float32)
|
|
53
|
+
out = bytearray()
|
|
54
|
+
out.append(arr.ndim)
|
|
55
|
+
for d in arr.shape:
|
|
56
|
+
out += struct.pack("<I", int(d))
|
|
57
|
+
out += arr.tobytes()
|
|
58
|
+
return bytes(out)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def decode_tensor(payload: bytes) -> np.ndarray:
|
|
62
|
+
"""Decode a float32 tensor frame payload into a numpy array."""
|
|
63
|
+
ndims = payload[0]
|
|
64
|
+
off = 1
|
|
65
|
+
dims = []
|
|
66
|
+
for _ in range(ndims):
|
|
67
|
+
(d,) = struct.unpack_from("<I", payload, off)
|
|
68
|
+
off += 4
|
|
69
|
+
dims.append(d)
|
|
70
|
+
return np.frombuffer(payload, dtype=np.float32, offset=off).reshape(dims)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Reference-parity test for the CLIP text tokenization done in Python.
|
|
3
|
+
|
|
4
|
+
Proves the HF `tokenizers` Rust BPE (loaded from the same hosted
|
|
5
|
+
`tokenizer.json` that ships beside every MobileCLIP text encoder) reproduces the
|
|
6
|
+
canonical CLIP reference ids and pads/truncates to the fixed 77-token context
|
|
7
|
+
window — i.e. tokenization is exact by construction, replacing the former
|
|
8
|
+
hand-rolled TypeScript BPE.
|
|
9
|
+
|
|
10
|
+
Run in a throwaway venv with the embedding requirements installed:
|
|
11
|
+
|
|
12
|
+
python3 -m venv /tmp/tokv
|
|
13
|
+
/tmp/tokv/bin/pip install tokenizers onnxruntime numpy
|
|
14
|
+
/tmp/tokv/bin/python -m pytest packages/addon-post-analysis/python/test_text_encoder.py
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
from text_encoder_inference import CONTEXT_LENGTH, build_tokenizer
|
|
23
|
+
|
|
24
|
+
# The locally-staged tokenizer.json (sibling of the MobileCLIP S1 text onnx).
|
|
25
|
+
TOKENIZER_PATH = os.environ.get(
|
|
26
|
+
"CLIP_TOKENIZER_JSON", "/tmp/mobileclip/s1/tokenizer.json"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Ground-truth ids from the HF `tokenizers` reference (include SOT 49406 + EOT
|
|
30
|
+
# 49407, exclude padding). The Python tokenization MUST reproduce these exactly.
|
|
31
|
+
REFERENCE_TEXT = "a photo of a cat"
|
|
32
|
+
REFERENCE_IDS = [49406, 320, 1125, 539, 320, 2368, 49407]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.mark.skipif(
|
|
36
|
+
not os.path.exists(TOKENIZER_PATH),
|
|
37
|
+
reason=f"tokenizer.json not staged at {TOKENIZER_PATH}",
|
|
38
|
+
)
|
|
39
|
+
def test_clip_reference_parity_and_padding() -> None:
|
|
40
|
+
tokenizer = build_tokenizer(TOKENIZER_PATH)
|
|
41
|
+
encoded = tokenizer.encode(REFERENCE_TEXT)
|
|
42
|
+
|
|
43
|
+
# The first 7 ids are SOT + 5 body tokens + EOT — the canonical CLIP ids.
|
|
44
|
+
assert encoded.ids[:7] == REFERENCE_IDS
|
|
45
|
+
# truncation + padding fixed the sequence to exactly the CLIP context window.
|
|
46
|
+
assert len(encoded.ids) == CONTEXT_LENGTH
|
|
47
|
+
# Everything after the reference ids is pad (id 0).
|
|
48
|
+
assert all(i == 0 for i in encoded.ids[7:])
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CLIP text-encoder inference subprocess for the embedding encoder.
|
|
3
|
+
|
|
4
|
+
Loads a CLIP text-encoder ONNX model plus its HuggingFace `tokenizers`-format
|
|
5
|
+
`tokenizer.json`, then for each request: tokenizes UTF-8 text into the fixed
|
|
6
|
+
77-token CLIP context window (exact-by-construction via the HF Rust tokenizer),
|
|
7
|
+
feeds the int64 token-id tensor through onnxruntime, and returns the output
|
|
8
|
+
embedding as a float32 tensor frame.
|
|
9
|
+
|
|
10
|
+
This replaces the hand-rolled TypeScript CLIP BPE — tokenization here is exact
|
|
11
|
+
by construction (same `tokenizers` library that produced the reference ids).
|
|
12
|
+
|
|
13
|
+
Wire protocol (both directions): [4B little-endian payload_len][payload]
|
|
14
|
+
ready (stdout, once): payload = b"\\x01"
|
|
15
|
+
request (stdin): payload = UTF-8 encoded text
|
|
16
|
+
response (stdout): payload = [1B ndims][ndims × 4B LE uint32 dims][float32 LE data]
|
|
17
|
+
|
|
18
|
+
Diagnostics go to stderr (stdout is the binary framing channel only).
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
import onnxruntime as ort
|
|
27
|
+
from tokenizers import Tokenizer
|
|
28
|
+
|
|
29
|
+
from tensor_frames import READY_FRAME, encode_tensor, read_frame, write_frame
|
|
30
|
+
|
|
31
|
+
# CLIP fixed context length — token ids are truncated/padded to exactly this.
|
|
32
|
+
CONTEXT_LENGTH = 77
|
|
33
|
+
PAD_ID = 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_tokenizer(tokenizer_path: str) -> Tokenizer:
|
|
37
|
+
tok = Tokenizer.from_file(tokenizer_path)
|
|
38
|
+
tok.enable_truncation(max_length=CONTEXT_LENGTH)
|
|
39
|
+
tok.enable_padding(length=CONTEXT_LENGTH, pad_id=PAD_ID)
|
|
40
|
+
return tok
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main() -> None:
|
|
44
|
+
ap = argparse.ArgumentParser()
|
|
45
|
+
ap.add_argument("model")
|
|
46
|
+
ap.add_argument("tokenizer")
|
|
47
|
+
args = ap.parse_args()
|
|
48
|
+
|
|
49
|
+
print(
|
|
50
|
+
f"text_encoder_inference: loading model {args.model} "
|
|
51
|
+
f"tokenizer {args.tokenizer}",
|
|
52
|
+
file=sys.stderr,
|
|
53
|
+
)
|
|
54
|
+
sess = ort.InferenceSession(args.model, providers=["CPUExecutionProvider"])
|
|
55
|
+
input_name = sess.get_inputs()[0].name
|
|
56
|
+
output_name = sess.get_outputs()[0].name
|
|
57
|
+
tokenizer = build_tokenizer(args.tokenizer)
|
|
58
|
+
|
|
59
|
+
write_frame(READY_FRAME) # ready
|
|
60
|
+
|
|
61
|
+
while True:
|
|
62
|
+
payload = read_frame()
|
|
63
|
+
if payload is None:
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
text = payload.decode("utf-8")
|
|
67
|
+
encoded = tokenizer.encode(text)
|
|
68
|
+
# `enable_truncation`/`enable_padding` already fixed this to length 77.
|
|
69
|
+
ids = np.asarray(encoded.ids, dtype=np.int64).reshape(1, CONTEXT_LENGTH)
|
|
70
|
+
|
|
71
|
+
out = sess.run([output_name], {input_name: ids})[0]
|
|
72
|
+
write_frame(encode_tensor(np.asarray(out)))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
main()
|