apocaliss92 commited on
Commit
bc56020
·
verified ·
1 Parent(s): 6389d70

source: centred INT8 calibration; keep the pre-2026-10-08 converter for the IRs that kept it

Browse files
source/README.md CHANGED
@@ -80,11 +80,20 @@ python build-camstack-models.py --family yolo26 --calib-dir ./val2017
80
 
81
  The recipe takes the first N images in file-name order: 128 for YOLO26, 300
82
  for YOLOv9, as recorded in each IR's `rt_info`. Each image is letterboxed to
83
- the model's input size with grey (114) padding and scaled to [0, 1], the way
84
- CamStack feeds the model at runtime. `--calib-samples` overrides N. An IR does
85
- not record WHICH images calibrated it, so a different set gives an equivalent
86
- INT8 model, not a bit-identical one. Without calibration images the INT8 files
87
- are skipped and the run says so.
 
 
 
 
 
 
 
 
 
88
 
89
  ## Licences
90
 
 
80
 
81
  The recipe takes the first N images in file-name order: 128 for YOLO26, 300
82
  for YOLOv9, as recorded in each IR's `rt_info`. Each image is letterboxed to
83
+ the model's input size, CENTRED with grey (114) padding, and scaled to [0, 1],
84
+ the way CamStack feeds the model at runtime. `--calib-samples` overrides N. An
85
+ IR does not record WHICH images calibrated it, so a different set gives an
86
+ equivalent INT8 model, not a bit-identical one. Without calibration images the
87
+ INT8 files are skipped and the run says so.
88
+
89
+ Until 2026-10-08 the calibration padded TOP-LEFT. On that date every INT8 IR
90
+ was re-quantised centred from the hosted ONNX (`--reuse-onnx`, OpenVINO
91
+ 2026.2.1 + NNCF 3.2.0) and measured against the old one. 13 replaced the old
92
+ IR; 8 measured worse and were kept, so they still carry the top-left
93
+ calibration. `build-camstack-models.py` lists which is which. The kept ones are
94
+ reproduced by [`archive/convert_lib-before-2026-10-08.py`](archive/convert_lib-before-2026-10-08.py),
95
+ which rebuilds the hosted `yolov9m-640` INT8 IR bit for bit. The replaced old
96
+ IRs are under `archive/2026-10-08-int8-topleft-calibration/` at the mirror root.
97
 
98
  ## Licences
99
 
source/archive/convert_lib-before-2026-10-08.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared model converter: ONNX -> OpenVINO IR (fp16/int8/CoreML) + validation.
2
+
3
+ Used by both the runtime model-convert cap (convert.py) and the offline
4
+ scripts/build-camstack-models.py. Conversion ALWAYS starts from ONNX via
5
+ ov.convert_model (never the Ultralytics OV exporter, which emits unnamed
6
+ tensors that break the runtime's named-output postprocess)."""
7
+ from __future__ import annotations
8
+ import os
9
+ from pathlib import Path
10
+
11
+ # OpenVINO sizes the pool that COMPILES a model to the machine's core count and
12
+ # TBB never gives those workers back. A container cgroup counts THREADS, and
13
+ # this one has been measured refusing forks against `pids.max=2048` — so a
14
+ # conversion running beside the live pools must not spend `nproc` of the budget
15
+ # just to validate an IR. Same knob, same env var and same default (4) as
16
+ # OV_COMPILATION_NUM_THREADS in
17
+ # packages/addon-pipeline/python/inference_pool.py, which carries the
18
+ # measurement; this process is short-lived, so the saving here is a spike, not
19
+ # the 214-thread accumulation that motivated the bound. 0 disables it.
20
+ _OV_COMPILATION_NUM_THREADS: int = 4
21
+ try:
22
+ _OV_COMPILATION_NUM_THREADS = int(
23
+ os.environ.get("CAMSTACK_OV_COMPILATION_THREADS", "4")
24
+ )
25
+ except ValueError:
26
+ _OV_COMPILATION_NUM_THREADS = 4
27
+
28
+
29
+ def export_openvino(onnx_path: str, out_xml: str, *, fp16: bool) -> None:
30
+ import openvino as ov
31
+ ov_model = ov.convert_model(onnx_path)
32
+ Path(out_xml).parent.mkdir(parents=True, exist_ok=True)
33
+ ov.save_model(ov_model, out_xml, compress_to_fp16=fp16)
34
+
35
+
36
+ def validate_openvino_ir(xml_path: str) -> None:
37
+ """Compile the IR and assert every input/output tensor carries a name.
38
+ This is the exact defect (unnamed tensors) that broke 14/26 catalog IRs."""
39
+ import openvino as ov
40
+ core = ov.Core()
41
+ model = core.read_model(xml_path)
42
+ # Bound the compilation pool (see _OV_COMPILATION_NUM_THREADS). A plugin
43
+ # REJECTS a property it does not know, and this compile is the VALIDATION —
44
+ # it must never fail for a reason that has nothing to do with the IR, so a
45
+ # refused property falls back to the unbounded compile rather than
46
+ # reporting a bad model.
47
+ if _OV_COMPILATION_NUM_THREADS > 0:
48
+ try:
49
+ compiled = core.compile_model(
50
+ model,
51
+ "CPU",
52
+ {"COMPILATION_NUM_THREADS": _OV_COMPILATION_NUM_THREADS},
53
+ )
54
+ except Exception:
55
+ compiled = core.compile_model(model, "CPU")
56
+ else:
57
+ compiled = core.compile_model(model, "CPU")
58
+ for port in list(compiled.inputs) + list(compiled.outputs):
59
+ try:
60
+ name = port.get_any_name()
61
+ except Exception as exc: # ov raises when a tensor has no names
62
+ raise ValueError(f"IR {xml_path} has an unnamed tensor: {exc}") from exc
63
+ if not name:
64
+ raise ValueError(f"IR {xml_path} has an empty-named tensor")
65
+
66
+
67
+ def quantize_openvino_int8(
68
+ onnx_path: str, out_xml: str, calib_dir: str, imgsz: int, max_samples: int = 300
69
+ ) -> None:
70
+ import cv2
71
+ import numpy as np
72
+ import openvino as ov
73
+ import nncf
74
+
75
+ calib = Path(calib_dir)
76
+ images = sorted(
77
+ p for p in calib.iterdir() if p.suffix.lower() in {".jpg", ".jpeg", ".png"}
78
+ )[:max_samples]
79
+ if not images:
80
+ raise ValueError(f"no calibration images in {calib_dir}")
81
+
82
+ def preprocess(path: Path):
83
+ img = cv2.imread(str(path))
84
+ img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
85
+ h, w = img.shape[:2]
86
+ scale = imgsz / max(h, w)
87
+ nh, nw = int(round(h * scale)), int(round(w * scale))
88
+ resized = cv2.resize(img, (nw, nh))
89
+ canvas = np.full((imgsz, imgsz, 3), 114, dtype=np.uint8)
90
+ canvas[:nh, :nw] = resized
91
+ chw = canvas.transpose(2, 0, 1).astype(np.float32) / 255.0
92
+ return chw[np.newaxis, ...]
93
+
94
+ dataset = nncf.Dataset(images, lambda p: preprocess(p))
95
+ ov_model = ov.convert_model(onnx_path)
96
+ quantized = nncf.quantize(ov_model, dataset, subset_size=len(images))
97
+ Path(out_xml).parent.mkdir(parents=True, exist_ok=True)
98
+ ov.save_model(quantized, out_xml)
99
+
100
+
101
+ def export_coreml_from_onnx(
102
+ onnx_path: str,
103
+ out_pkg: str,
104
+ imgsz: int,
105
+ *,
106
+ input_shape: "tuple[int, ...] | None" = None,
107
+ fp32: bool = False,
108
+ ) -> None:
109
+ """ONNX -> CoreML mlpackage (darwin only).
110
+
111
+ Modern coremltools (>=6) dropped the ONNX frontend entirely — ``ct.convert``
112
+ only accepts TensorFlow / PyTorch / MIL sources, so feeding it an ``.onnx``
113
+ raises "Unable to determine the type of the model". We bridge through
114
+ PyTorch: ``onnx2torch`` rebuilds the ONNX graph as an ``nn.Module``, we trace
115
+ it with a representative image input, then coremltools converts the
116
+ TorchScript. Image models are NCHW ``(1, 3, imgsz, imgsz)``.
117
+
118
+ ``input_shape`` overrides that square-NCHW assumption for models whose input
119
+ is neither square nor channels-first — the plate recognizer is NHWC
120
+ ``(1, 64, 128, 3)``. Without it the trace runs on a wrongly-shaped tensor and
121
+ either throws deep inside the graph or, worse, produces a package whose
122
+ declared input silently disagrees with the real one. ``imgsz`` stays the
123
+ positional contract for every existing caller.
124
+
125
+ ``fp32`` keeps full precision instead of coremltools' default fp16 weights.
126
+ Detectors and embedders absorb fp16 — a box moves a fraction of a pixel, a
127
+ cosine moves 5e-5 — but a RECOGNIZER cannot: its output is an argmax plus a
128
+ confidence threshold, so a small shift in one softmax flips a stored
129
+ character or silences a real read. Measured on the plate recognizer, fp16
130
+ moved the weakest-character probability by up to 0.16 and changed the label
131
+ on a ground-truthed plate. The default stays fp16 so nothing already
132
+ published moves.
133
+
134
+ Dynamic-shape models (e.g. YOLO) emit ``Shape`` ops that coremltools' Torch
135
+ converter cannot lower ("Converter is not implemented … 'Shape'"). We first
136
+ run onnx-simplifier with the input shape pinned, which constant-folds those
137
+ Shape ops away, so the traced graph is static and convertible.
138
+ """
139
+ import coremltools as ct
140
+ import onnx
141
+ import torch
142
+ from onnx2torch import convert as onnx_to_torch
143
+ from onnxsim import simplify
144
+
145
+ shape = list(input_shape) if input_shape is not None else [1, 3, imgsz, imgsz]
146
+ onnx_model = onnx.load(onnx_path)
147
+ input_name = onnx_model.graph.input[0].name
148
+ simplified, ok = simplify(onnx_model, overwrite_input_shapes={input_name: shape})
149
+ if not ok:
150
+ simplified = onnx_model # fall back to the original if simplify bailed
151
+
152
+ model = onnx_to_torch(simplified).eval()
153
+ example = torch.rand(*shape)
154
+ with torch.no_grad():
155
+ traced = torch.jit.trace(model, example, strict=False)
156
+
157
+ mlmodel = ct.convert(
158
+ traced,
159
+ source="pytorch",
160
+ inputs=[ct.TensorType(name="input", shape=example.shape)],
161
+ minimum_deployment_target=ct.target.iOS16,
162
+ **({"compute_precision": ct.precision.FLOAT32} if fp32 else {}),
163
+ )
164
+ Path(out_pkg).parent.mkdir(parents=True, exist_ok=True)
165
+ mlmodel.save(out_pkg)
166
+
167
+
168
+ def validate_coreml(pkg_path: str) -> None:
169
+ import coremltools as ct
170
+ ct.models.MLModel(pkg_path) # load; raises if malformed
source/build-camstack-models.py CHANGED
@@ -28,6 +28,11 @@ Usage:
28
  python build-camstack-models.py --model yolo26n --imgsz 416 \
29
  --openvino fp16,int8 --calib-samples 128
30
 
 
 
 
 
 
31
  Outputs land in `--out` (default ./exported-models) under the MIRROR's paths,
32
  e.g. exported-models/objectDetection/yolo26/openvino/camstack-yolo26n-320-int8.xml.
33
 
@@ -38,17 +43,33 @@ Formats:
38
  openvino convert_lib.export_openvino: ov.convert_model(ONNX) + ov.save_model
39
  (compress_to_fp16 per precision) — never the Ultralytics OV exporter
40
  int8 convert_lib.quantize_openvino_int8: NNCF post-training quantisation
41
- of the ONNX, calibrated on the first N images (sorted by file name)
42
- of --calib-dir (default $CAMSTACK_CALIB_DIR or ./calibration-images)
 
43
 
44
  INT8 calibration: the hosted INT8 IRs record subset_size 128 (YOLO26) and 300
45
  (YOLOv9), drawn from COCO; an IR does not record WHICH images. The documented
46
  set is COCO val2017: unpack http://images.cocodataset.org/zips/val2017.zip and
47
  point --calib-dir at it — the recipe's N is taken in file-name order. Images are letterboxed to
48
- the recipe's input size with grey (114) padding and scaled to [0, 1], exactly
49
- as the CamStack runtime feeds the model. A different calibration set yields an
 
50
  equivalent but not bit-identical IR (NNCF picks ranges from the data).
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  Toolchain: each recipe names the Ultralytics / torch / coremltools / OpenVINO
53
  versions its hosted files record. Other versions produce the same model with a
54
  possibly different graph layout; the script warns when they differ.
@@ -128,7 +149,8 @@ class Recipe:
128
  imgsz: int
129
  toolchain: Toolchain
130
  # The PyTorch checkpoint: an Ultralytics asset id (`yolo26n` -> yolo26n.pt,
131
- # auto-downloaded by Ultralytics).
 
132
  checkpoint: str
133
  onnx: Optional[OnnxExport]
134
  coreml: Optional[CoremlExport]
@@ -304,6 +326,8 @@ def place(src: Path, dest: Path) -> Path:
304
 
305
 
306
  def checkpoint_path(recipe: Recipe) -> str:
 
 
307
  return f'{recipe.checkpoint}.pt' # Ultralytics downloads its own assets
308
 
309
 
@@ -468,13 +492,18 @@ def run_step(report: Report, label: str, step: Callable[[], object]) -> object:
468
 
469
 
470
  def build(recipe: Recipe, formats: set[str], out: Path, calib_dir: Path,
471
- calib_samples: Optional[int], verify_coreml: bool, report: Report) -> None:
 
472
  print('\n' + '=' * 70 + f'\n{recipe.name} ({recipe.folder}, {recipe.imgsz}×{recipe.imgsz})\n' + '=' * 70)
473
  if recipe.note:
474
  print(f' {recipe.note}')
475
  warn_toolchain(recipe)
476
  onnx_path: Optional[Path] = None
477
- if formats & {'onnx', 'openvino'} and recipe.onnx:
 
 
 
 
478
  result = run_step(report, f'{recipe.name} onnx', lambda: build_onnx(recipe, out))
479
  onnx_path = result if isinstance(result, Path) else None
480
  if 'coreml' in formats and recipe.coreml is not None:
@@ -496,12 +525,23 @@ def adhoc_recipe(args: argparse.Namespace) -> Recipe:
496
  bad = [p for p in precisions if p not in ('fp32', 'fp16', 'int8')]
497
  if bad:
498
  sys.exit(f'--openvino accepts fp32, fp16, int8; got {bad}')
499
- name = args.name or f'camstack-{args.model}-{args.imgsz}'
 
 
 
 
 
 
 
 
500
  return Recipe(
501
  name=name, folder=args.folder, imgsz=args.imgsz,
502
- toolchain=Toolchain('any', 'any', 'any', 'any'), checkpoint=args.model,
503
  onnx=OnnxExport(dynamic=args.dynamic, opset=args.opset),
504
- coreml=CoremlExport(dynamic=args.dynamic, batch=16 if args.dynamic else 1, half=True),
 
 
 
505
  openvino=tuple(OpenvinoOutput(f'-{p}', p) for p in precisions),
506
  calib_samples=args.calib_samples or 300,
507
  )
@@ -572,8 +612,16 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
572
  p.add_argument('--verify-coreml', action='store_true',
573
  help='predict N=1 and N=4 on each flexible-batch CoreML package (in a child process)')
574
  p.add_argument('--calib-samples', type=int, help="override the recipe's INT8 calibration image count")
 
 
 
 
575
  adhoc = p.add_argument_group('ad-hoc build (not a hosted file)')
576
- adhoc.add_argument('--model', help='Ultralytics id, e.g. yolo26n, yolov9m, yolo26s-seg')
 
 
 
 
577
  adhoc.add_argument('--imgsz', type=int, default=640)
578
  adhoc.add_argument('--name', help='output stem (default camstack-<model>-<imgsz>)')
579
  adhoc.add_argument('--folder', default='custom')
@@ -584,7 +632,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
584
 
585
 
586
  def select(args: argparse.Namespace) -> list[Recipe]:
587
- if args.model:
588
  return [adhoc_recipe(args)]
589
  by_name = {r.name: r for r in RECIPES}
590
  unknown = [v for v in (args.variant or []) if v not in by_name]
@@ -625,7 +673,7 @@ def main(argv: list[str]) -> int:
625
  report = Report()
626
  for r in recipes:
627
  build(r, formats, args.out, args.calib_dir, args.calib_samples,
628
- args.verify_coreml, report)
629
  print('\nDone.' + (f' Failed: {report.failed}.' if report.failed else '')
630
  + (f' Skipped: {report.skipped}.' if report.skipped else ''))
631
  return 1 if report.failed else 0
 
28
  python build-camstack-models.py --model yolo26n --imgsz 416 \
29
  --openvino fp16,int8 --calib-samples 128
30
 
31
+ # A fine-tuned local checkpoint, INT8-calibrated on your own frames (a
32
+ # folder of images, or an extracted retrain export: its frames/ are used):
33
+ python build-camstack-models.py --checkpoint runs/train/weights/best.pt \
34
+ --imgsz 640 --openvino fp16,int8 --calib-dir ./my-export --name custom-yolov9m-640
35
+
36
  Outputs land in `--out` (default ./exported-models) under the MIRROR's paths,
37
  e.g. exported-models/objectDetection/yolo26/openvino/camstack-yolo26n-320-int8.xml.
38
 
 
43
  openvino convert_lib.export_openvino: ov.convert_model(ONNX) + ov.save_model
44
  (compress_to_fp16 per precision) — never the Ultralytics OV exporter
45
  int8 convert_lib.quantize_openvino_int8: NNCF post-training quantisation
46
+ of the ONNX, calibrated on the first N images (sorted by path,
47
+ recursively; an export folder's frames/ only) of --calib-dir
48
+ (default $CAMSTACK_CALIB_DIR or ./calibration-images)
49
 
50
  INT8 calibration: the hosted INT8 IRs record subset_size 128 (YOLO26) and 300
51
  (YOLOv9), drawn from COCO; an IR does not record WHICH images. The documented
52
  set is COCO val2017: unpack http://images.cocodataset.org/zips/val2017.zip and
53
  point --calib-dir at it — the recipe's N is taken in file-name order. Images are letterboxed to
54
+ the recipe's input size, CENTRED with grey (114) padding, and scaled to [0, 1],
55
+ exactly as the CamStack runtime feeds the model (the padding sat top-left until
56
+ 2026-10-08, which the runtime never does). A different calibration set yields an
57
  equivalent but not bit-identical IR (NNCF picks ranges from the data).
58
 
59
+ Which calibration each hosted INT8 IR carries (measured, D739 holdout +
60
+ COCO; docs/benchmarks/2026-10-08-int8-recalibration.md). All were built from
61
+ the HOSTED ONNX with OpenVINO 2026.2.1 + NNCF 3.2.0 on the first N COCO val2017
62
+ images (reproduce with `--reuse-onnx`, the mirror's .onnx placed under --out):
63
+ centred (this script, since 2026-10-08):
64
+ yolov9{t,s,m}-320, yolov9s-640, yolo26n-320, yolo26{n,m,l,x} @640,
65
+ yolo26{n,s,m,l}-256
66
+ top-left (convert_lib as it was before 2026-10-08 — kept: re-quantising them
67
+ centred measured WORSE, beyond noise):
68
+ yolov9m-640, yolov9t-640, yolov9c-320, yolov9c-640, yolo26s @640,
69
+ yolo26{s,m,l}-320
70
+ The top-left padding (`canvas[:nh, :nw]`, sizes by round()) reproduces the
71
+ hosted yolov9m-640 IR bit for bit.
72
+
73
  Toolchain: each recipe names the Ultralytics / torch / coremltools / OpenVINO
74
  versions its hosted files record. Other versions produce the same model with a
75
  possibly different graph layout; the script warns when they differ.
 
149
  imgsz: int
150
  toolchain: Toolchain
151
  # The PyTorch checkpoint: an Ultralytics asset id (`yolo26n` -> yolo26n.pt,
152
+ # auto-downloaded by Ultralytics), or — `--checkpoint` — the absolute path
153
+ # of a local `.pt` (a fine-tune), used verbatim.
154
  checkpoint: str
155
  onnx: Optional[OnnxExport]
156
  coreml: Optional[CoremlExport]
 
326
 
327
 
328
  def checkpoint_path(recipe: Recipe) -> str:
329
+ if recipe.checkpoint.endswith('.pt'):
330
+ return recipe.checkpoint # a local checkpoint (--checkpoint), verbatim
331
  return f'{recipe.checkpoint}.pt' # Ultralytics downloads its own assets
332
 
333
 
 
492
 
493
 
494
  def build(recipe: Recipe, formats: set[str], out: Path, calib_dir: Path,
495
+ calib_samples: Optional[int], verify_coreml: bool, report: Report,
496
+ reuse_onnx: bool = False) -> None:
497
  print('\n' + '=' * 70 + f'\n{recipe.name} ({recipe.folder}, {recipe.imgsz}×{recipe.imgsz})\n' + '=' * 70)
498
  if recipe.note:
499
  print(f' {recipe.note}')
500
  warn_toolchain(recipe)
501
  onnx_path: Optional[Path] = None
502
+ existing_onnx = out / recipe.folder / 'onnx' / f'{recipe.name}.onnx'
503
+ if reuse_onnx and recipe.onnx and existing_onnx.is_file():
504
+ print(f' → ONNX: reusing {existing_onnx} (--reuse-onnx)')
505
+ onnx_path = existing_onnx
506
+ elif formats & {'onnx', 'openvino'} and recipe.onnx:
507
  result = run_step(report, f'{recipe.name} onnx', lambda: build_onnx(recipe, out))
508
  onnx_path = result if isinstance(result, Path) else None
509
  if 'coreml' in formats and recipe.coreml is not None:
 
525
  bad = [p for p in precisions if p not in ('fp32', 'fp16', 'int8')]
526
  if bad:
527
  sys.exit(f'--openvino accepts fp32, fp16, int8; got {bad}')
528
+ if args.checkpoint is not None:
529
+ local = Path(args.checkpoint).expanduser().resolve()
530
+ if not local.is_file() or local.suffix != '.pt':
531
+ sys.exit(f'--checkpoint {args.checkpoint}: not a .pt file')
532
+ checkpoint, stem = str(local), local.stem
533
+ else:
534
+ checkpoint, stem = args.model, args.model
535
+ name = args.name or f'camstack-{stem}-{args.imgsz}'
536
+ coreml_dynamic = args.dynamic and args.checkpoint is None
537
  return Recipe(
538
  name=name, folder=args.folder, imgsz=args.imgsz,
539
+ toolchain=Toolchain('any', 'any', 'any', 'any'), checkpoint=checkpoint,
540
  onnx=OnnxExport(dynamic=args.dynamic, opset=args.opset),
541
+ # A local checkpoint's architecture is not in its name, so the D742
542
+ # check cannot tell a YOLO26 fine-tune: its CoreML export is always
543
+ # static, the shape every architecture loads.
544
+ coreml=CoremlExport(dynamic=coreml_dynamic, batch=16 if coreml_dynamic else 1, half=True),
545
  openvino=tuple(OpenvinoOutput(f'-{p}', p) for p in precisions),
546
  calib_samples=args.calib_samples or 300,
547
  )
 
612
  p.add_argument('--verify-coreml', action='store_true',
613
  help='predict N=1 and N=4 on each flexible-batch CoreML package (in a child process)')
614
  p.add_argument('--calib-samples', type=int, help="override the recipe's INT8 calibration image count")
615
+ p.add_argument('--reuse-onnx', action='store_true',
616
+ help='an ONNX already at its mirror path under --out is used as is, not re-exported: '
617
+ 'download the hosted ONNX there to re-quantise an IR from the exact graph the '
618
+ 'mirror serves (no Ultralytics needed)')
619
  adhoc = p.add_argument_group('ad-hoc build (not a hosted file)')
620
+ source = adhoc.add_mutually_exclusive_group()
621
+ source.add_argument('--model', help='Ultralytics id, e.g. yolo26n, yolov9m, yolo26s-seg')
622
+ source.add_argument('--checkpoint',
623
+ help='a local .pt (e.g. a fine-tune), exported exactly like --model; '
624
+ 'pair with --calib-dir <your frames> for its INT8 IR')
625
  adhoc.add_argument('--imgsz', type=int, default=640)
626
  adhoc.add_argument('--name', help='output stem (default camstack-<model>-<imgsz>)')
627
  adhoc.add_argument('--folder', default='custom')
 
632
 
633
 
634
  def select(args: argparse.Namespace) -> list[Recipe]:
635
+ if args.model or args.checkpoint:
636
  return [adhoc_recipe(args)]
637
  by_name = {r.name: r for r in RECIPES}
638
  unknown = [v for v in (args.variant or []) if v not in by_name]
 
673
  report = Report()
674
  for r in recipes:
675
  build(r, formats, args.out, args.calib_dir, args.calib_samples,
676
+ args.verify_coreml, report, reuse_onnx=args.reuse_onnx)
677
  print('\nDone.' + (f' Failed: {report.failed}.' if report.failed else '')
678
  + (f' Skipped: {report.skipped}.' if report.skipped else ''))
679
  return 1 if report.failed else 0
source/convert_lib.py CHANGED
@@ -64,32 +64,61 @@ def validate_openvino_ir(xml_path: str) -> None:
64
  raise ValueError(f"IR {xml_path} has an empty-named tensor")
65
 
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  def quantize_openvino_int8(
68
  onnx_path: str, out_xml: str, calib_dir: str, imgsz: int, max_samples: int = 300
69
  ) -> None:
70
  import cv2
71
- import numpy as np
72
  import openvino as ov
73
  import nncf
74
 
75
- calib = Path(calib_dir)
76
- images = sorted(
77
- p for p in calib.iterdir() if p.suffix.lower() in {".jpg", ".jpeg", ".png"}
78
- )[:max_samples]
79
- if not images:
80
- raise ValueError(f"no calibration images in {calib_dir}")
81
 
82
  def preprocess(path: Path):
83
  img = cv2.imread(str(path))
84
- img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
85
- h, w = img.shape[:2]
86
- scale = imgsz / max(h, w)
87
- nh, nw = int(round(h * scale)), int(round(w * scale))
88
- resized = cv2.resize(img, (nw, nh))
89
- canvas = np.full((imgsz, imgsz, 3), 114, dtype=np.uint8)
90
- canvas[:nh, :nw] = resized
91
- chw = canvas.transpose(2, 0, 1).astype(np.float32) / 255.0
92
- return chw[np.newaxis, ...]
93
 
94
  dataset = nncf.Dataset(images, lambda p: preprocess(p))
95
  ov_model = ov.convert_model(onnx_path)
 
64
  raise ValueError(f"IR {xml_path} has an empty-named tensor")
65
 
66
 
67
+ CALIBRATION_SUFFIXES = {".jpg", ".jpeg", ".png"}
68
+
69
+
70
+ def calibration_images(calib_dir: str, max_samples: int) -> "list[Path]":
71
+ """The first ``max_samples`` calibration images of ``calib_dir``, in path order.
72
+
73
+ A flat folder (COCO val2017) is read exactly as before: file-name order. A
74
+ folder is walked recursively, so the operator's own frames work as they
75
+ come. The hub's annotated export (a folder holding ``annotations.json``)
76
+ calibrates on its ``frames/`` only: its ``crops/`` are classifier inputs,
77
+ not what a root detector is fed.
78
+ """
79
+ root = Path(calib_dir)
80
+ if not root.is_dir():
81
+ raise ValueError(f"calibration folder {calib_dir} does not exist")
82
+ base = root / "frames" if (root / "annotations.json").is_file() else root
83
+ images = sorted(
84
+ (p for p in base.rglob("*") if p.is_file() and p.suffix.lower() in CALIBRATION_SUFFIXES),
85
+ key=lambda p: p.relative_to(base).as_posix(),
86
+ )[:max_samples]
87
+ if not images:
88
+ raise ValueError(f"no calibration images in {calib_dir}")
89
+ return images
90
+
91
+
92
+ def letterbox_for_calibration(rgb, imgsz: int):
93
+ """``rgb`` (HxWx3 uint8) → ``[1,3,imgsz,imgsz]`` float32 in [0,1], letterboxed
94
+ as the inference pool does (``inference_pool.letterbox_image``): scale by the
95
+ long side, truncate, CENTRE with grey 114 padding."""
96
+ import cv2
97
+ import numpy as np
98
+
99
+ h, w = rgb.shape[:2]
100
+ scale = imgsz / max(h, w)
101
+ nw, nh = int(w * scale), int(h * scale)
102
+ pad_x, pad_y = (imgsz - nw) // 2, (imgsz - nh) // 2
103
+ resized = rgb if (nw, nh) == (w, h) else cv2.resize(rgb, (nw, nh), interpolation=cv2.INTER_LINEAR)
104
+ canvas = np.full((imgsz, imgsz, 3), 114, dtype=np.uint8)
105
+ canvas[pad_y : pad_y + nh, pad_x : pad_x + nw] = resized
106
+ chw = canvas.transpose(2, 0, 1).astype(np.float32) / 255.0
107
+ return chw[np.newaxis, ...]
108
+
109
+
110
  def quantize_openvino_int8(
111
  onnx_path: str, out_xml: str, calib_dir: str, imgsz: int, max_samples: int = 300
112
  ) -> None:
113
  import cv2
 
114
  import openvino as ov
115
  import nncf
116
 
117
+ images = calibration_images(calib_dir, max_samples)
 
 
 
 
 
118
 
119
  def preprocess(path: Path):
120
  img = cv2.imread(str(path))
121
+ return letterbox_for_calibration(cv2.cvtColor(img, cv2.COLOR_BGR2RGB), imgsz)
 
 
 
 
 
 
 
 
122
 
123
  dataset = nncf.Dataset(images, lambda p: preprocess(p))
124
  ov_model = ov.convert_model(onnx_path)