@camstack/addon-pipeline 1.0.8 → 1.1.0

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 (35) hide show
  1. package/dist/audio-analyzer/index.js +5 -5
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +636 -659
  4. package/dist/detection-pipeline/index.mjs +624 -647
  5. package/dist/{model-download-service-C7AjBsX9-rXY-VFDk.js → model-download-service-RxAOiYvX-C8rTRJy_.js} +36 -6
  6. package/dist/{model-download-service-C7AjBsX9-B0ekM6dF.mjs → model-download-service-RxAOiYvX-CMAvhgO7.mjs} +36 -6
  7. package/dist/recorder/index.js +3 -3
  8. package/dist/recorder/index.mjs +1 -1
  9. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BFy9iszl.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DrohyZ5L.mjs} +3 -3
  10. package/dist/stream-broker/{hostInit-zRy9SzlX.mjs → hostInit-zLZbYJcg.mjs} +3 -3
  11. package/dist/stream-broker/index.js +7 -7
  12. package/dist/stream-broker/index.mjs +1 -1
  13. package/dist/stream-broker/remoteEntry.js +1 -1
  14. package/package.json +1 -1
  15. package/python/inference_pool.py +65 -6
  16. package/python/__pycache__/inference_pool.cpython-313.pyc +0 -0
  17. package/python/postprocessors/__pycache__/__init__.cpython-312.pyc +0 -0
  18. package/python/postprocessors/__pycache__/__init__.cpython-313.pyc +0 -0
  19. package/python/postprocessors/__pycache__/_safety.cpython-313.pyc +0 -0
  20. package/python/postprocessors/__pycache__/arcface.cpython-312.pyc +0 -0
  21. package/python/postprocessors/__pycache__/arcface.cpython-313.pyc +0 -0
  22. package/python/postprocessors/__pycache__/ctc.cpython-312.pyc +0 -0
  23. package/python/postprocessors/__pycache__/ctc.cpython-313.pyc +0 -0
  24. package/python/postprocessors/__pycache__/saliency.cpython-312.pyc +0 -0
  25. package/python/postprocessors/__pycache__/saliency.cpython-313.pyc +0 -0
  26. package/python/postprocessors/__pycache__/scrfd.cpython-312.pyc +0 -0
  27. package/python/postprocessors/__pycache__/scrfd.cpython-313.pyc +0 -0
  28. package/python/postprocessors/__pycache__/softmax.cpython-312.pyc +0 -0
  29. package/python/postprocessors/__pycache__/softmax.cpython-313.pyc +0 -0
  30. package/python/postprocessors/__pycache__/yamnet.cpython-312.pyc +0 -0
  31. package/python/postprocessors/__pycache__/yamnet.cpython-313.pyc +0 -0
  32. package/python/postprocessors/__pycache__/yolo.cpython-312.pyc +0 -0
  33. package/python/postprocessors/__pycache__/yolo.cpython-313.pyc +0 -0
  34. package/python/postprocessors/__pycache__/yolo_seg.cpython-312.pyc +0 -0
  35. package/python/postprocessors/__pycache__/yolo_seg.cpython-313.pyc +0 -0
@@ -25,7 +25,7 @@ node_fs = __toESM(node_fs, 1);
25
25
  let node_path = require("node:path");
26
26
  node_path = __toESM(node_path, 1);
27
27
  require("node:crypto");
28
- //#region ../system/dist/model-download-service-C7AjBsX9.mjs
28
+ //#region ../system/dist/model-download-service-RxAOiYvX.mjs
29
29
  /**
30
30
  * Map a rel path to one candidate absolute path PER root, keeping only roots the
31
31
  * path stays within (traversal guard). The handler serves the first candidate
@@ -173,6 +173,24 @@ function createFileDataPlaneHandler(opts) {
173
173
  (0, node_fs.createReadStream)(absPath).pipe(res);
174
174
  };
175
175
  }
176
+ function isNonEmptyFile(filePath) {
177
+ return node_fs.existsSync(filePath) && node_fs.statSync(filePath).size > 0;
178
+ }
179
+ /**
180
+ * Sibling files of a single-file (non-directory) format — extra files the
181
+ * format needs, fetched from the same remote directory as `url` and stored
182
+ * flat alongside the main file in `modelsDir`. Catalog-declared via
183
+ * `formatEntry.files`, e.g. OpenVINO IR lists its `.bin` weights next to the
184
+ * `.xml`. The downloader stays format-agnostic; the convention lives in the
185
+ * catalog data (like `MLPACKAGE_FILES` for the directory case).
186
+ */
187
+ function siblingFilesFor(formatEntry) {
188
+ return formatEntry.isDirectory ? [] : formatEntry.files ?? [];
189
+ }
190
+ /** Resolve a sibling's remote URL relative to the main file's directory. */
191
+ function siblingUrl(mainUrl, sibling) {
192
+ return mainUrl.replace(/[^/]+$/, sibling);
193
+ }
176
194
  /** Build fetch headers, including HF auth token for huggingface.co URLs */
177
195
  function buildHeaders(url) {
178
196
  const headers = { "User-Agent": "CamStack/1.0" };
@@ -282,14 +300,18 @@ async function ensureModel(modelsDir, entry, format, onProgress) {
282
300
  if (entry.extraFiles) for (const extra of entry.extraFiles) await downloadFile(extra.url, node_path.join(modelsDir, extra.filename));
283
301
  const filename = formatEntry.url.split("/").pop() ?? `${entry.id}.${format}`;
284
302
  const modelPath = node_path.join(modelsDir, filename);
303
+ const siblings = siblingFilesFor(formatEntry);
285
304
  if (node_fs.existsSync(modelPath)) if (formatEntry.isDirectory && !node_fs.existsSync(node_path.join(modelPath, "Manifest.json"))) node_fs.rmSync(modelPath, {
286
305
  recursive: true,
287
306
  force: true
288
307
  });
289
- else return modelPath;
308
+ else if (siblings.some((f) => !isNonEmptyFile(node_path.join(modelsDir, f)))) {} else return modelPath;
290
309
  node_fs.mkdirSync(modelsDir, { recursive: true });
291
310
  if (formatEntry.isDirectory) await downloadDirectory(formatEntry.url, modelPath, formatEntry.files, onProgress);
292
- else await downloadFile(formatEntry.url, modelPath, (downloaded, total) => onProgress?.(downloaded, total === 0 ? void 0 : total));
311
+ else {
312
+ await downloadFile(formatEntry.url, modelPath, (downloaded, total) => onProgress?.(downloaded, total === 0 ? void 0 : total));
313
+ for (const sibling of siblings) await downloadFile(siblingUrl(formatEntry.url, sibling), node_path.join(modelsDir, sibling));
314
+ }
293
315
  return modelPath;
294
316
  }
295
317
  /** Compute the on-disk path for a given model + format, even when not yet downloaded. */
@@ -306,17 +328,25 @@ function isModelDownloaded(modelsDir, entry, format) {
306
328
  const modelPath = getModelFilePath(modelsDir, entry, format);
307
329
  if (!modelPath || !node_fs.existsSync(modelPath)) return false;
308
330
  if (formatEntry.isDirectory) return node_fs.existsSync(node_path.join(modelPath, "Manifest.json"));
309
- return node_fs.statSync(modelPath).size > 0;
331
+ if (node_fs.statSync(modelPath).size <= 0) return false;
332
+ return siblingFilesFor(formatEntry).every((f) => isNonEmptyFile(node_path.join(modelsDir, f)));
310
333
  }
311
334
  /** Remove the on-disk model file/directory. Returns true if something was deleted. */
312
335
  function deleteModelFromDisk(modelsDir, entry, format) {
313
336
  const modelPath = getModelFilePath(modelsDir, entry, format);
314
337
  if (!modelPath || !node_fs.existsSync(modelPath)) return false;
315
- if (entry.formats[format]?.isDirectory) node_fs.rmSync(modelPath, {
338
+ const formatEntry = entry.formats[format];
339
+ if (formatEntry?.isDirectory) node_fs.rmSync(modelPath, {
316
340
  recursive: true,
317
341
  force: true
318
342
  });
319
- else node_fs.unlinkSync(modelPath);
343
+ else {
344
+ node_fs.unlinkSync(modelPath);
345
+ if (formatEntry) for (const sibling of siblingFilesFor(formatEntry)) {
346
+ const sibPath = node_path.join(modelsDir, sibling);
347
+ if (node_fs.existsSync(sibPath)) node_fs.unlinkSync(sibPath);
348
+ }
349
+ }
320
350
  return true;
321
351
  }
322
352
  //#endregion
@@ -3,7 +3,7 @@ import { createReadStream, promises } from "node:fs";
3
3
  import * as path$1 from "node:path";
4
4
  import path from "node:path";
5
5
  import "node:crypto";
6
- //#region ../system/dist/model-download-service-C7AjBsX9.mjs
6
+ //#region ../system/dist/model-download-service-RxAOiYvX.mjs
7
7
  /**
8
8
  * Map a rel path to one candidate absolute path PER root, keeping only roots the
9
9
  * path stays within (traversal guard). The handler serves the first candidate
@@ -151,6 +151,24 @@ function createFileDataPlaneHandler(opts) {
151
151
  createReadStream(absPath).pipe(res);
152
152
  };
153
153
  }
154
+ function isNonEmptyFile(filePath) {
155
+ return fs.existsSync(filePath) && fs.statSync(filePath).size > 0;
156
+ }
157
+ /**
158
+ * Sibling files of a single-file (non-directory) format — extra files the
159
+ * format needs, fetched from the same remote directory as `url` and stored
160
+ * flat alongside the main file in `modelsDir`. Catalog-declared via
161
+ * `formatEntry.files`, e.g. OpenVINO IR lists its `.bin` weights next to the
162
+ * `.xml`. The downloader stays format-agnostic; the convention lives in the
163
+ * catalog data (like `MLPACKAGE_FILES` for the directory case).
164
+ */
165
+ function siblingFilesFor(formatEntry) {
166
+ return formatEntry.isDirectory ? [] : formatEntry.files ?? [];
167
+ }
168
+ /** Resolve a sibling's remote URL relative to the main file's directory. */
169
+ function siblingUrl(mainUrl, sibling) {
170
+ return mainUrl.replace(/[^/]+$/, sibling);
171
+ }
154
172
  /** Build fetch headers, including HF auth token for huggingface.co URLs */
155
173
  function buildHeaders(url) {
156
174
  const headers = { "User-Agent": "CamStack/1.0" };
@@ -260,14 +278,18 @@ async function ensureModel(modelsDir, entry, format, onProgress) {
260
278
  if (entry.extraFiles) for (const extra of entry.extraFiles) await downloadFile(extra.url, path$1.join(modelsDir, extra.filename));
261
279
  const filename = formatEntry.url.split("/").pop() ?? `${entry.id}.${format}`;
262
280
  const modelPath = path$1.join(modelsDir, filename);
281
+ const siblings = siblingFilesFor(formatEntry);
263
282
  if (fs.existsSync(modelPath)) if (formatEntry.isDirectory && !fs.existsSync(path$1.join(modelPath, "Manifest.json"))) fs.rmSync(modelPath, {
264
283
  recursive: true,
265
284
  force: true
266
285
  });
267
- else return modelPath;
286
+ else if (siblings.some((f) => !isNonEmptyFile(path$1.join(modelsDir, f)))) {} else return modelPath;
268
287
  fs.mkdirSync(modelsDir, { recursive: true });
269
288
  if (formatEntry.isDirectory) await downloadDirectory(formatEntry.url, modelPath, formatEntry.files, onProgress);
270
- else await downloadFile(formatEntry.url, modelPath, (downloaded, total) => onProgress?.(downloaded, total === 0 ? void 0 : total));
289
+ else {
290
+ await downloadFile(formatEntry.url, modelPath, (downloaded, total) => onProgress?.(downloaded, total === 0 ? void 0 : total));
291
+ for (const sibling of siblings) await downloadFile(siblingUrl(formatEntry.url, sibling), path$1.join(modelsDir, sibling));
292
+ }
271
293
  return modelPath;
272
294
  }
273
295
  /** Compute the on-disk path for a given model + format, even when not yet downloaded. */
@@ -284,17 +306,25 @@ function isModelDownloaded(modelsDir, entry, format) {
284
306
  const modelPath = getModelFilePath(modelsDir, entry, format);
285
307
  if (!modelPath || !fs.existsSync(modelPath)) return false;
286
308
  if (formatEntry.isDirectory) return fs.existsSync(path$1.join(modelPath, "Manifest.json"));
287
- return fs.statSync(modelPath).size > 0;
309
+ if (fs.statSync(modelPath).size <= 0) return false;
310
+ return siblingFilesFor(formatEntry).every((f) => isNonEmptyFile(path$1.join(modelsDir, f)));
288
311
  }
289
312
  /** Remove the on-disk model file/directory. Returns true if something was deleted. */
290
313
  function deleteModelFromDisk(modelsDir, entry, format) {
291
314
  const modelPath = getModelFilePath(modelsDir, entry, format);
292
315
  if (!modelPath || !fs.existsSync(modelPath)) return false;
293
- if (entry.formats[format]?.isDirectory) fs.rmSync(modelPath, {
316
+ const formatEntry = entry.formats[format];
317
+ if (formatEntry?.isDirectory) fs.rmSync(modelPath, {
294
318
  recursive: true,
295
319
  force: true
296
320
  });
297
- else fs.unlinkSync(modelPath);
321
+ else {
322
+ fs.unlinkSync(modelPath);
323
+ if (formatEntry) for (const sibling of siblingFilesFor(formatEntry)) {
324
+ const sibPath = path$1.join(modelsDir, sibling);
325
+ if (fs.existsSync(sibPath)) fs.unlinkSync(sibPath);
326
+ }
327
+ }
298
328
  return true;
299
329
  }
300
330
  //#endregion
@@ -1,8 +1,8 @@
1
- const require_model_download_service_C7AjBsX9 = require("../model-download-service-C7AjBsX9-rXY-VFDk.js");
1
+ const require_model_download_service_RxAOiYvX = require("../model-download-service-RxAOiYvX-C8rTRJy_.js");
2
2
  const require_dist = require("../dist-BLcTVvol.js");
3
3
  let node_fs = require("node:fs");
4
4
  let node_path = require("node:path");
5
- node_path = require_model_download_service_C7AjBsX9.__toESM(node_path);
5
+ node_path = require_model_download_service_RxAOiYvX.__toESM(node_path);
6
6
  let node_child_process = require("node:child_process");
7
7
  //#region src/recorder/segment-path.ts
8
8
  function segmentRelPath(deviceId, profile, startMs, durMs, bytes) {
@@ -1934,7 +1934,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
1934
1934
  store: this.segmentStore
1935
1935
  });
1936
1936
  try {
1937
- const handler = require_model_download_service_C7AjBsX9.createFileDataPlaneHandler({ getRoots: () => this.playbackRoots() });
1937
+ const handler = require_model_download_service_RxAOiYvX.createFileDataPlaneHandler({ getRoots: () => this.playbackRoots() });
1938
1938
  const served = await this.ctx.dataPlane?.serve({
1939
1939
  prefix: PLAYBACK_PREFIX,
1940
1940
  access: "authenticated",
@@ -1,5 +1,5 @@
1
1
  import { $ as selectAssignedProfileSlots, B as BaseAddon, F as storageEvictableCapability, J as hydrateSchema, K as createDurableState, N as recordingCapability, O as migrateConfigToBands, W as EventCategory, d as RecordingConfigSchema, lt as record, ut as string, z as errMsg } from "../dist-BA6DR_jV.mjs";
2
- import { t as createFileDataPlaneHandler } from "../model-download-service-C7AjBsX9-B0ekM6dF.mjs";
2
+ import { t as createFileDataPlaneHandler } from "../model-download-service-RxAOiYvX-CMAvhgO7.mjs";
3
3
  import { promises } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { spawn } from "node:child_process";
@@ -3,7 +3,7 @@ import "./dist-CYZr2fwk.mjs";
3
3
  var e = {
4
4
  "@camstack/sdk": {
5
5
  name: "@camstack/sdk",
6
- version: "1.0.7",
6
+ version: "1.1.0",
7
7
  scope: ["default"],
8
8
  loaded: !1,
9
9
  from: "addon_stream_broker_widgets",
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.0.7",
21
+ version: "1.1.0",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_stream_broker_widgets",
@@ -33,7 +33,7 @@ var e = {
33
33
  },
34
34
  "@camstack/ui-library": {
35
35
  name: "@camstack/ui-library",
36
- version: "1.0.7",
36
+ version: "1.1.0",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_stream_broker_widgets",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.0.7",
39
+ version: "1.1.0",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -45,7 +45,7 @@ async function r() {
45
45
  }
46
46
  },
47
47
  "@camstack/sdk": {
48
- version: "1.0.7",
48
+ version: "1.1.0",
49
49
  scope: "default",
50
50
  shareConfig: {
51
51
  singleton: !0,
@@ -81,7 +81,7 @@ async function r() {
81
81
  }
82
82
  },
83
83
  "@camstack/ui-library": {
84
- version: "1.0.7",
84
+ version: "1.1.0",
85
85
  scope: "default",
86
86
  shareConfig: {
87
87
  singleton: !0,
@@ -2,20 +2,20 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_model_download_service_C7AjBsX9 = require("../model-download-service-C7AjBsX9-rXY-VFDk.js");
5
+ const require_model_download_service_RxAOiYvX = require("../model-download-service-RxAOiYvX-C8rTRJy_.js");
6
6
  const require_dist = require("../dist-BLcTVvol.js");
7
7
  const require_codec_runtime = require("../codec-runtime-BOk-13PN.js");
8
8
  let node_fs = require("node:fs");
9
- node_fs = require_model_download_service_C7AjBsX9.__toESM(node_fs);
9
+ node_fs = require_model_download_service_RxAOiYvX.__toESM(node_fs);
10
10
  let node_path = require("node:path");
11
- node_path = require_model_download_service_C7AjBsX9.__toESM(node_path);
11
+ node_path = require_model_download_service_RxAOiYvX.__toESM(node_path);
12
12
  let node_os = require("node:os");
13
- node_os = require_model_download_service_C7AjBsX9.__toESM(node_os);
13
+ node_os = require_model_download_service_RxAOiYvX.__toESM(node_os);
14
14
  let node_crypto = require("node:crypto");
15
- node_crypto = require_model_download_service_C7AjBsX9.__toESM(node_crypto);
15
+ node_crypto = require_model_download_service_RxAOiYvX.__toESM(node_crypto);
16
16
  let node_child_process = require("node:child_process");
17
17
  let node_net = require("node:net");
18
- node_net = require_model_download_service_C7AjBsX9.__toESM(node_net);
18
+ node_net = require_model_download_service_RxAOiYvX.__toESM(node_net);
19
19
  let net = require("net");
20
20
  let events = require("events");
21
21
  let node_fs_promises = require("node:fs/promises");
@@ -26,7 +26,7 @@ let node_events = require("node:events");
26
26
  /** Build a `(req, res)` handler serving the embed bundle at `root` with SPA fallback.
27
27
  * Web MIME types (`.html`/`.js`/`.css`/…) come from the core handler's defaults. */
28
28
  function createEmbedSpaHandler(root) {
29
- const fileHandler = require_model_download_service_C7AjBsX9.createFileDataPlaneHandler({ getRoots: () => [root] });
29
+ const fileHandler = require_model_download_service_RxAOiYvX.createFileDataPlaneHandler({ getRoots: () => [root] });
30
30
  return (req, res) => {
31
31
  const [pathPart = "/", query] = (req.url ?? "/").split("?");
32
32
  const lastSegment = pathPart.split("/").pop() ?? "";
@@ -1,6 +1,6 @@
1
1
  import { t as __require } from "../chunk-BdkLduGY.mjs";
2
2
  import { B as BaseAddon, D as maskUrlCredentials, G as asJsonObject, H as DeviceFeature, I as streamBrokerCapability, Q as parseProfileBrokerId, R as webrtcSessionCapability, U as DeviceType, V as CAM_PROFILE_ORDER, W as EventCategory, X as makeSourceBrokerId, Y as makeProfileBrokerId, c as EncodeProfileSchema, ct as object, dt as union, f as RingBuffer, it as discriminatedUnion, lt as record, m as addonWidgetsSourceCapability, ot as literal, q as createEvent, rt as boolean, st as number, tt as _enum, ut as string, v as cameraStreamsCapability, z as errMsg } from "../dist-BA6DR_jV.mjs";
3
- import { t as createFileDataPlaneHandler } from "../model-download-service-C7AjBsX9-B0ekM6dF.mjs";
3
+ import { t as createFileDataPlaneHandler } from "../model-download-service-RxAOiYvX-CMAvhgO7.mjs";
4
4
  import { t as DecodeRuntime } from "../codec-runtime-BsqlEjPi.mjs";
5
5
  import * as fs from "node:fs";
6
6
  import * as path$1 from "node:path";
@@ -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_stream_broker_widgets-BFy9iszl.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DrohyZ5L.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-pipeline",
3
- "version": "1.0.8",
3
+ "version": "1.1.0",
4
4
  "description": "CamStack Pipeline bundle — runner, detection, motion, decoders, audio + stream broker. Multi-entry npm package shipping 7 addons under a single bundle.",
5
5
  "keywords": [
6
6
  "camstack",
@@ -9,8 +9,10 @@ Architecture mirrors Scrypted's ML plugins (coreml / openvino / onnx):
9
9
  - Runtime executors:
10
10
  CoreML → ThreadPoolExecutor(1) — ANE is single-context; one
11
11
  Python thread is enough, and avoids GIL thrashing.
12
- OpenVINO → ThreadPoolExecutor(1) driving the compiled model (the
13
- OV runtime manages internal infer-request parallelism).
12
+ OpenVINO → ThreadPoolExecutor(OPTIMAL_NUMBER_OF_INFER_REQUESTS),
13
+ each thread driving its own InferRequest, with the model
14
+ compiled under the THROUGHPUT hint so the streams run
15
+ concurrently (a single shared request serialises cameras).
14
16
  ONNX → ThreadPoolExecutor(N) — N independent InferenceSessions
15
17
  where N = concurrency setting; each session pinned to
16
18
  its own worker thread.
@@ -160,6 +162,13 @@ class ModelSlot:
160
162
 
161
163
  _runtime: str = ""
162
164
  _runtime_lib: Any = None
165
+ # Max OPTIMAL_NUMBER_OF_INFER_REQUESTS across loaded OpenVINO models — used
166
+ # to size the predict pool so the THROUGHPUT streams are actually fed.
167
+ _ov_optimal_reqs: int = 0
168
+ # Default OpenVINO predict-pool size when models load lazily (so we can't yet
169
+ # query the device's optimal request count). ≈ measured optimal on Intel
170
+ # CPU/iGPU/NPU (4-5). Threads idle-block on infer, so over-provisioning is cheap.
171
+ OV_DEFAULT_CONCURRENCY: int = 4
163
172
 
164
173
 
165
174
  def _init_runtime(runtime: str) -> None:
@@ -226,14 +235,48 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
226
235
  elif _runtime == "openvino":
227
236
  core = _runtime_lib
228
237
  ov_device = config.get("device", "AUTO").upper()
229
- compiled = core.compile_model(path, device_name=ov_device)
238
+ if ov_device == "AUTO":
239
+ # Scrypted-style device priority, built from the devices OpenVINO
240
+ # actually enumerates (more accurate than a hardware probe): NPU >
241
+ # GPU > CPU. The AUTO plugin handles runtime selection + failover.
242
+ # On a CPU-only image this is just AUTO:CPU; once the Intel GPU/NPU
243
+ # runtime is present it becomes AUTO:NPU,GPU,CPU automatically.
244
+ order = [d for d in ("NPU", "GPU", "CPU") if d in core.available_devices]
245
+ if order:
246
+ ov_device = "AUTO:" + ",".join(order)
247
+ # THROUGHPUT hint lets OpenVINO spin up multiple internal execution
248
+ # streams. Combined with one InferRequest per predict-pool thread
249
+ # (below), concurrent frames from N cameras run in parallel — the
250
+ # old `compiled(inp)` path drove a single shared default request, so
251
+ # every camera serialised through one stream regardless of how many
252
+ # predict workers existed. Measured ~1.5–2.4x throughput on CPU/GPU/NPU.
253
+ ov_config = {"PERFORMANCE_HINT": "THROUGHPUT"}
254
+ compiled = core.compile_model(path, device_name=ov_device, config=ov_config)
230
255
  output_layers = [compiled.output(i) for i in range(len(compiled.outputs))]
231
256
  output_names = [o.get_any_name() for o in compiled.outputs]
232
257
 
233
- def predict(inp_dict: dict) -> dict:
258
+ # Record the device's optimal infer-request count so the dispatcher
259
+ # can size the predict pool to actually feed the streams.
260
+ global _ov_optimal_reqs
261
+ try:
262
+ opt = int(compiled.get_property("OPTIMAL_NUMBER_OF_INFER_REQUESTS"))
263
+ if opt > _ov_optimal_reqs:
264
+ _ov_optimal_reqs = opt
265
+ except Exception:
266
+ pass
267
+
268
+ # One InferRequest per predict thread (thread-local) — InferRequests
269
+ # are NOT safe to share across threads, and a per-thread request is
270
+ # what lets the THROUGHPUT streams run concurrently.
271
+ _ov_tls = threading.local()
272
+
273
+ def predict(inp_dict: dict, _c=compiled, _layers=output_layers, _names=output_names, _tls=_ov_tls) -> dict:
274
+ req = getattr(_tls, "req", None)
275
+ if req is None:
276
+ req = _tls.req = _c.create_infer_request()
234
277
  inp = list(inp_dict.values())[0]
235
- result = compiled(inp)
236
- return {name: result[layer] for name, layer in zip(output_names, output_layers)}
278
+ result = req.infer(inp)
279
+ return {name: result[layer] for name, layer in zip(_names, _layers)}
237
280
 
238
281
  slot.model = compiled
239
282
  slot.predict_fn = predict
@@ -772,6 +815,22 @@ async def _run() -> None:
772
815
  sys.stderr.flush()
773
816
  models.append(slot)
774
817
 
818
+ # OpenVINO: size the predict pool so the THROUGHPUT execution streams are
819
+ # actually fed by concurrent infer-requests. The addon passes
820
+ # concurrency=1 (it predates per-thread infer requests) and models load
821
+ # lazily AFTER this point, so we can't read OPTIMAL_NUMBER_OF_INFER_REQUESTS
822
+ # here — default to OV_DEFAULT_CONCURRENCY (≈ the measured optimal of 4-5
823
+ # on Intel CPU/iGPU/NPU). If startup-loaded models report a higher optimal,
824
+ # use that. One InferRequest is created per predict thread on first use.
825
+ if runtime == "openvino":
826
+ target = max(_ov_optimal_reqs, OV_DEFAULT_CONCURRENCY)
827
+ if target > concurrency:
828
+ sys.stderr.write(
829
+ f"OpenVINO THROUGHPUT: predict concurrency {concurrency} -> {target}\n"
830
+ )
831
+ sys.stderr.flush()
832
+ concurrency = target
833
+
775
834
  dispatcher = RuntimeDispatcher(runtime, concurrency)
776
835
  startup_ms = round((time.perf_counter() - t_start) * 1000)
777
836
  loaded_count = sum(1 for s in models if s.loaded)