@camstack/addon-post-analysis 1.1.4 → 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.
Files changed (20) hide show
  1. package/dist/{dist-B2FbCLNx.mjs → dist-BpGP9ago.mjs} +41 -5
  2. package/dist/{dist-B6aOcq5T.js → dist-n-zJ0Uox.js} +46 -4
  3. package/dist/embedding-encoder/index.js +272 -93
  4. package/dist/embedding-encoder/index.mjs +270 -91
  5. package/dist/enrichment-engine/index.js +2 -2
  6. package/dist/enrichment-engine/index.mjs +1 -1
  7. package/dist/pipeline-analytics/_stub.js +1 -1
  8. package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Bcn1PmOA.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DgAlWohT.mjs} +3 -3
  9. 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
  10. package/dist/pipeline-analytics/{hostInit-gu9R5ABc.mjs → hostInit-D51b7QGW.mjs} +3 -3
  11. package/dist/pipeline-analytics/index.js +316 -2
  12. package/dist/pipeline-analytics/index.mjs +315 -1
  13. package/dist/pipeline-analytics/remoteEntry.js +1 -1
  14. package/dist/{resolve-frame-B6a1wzQb.js → resolve-frame-DB2NdMu2.js} +1 -1
  15. package/package.json +2 -1
  16. package/python/raw_tensor_inference.py +73 -0
  17. package/python/requirements-embedding.txt +8 -0
  18. package/python/tensor_frames.py +70 -0
  19. package/python/test_text_encoder.py +48 -0
  20. package/python/text_encoder_inference.py +76 -0
@@ -1,4 +1,4 @@
1
- import { a as faceGalleryCapability, b as number, c as videoclipsCapability, d as BaseAddon, f as DeviceType, g as hydrateSchema, l as zoneAnalyticsCapability, n as audioMetricsCapability, o as pipelineAnalyticsCapability, p as EventCategory, r as cosineSimilarity, s as plateGalleryCapability, t as addonWidgetsSourceCapability, u as errMsg, x as object, y as boolean } from "../dist-B2FbCLNx.mjs";
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-Bcn1PmOA.mjs")).catch((e) => {
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
  }
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-B6aOcq5T.js");
1
+ const require_dist = require("./dist-n-zJ0Uox.js");
2
2
  let sharp = require("sharp");
3
3
  sharp = require_dist.__toESM(sharp);
4
4
  //#region src/shared/frame/crop-extractor.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.1.4",
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()