simplevision 0.5.1__py3-none-any.whl

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 (42) hide show
  1. simplevision/__init__.py +23 -0
  2. simplevision/_extractors.py +321 -0
  3. simplevision/_version.py +1 -0
  4. simplevision/outputs.py +74 -0
  5. simplevision/pipeline.py +229 -0
  6. simplevision/runtime/__init__.py +72 -0
  7. simplevision/runtime/__main__.py +51 -0
  8. simplevision/runtime/capture.py +136 -0
  9. simplevision/runtime/context.py +22 -0
  10. simplevision/runtime/executor.py +73 -0
  11. simplevision/runtime/list_cameras.py +50 -0
  12. simplevision/runtime/ops/__init__.py +31 -0
  13. simplevision/runtime/ops/_common.py +35 -0
  14. simplevision/runtime/ops/_registry.py +54 -0
  15. simplevision/runtime/ops/barcode.py +115 -0
  16. simplevision/runtime/ops/binary_ops.py +211 -0
  17. simplevision/runtime/ops/caliper.py +182 -0
  18. simplevision/runtime/ops/circles.py +62 -0
  19. simplevision/runtime/ops/color_ops.py +126 -0
  20. simplevision/runtime/ops/convolve.py +46 -0
  21. simplevision/runtime/ops/cspace.py +73 -0
  22. simplevision/runtime/ops/geomatch.py +205 -0
  23. simplevision/runtime/ops/grayscale_ops.py +160 -0
  24. simplevision/runtime/ops/image_ops.py +146 -0
  25. simplevision/runtime/ops/lines.py +71 -0
  26. simplevision/runtime/ops/lut.py +40 -0
  27. simplevision/runtime/ops/mask.py +107 -0
  28. simplevision/runtime/ops/ocr.py +145 -0
  29. simplevision/runtime/ops/patternmatch.py +142 -0
  30. simplevision/runtime/ops/pfilter.py +168 -0
  31. simplevision/runtime/ops/qrcode.py +101 -0
  32. simplevision/runtime/ops/rangethr.py +103 -0
  33. simplevision/runtime/ops/shapematch.py +148 -0
  34. simplevision/runtime/ops/skeleton.py +168 -0
  35. simplevision/runtime/ops/warp.py +74 -0
  36. simplevision/runtime/ops/watershed.py +110 -0
  37. simplevision/runtime/types.py +77 -0
  38. simplevision-0.5.1.dist-info/METADATA +102 -0
  39. simplevision-0.5.1.dist-info/RECORD +42 -0
  40. simplevision-0.5.1.dist-info/WHEEL +4 -0
  41. simplevision-0.5.1.dist-info/licenses/LICENSE +201 -0
  42. simplevision-0.5.1.dist-info/licenses/NOTICE +44 -0
@@ -0,0 +1,23 @@
1
+ """SimpleVision — student-friendly Python interface to SimpleVision pipelines.
2
+
3
+ Typical use::
4
+
5
+ from simplevision import Pipeline
6
+
7
+ p = Pipeline.load("my_pipeline.simplevision")
8
+ p.run()
9
+
10
+ hit = p.outputs.MatchPercentage[0]
11
+ if hit > 0.85:
12
+ print("Match found")
13
+
14
+ The pipeline definition (steps, params, output bindings) lives entirely
15
+ in the ``.simplevision`` JSON. To change a step, open the editor — the
16
+ ``.py`` file never needs to be regenerated.
17
+ """
18
+
19
+ from ._version import __version__
20
+ from .outputs import Outputs, Point
21
+ from .pipeline import Pipeline
22
+
23
+ __all__ = ["Pipeline", "Outputs", "Point", "__version__"]
@@ -0,0 +1,321 @@
1
+ """Per-(fnId, outputId) extraction functions.
2
+
3
+ Each entry takes the step's params, the runtime's ApplyResult, and the
4
+ final image, and returns the value to set on the Outputs object. The TS
5
+ node manifests declare which output ids exist; this table implements
6
+ their Python-side computation.
7
+
8
+ Adding a new output to a node = add one entry here + one entry on the
9
+ TS manifest. Both sides must agree on the id.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Callable
15
+
16
+ import cv2
17
+ import numpy as np
18
+
19
+ from .outputs import Point
20
+ from .runtime.ops._registry import ApplyResult
21
+
22
+
23
+ # Signature: (params, result, final_image) -> value
24
+ Extractor = Callable[[dict[str, Any], ApplyResult, "np.ndarray"], Any]
25
+
26
+
27
+ def _rows(result: ApplyResult) -> list[dict[str, Any]]:
28
+ return result.rows
29
+
30
+
31
+ # --- thr -------------------------------------------------------------------
32
+
33
+
34
+ def _thr_chosen_threshold(
35
+ params: dict[str, Any], result: ApplyResult, _img: "np.ndarray"
36
+ ) -> float:
37
+ # The runtime stashes the chosen value into a `Threshold` row.
38
+ for row in _rows(result):
39
+ if row.get("metric") == "Threshold":
40
+ v = row.get("value")
41
+ if isinstance(v, (int, float)):
42
+ return float(v)
43
+ # Adaptive mode reports "adaptive" — surface as NaN so students
44
+ # can `math.isnan(...)` check.
45
+ return float("nan")
46
+ # Fall back to the manual lower bound.
47
+ return float(params.get("lower", 0))
48
+
49
+
50
+ # --- calibrate -------------------------------------------------------------
51
+
52
+
53
+ def _calibrate_px_per_mm(
54
+ _params: dict[str, Any], result: ApplyResult, _img: "np.ndarray"
55
+ ) -> float:
56
+ for row in _rows(result):
57
+ if row.get("metric") == "px/mm":
58
+ return float(row["value"])
59
+ return float("nan")
60
+
61
+
62
+ # --- hist ------------------------------------------------------------------
63
+
64
+
65
+ def _hist_bins(
66
+ _params: dict[str, Any], _result: ApplyResult, img: "np.ndarray"
67
+ ) -> list[int]:
68
+ # The runtime's hist op only stores mean/stdev in rows. Compute the
69
+ # full 256-bin histogram here from the final (possibly equalised) image.
70
+ gray = img if img.ndim == 2 else cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
71
+ hist = cv2.calcHist([gray], [0], None, [256], [0, 256]).flatten()
72
+ return [int(x) for x in hist]
73
+
74
+
75
+ # --- blob ------------------------------------------------------------------
76
+
77
+
78
+ def _blob_count(_p: dict[str, Any], result: ApplyResult, _img: "np.ndarray") -> int:
79
+ return sum(1 for r in _rows(result) if r.get("ok", True))
80
+
81
+
82
+ def _blob_centers(
83
+ _p: dict[str, Any], result: ApplyResult, _img: "np.ndarray"
84
+ ) -> list[Point]:
85
+ return [Point(x=float(r["cx"]), y=float(r["cy"])) for r in _rows(result)]
86
+
87
+
88
+ def _blob_areas(
89
+ _p: dict[str, Any], result: ApplyResult, _img: "np.ndarray"
90
+ ) -> list[float]:
91
+ return [float(r["area"]) for r in _rows(result)]
92
+
93
+
94
+ # --- particle --------------------------------------------------------------
95
+
96
+
97
+ def _particle_count(_p: dict[str, Any], result: ApplyResult, _img: "np.ndarray") -> int:
98
+ return len(_rows(result))
99
+
100
+
101
+ def _particle_areas(
102
+ _p: dict[str, Any], result: ApplyResult, _img: "np.ndarray"
103
+ ) -> list[float]:
104
+ return [float(r["area"]) for r in _rows(result)]
105
+
106
+
107
+ def _particle_perimeters(
108
+ _p: dict[str, Any], result: ApplyResult, _img: "np.ndarray"
109
+ ) -> list[float]:
110
+ return [float(r["perim"]) for r in _rows(result)]
111
+
112
+
113
+ def _particle_circularities(
114
+ _p: dict[str, Any], result: ApplyResult, _img: "np.ndarray"
115
+ ) -> list[float]:
116
+ return [float(r["circ"]) for r in _rows(result)]
117
+
118
+
119
+ def _particle_centroids(
120
+ _p: dict[str, Any], result: ApplyResult, _img: "np.ndarray"
121
+ ) -> list[Point]:
122
+ return [Point(x=float(r["cx"]), y=float(r["cy"])) for r in _rows(result)]
123
+
124
+
125
+ # --- shapematch ------------------------------------------------------------
126
+
127
+
128
+ def _shapematch_count(
129
+ _p: dict[str, Any], result: ApplyResult, _img: "np.ndarray"
130
+ ) -> int:
131
+ return len(_rows(result))
132
+
133
+
134
+ def _shapematch_scores(
135
+ _p: dict[str, Any], result: ApplyResult, _img: "np.ndarray"
136
+ ) -> list[float]:
137
+ # Sort best-first to match the editor's UI hint.
138
+ rows = sorted(_rows(result), key=lambda r: -float(r.get("score", 0)))
139
+ return [float(r.get("score", 0.0)) for r in rows]
140
+
141
+
142
+ def _shapematch_positions(
143
+ _p: dict[str, Any], result: ApplyResult, _img: "np.ndarray"
144
+ ) -> list[Point]:
145
+ rows = sorted(_rows(result), key=lambda r: -float(r.get("score", 0)))
146
+ return [Point(x=float(r["cx"]), y=float(r["cy"])) for r in rows]
147
+
148
+
149
+ # --- ocr -------------------------------------------------------------------
150
+
151
+
152
+ def _ocr_text(_p: dict[str, Any], result: ApplyResult, _img: "np.ndarray") -> str:
153
+ # The OCR op stashes the concatenated string on a synthetic first row
154
+ # labelled "TEXT". Falls back to joining individual word rows if that
155
+ # row is missing (e.g. nothing recognised).
156
+ for row in _rows(result):
157
+ if row.get("label") == "TEXT":
158
+ return str(row.get("text", ""))
159
+ return " ".join(
160
+ str(r.get("text", "")) for r in _rows(result) if r.get("text")
161
+ )
162
+
163
+
164
+ def _ocr_word_count(_p: dict[str, Any], result: ApplyResult, _img: "np.ndarray") -> int:
165
+ return sum(1 for r in _rows(result) if r.get("label", "").startswith("W"))
166
+
167
+
168
+ def _ocr_positions(
169
+ _p: dict[str, Any], result: ApplyResult, _img: "np.ndarray"
170
+ ) -> list[Point]:
171
+ return [
172
+ Point(x=float(r["cx"]), y=float(r["cy"]))
173
+ for r in _rows(result)
174
+ if r.get("label", "").startswith("W")
175
+ ]
176
+
177
+
178
+ def _ocr_scores(
179
+ _p: dict[str, Any], result: ApplyResult, _img: "np.ndarray"
180
+ ) -> list[float]:
181
+ return [
182
+ float(r.get("score", 0.0))
183
+ for r in _rows(result)
184
+ if r.get("label", "").startswith("W")
185
+ ]
186
+
187
+
188
+ # --- geomatch --------------------------------------------------------------
189
+
190
+
191
+ def _geomatch_count(_p: dict[str, Any], result: ApplyResult, _img: "np.ndarray") -> int:
192
+ return len(_rows(result))
193
+
194
+
195
+ def _geomatch_positions(
196
+ _p: dict[str, Any], result: ApplyResult, _img: "np.ndarray"
197
+ ) -> list[Point]:
198
+ return [Point(x=float(r["cx"]), y=float(r["cy"])) for r in _rows(result)]
199
+
200
+
201
+ def _geomatch_scores(
202
+ _p: dict[str, Any], result: ApplyResult, _img: "np.ndarray"
203
+ ) -> list[float]:
204
+ return [float(r.get("score", 0.0)) for r in _rows(result)]
205
+
206
+
207
+ def _geomatch_angles(
208
+ _p: dict[str, Any], result: ApplyResult, _img: "np.ndarray"
209
+ ) -> list[float]:
210
+ return [float(r.get("angle", 0.0)) for r in _rows(result)]
211
+
212
+
213
+ # --- barcode ---------------------------------------------------------------
214
+
215
+
216
+ def _barcode_count(_p: dict[str, Any], result: ApplyResult, _img: "np.ndarray") -> int:
217
+ return sum(1 for r in _rows(result) if r.get("decoded"))
218
+
219
+
220
+ def _barcode_text(_p: dict[str, Any], result: ApplyResult, _img: "np.ndarray") -> str:
221
+ return "\n".join(
222
+ str(r.get("text", "")) for r in _rows(result) if r.get("decoded")
223
+ )
224
+
225
+
226
+ def _barcode_positions(
227
+ _p: dict[str, Any], result: ApplyResult, _img: "np.ndarray"
228
+ ) -> list[Point]:
229
+ return [
230
+ Point(x=float(r["cx"]), y=float(r["cy"]))
231
+ for r in _rows(result)
232
+ if r.get("decoded")
233
+ ]
234
+
235
+
236
+ # --- pfilter ---------------------------------------------------------------
237
+
238
+
239
+ def _pfilter_kept(_p: dict[str, Any], result: ApplyResult, _img: "np.ndarray") -> int:
240
+ return sum(1 for r in _rows(result) if r.get("kept"))
241
+
242
+
243
+ def _pfilter_dropped(_p: dict[str, Any], result: ApplyResult, _img: "np.ndarray") -> int:
244
+ return sum(1 for r in _rows(result) if not r.get("kept", True))
245
+
246
+
247
+ def _pfilter_areas(
248
+ _p: dict[str, Any], result: ApplyResult, _img: "np.ndarray"
249
+ ) -> list[float]:
250
+ return [float(r["area"]) for r in _rows(result) if r.get("kept")]
251
+
252
+
253
+ def _pfilter_centroids(
254
+ _p: dict[str, Any], result: ApplyResult, _img: "np.ndarray"
255
+ ) -> list[Point]:
256
+ return [
257
+ Point(x=float(r["cx"]), y=float(r["cy"]))
258
+ for r in _rows(result)
259
+ if r.get("kept")
260
+ ]
261
+
262
+
263
+ # --- qrcode ----------------------------------------------------------------
264
+
265
+
266
+ def _qrcode_count(_p: dict[str, Any], result: ApplyResult, _img: "np.ndarray") -> int:
267
+ return sum(1 for r in _rows(result) if r.get("decoded"))
268
+
269
+
270
+ def _qrcode_text(_p: dict[str, Any], result: ApplyResult, _img: "np.ndarray") -> str:
271
+ return "\n".join(
272
+ str(r.get("text", "")) for r in _rows(result) if r.get("decoded")
273
+ )
274
+
275
+
276
+ def _qrcode_positions(
277
+ _p: dict[str, Any], result: ApplyResult, _img: "np.ndarray"
278
+ ) -> list[Point]:
279
+ return [
280
+ Point(x=float(r["cx"]), y=float(r["cy"]))
281
+ for r in _rows(result)
282
+ if r.get("decoded")
283
+ ]
284
+
285
+
286
+ # --- dispatch table --------------------------------------------------------
287
+
288
+ EXTRACTORS: dict[tuple[str, str], Extractor] = {
289
+ ("thr", "chosen_threshold"): _thr_chosen_threshold,
290
+ ("calibrate", "px_per_mm"): _calibrate_px_per_mm,
291
+ ("hist", "bins"): _hist_bins,
292
+ ("blob", "count"): _blob_count,
293
+ ("blob", "centers"): _blob_centers,
294
+ ("blob", "areas"): _blob_areas,
295
+ ("particle", "count"): _particle_count,
296
+ ("particle", "areas"): _particle_areas,
297
+ ("particle", "perimeters"): _particle_perimeters,
298
+ ("particle", "circularities"): _particle_circularities,
299
+ ("particle", "centroids"): _particle_centroids,
300
+ ("shapematch", "count"): _shapematch_count,
301
+ ("shapematch", "scores"): _shapematch_scores,
302
+ ("shapematch", "positions"): _shapematch_positions,
303
+ ("ocr", "text"): _ocr_text,
304
+ ("ocr", "wordCount"): _ocr_word_count,
305
+ ("ocr", "positions"): _ocr_positions,
306
+ ("ocr", "scores"): _ocr_scores,
307
+ ("qrcode", "count"): _qrcode_count,
308
+ ("qrcode", "text"): _qrcode_text,
309
+ ("qrcode", "positions"): _qrcode_positions,
310
+ ("pfilter", "kept"): _pfilter_kept,
311
+ ("pfilter", "dropped"): _pfilter_dropped,
312
+ ("pfilter", "areas"): _pfilter_areas,
313
+ ("pfilter", "centroids"): _pfilter_centroids,
314
+ ("barcode", "count"): _barcode_count,
315
+ ("barcode", "text"): _barcode_text,
316
+ ("barcode", "positions"): _barcode_positions,
317
+ ("geomatch", "count"): _geomatch_count,
318
+ ("geomatch", "positions"): _geomatch_positions,
319
+ ("geomatch", "scores"): _geomatch_scores,
320
+ ("geomatch", "angles"): _geomatch_angles,
321
+ }
@@ -0,0 +1 @@
1
+ __version__ = "0.5.1"
@@ -0,0 +1,74 @@
1
+ """The ``Outputs`` container exposed as ``p.outputs`` after a pipeline run.
2
+
3
+ Bindings declared in the editor's Output Control populate this object
4
+ under student-chosen attribute names. The class itself stays simple:
5
+ attribute access for reads, a friendly ``__repr__`` so ``print(outputs)``
6
+ shows everything tidily.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections import namedtuple
12
+ from typing import Any
13
+
14
+ # Named-tuple so students can write `outputs.Centroids[0].x` instead of
15
+ # unpacking. Confirmed during the design discussion: dot access is the
16
+ # expected style.
17
+ Point = namedtuple("Point", ["x", "y"])
18
+
19
+
20
+ class Outputs:
21
+ """A simple attribute bag with a friendly repr.
22
+
23
+ Each entry corresponds to one ticked output in the editor's Output
24
+ Control panel. The variable name is whatever the student typed; the
25
+ value's shape depends on the output type (scalar, list of numbers,
26
+ list of ``Point``s — see the editor's type hint).
27
+ """
28
+
29
+ __slots__ = ("_values",)
30
+
31
+ def __init__(self) -> None:
32
+ # Bypass __setattr__ — direct dict assignment is the source of truth.
33
+ object.__setattr__(self, "_values", {})
34
+
35
+ def __setattr__(self, name: str, value: Any) -> None:
36
+ if name.startswith("_"):
37
+ object.__setattr__(self, name, value)
38
+ else:
39
+ self._values[name] = value
40
+
41
+ def __getattr__(self, name: str) -> Any:
42
+ # __getattr__ only fires when normal lookup fails, so the dunder
43
+ # names are already handled by object.__getattribute__.
44
+ try:
45
+ return self._values[name]
46
+ except KeyError as exc:
47
+ raise AttributeError(
48
+ f"outputs.{name} is not set. Open Output Control in the editor "
49
+ f"and tick this measurement to expose it as a variable."
50
+ ) from exc
51
+
52
+ def __contains__(self, name: str) -> bool:
53
+ return name in self._values
54
+
55
+ def __iter__(self):
56
+ return iter(self._values.items())
57
+
58
+ def __repr__(self) -> str:
59
+ if not self._values:
60
+ return "Outputs(empty — no measurements exposed)"
61
+ lines = ["Outputs:"]
62
+ for k, v in self._values.items():
63
+ lines.append(f" {k} = {_short_repr(v)}")
64
+ return "\n".join(lines)
65
+
66
+
67
+ def _short_repr(value: Any) -> str:
68
+ """Compact repr for the Outputs.__repr__ — long lists get truncated."""
69
+ if isinstance(value, list):
70
+ if len(value) > 5:
71
+ head = ", ".join(repr(x) for x in value[:3])
72
+ return f"[{head}, ... +{len(value) - 3} more]"
73
+ return repr(value)
74
+ return repr(value)
@@ -0,0 +1,229 @@
1
+ """``Pipeline.load(...).run()`` — the student-facing API.
2
+
3
+ Loads a ``.simplevision`` JSON, executes its steps using the same ops
4
+ the editor uses (via ``simplevision.runtime``), and populates an
5
+ Outputs object based on the bindings declared in the editor's Output
6
+ Control.
7
+
8
+ Source of the first frame:
9
+
10
+ - The ``load`` step's params decide where the source comes from (file or
11
+ webcam). The ``path`` param is resolved relative to the ``.simplevision``
12
+ file's directory, so distributing a pipeline + a sample frame as a
13
+ single folder Just Works.
14
+ - Pass ``p.run(frame_or_path)`` to override the load step with your own
15
+ image (handy for batch-processing many frames through the same
16
+ pipeline).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import tempfile
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import cv2
26
+ import numpy as np
27
+
28
+ from ._extractors import EXTRACTORS
29
+ from .outputs import Outputs
30
+ from .runtime import (
31
+ OutputBinding,
32
+ Pipeline as _PipelineSpec,
33
+ Step,
34
+ load_pipeline as _load_pipeline,
35
+ )
36
+ from .runtime.context import Context
37
+ from .runtime.ops import REGISTRY
38
+
39
+
40
+ class Pipeline:
41
+ """Runs a SimpleVision pipeline against an image and exposes named
42
+ measurements via :attr:`outputs`.
43
+
44
+ Typical use::
45
+
46
+ p = Pipeline.load("my_pipeline.simplevision")
47
+ p.run()
48
+ print(p.outputs)
49
+ """
50
+
51
+ def __init__(self, spec: _PipelineSpec, base_dir: Path):
52
+ self._spec = spec
53
+ self._base_dir = base_dir
54
+ self.outputs: Outputs = Outputs()
55
+ self.image: np.ndarray | None = None
56
+
57
+ @classmethod
58
+ def load(cls, path: str | Path) -> "Pipeline":
59
+ """Load a pipeline from a ``.simplevision`` JSON file."""
60
+ p = Path(path).resolve()
61
+ spec = _load_pipeline(p)
62
+ return cls(spec=spec, base_dir=p.parent)
63
+
64
+ def run(self, source: "str | Path | np.ndarray | None" = None) -> "Pipeline":
65
+ """Execute the pipeline and populate :attr:`outputs`.
66
+
67
+ Args:
68
+ source: Optional override for the first frame. If a path, the
69
+ image is read from disk. If a numpy array, it's used
70
+ directly (and the pipeline's load step is skipped). If
71
+ ``None`` (the default), the load step's params decide.
72
+
73
+ Returns ``self`` so calls can be chained:
74
+ ``Pipeline.load(...).run().outputs``.
75
+ """
76
+ self.outputs = Outputs() # fresh on every run
77
+
78
+ ctx = self._build_context()
79
+ img = self._initial_image(source, ctx)
80
+
81
+ for step in self._spec.steps:
82
+ if not step.enabled:
83
+ continue
84
+ # The load step is handled above; skip when source was provided
85
+ # or when we've already resolved its image from params.
86
+ if step.fnId == "load":
87
+ self._bind_outputs(step, _LoadResult(image=img), img)
88
+ continue
89
+ img = self._execute_step(step, img, ctx)
90
+
91
+ self.image = img
92
+ return self
93
+
94
+ # --- internals ---------------------------------------------------------
95
+
96
+ def _build_context(self) -> Context:
97
+ # The runtime's Context expects a frame_path + out_dir. The lab
98
+ # doesn't write per-step PNGs (we only care about measurements),
99
+ # but a few ops (load, calibrate) read from frame_path or
100
+ # write into ctx.state, so we have to provide one. The frame_path
101
+ # is set later once we know the load source.
102
+ return Context(frame_path=Path("."), out_dir=Path(tempfile.gettempdir()))
103
+
104
+ def _initial_image(
105
+ self,
106
+ source: "str | Path | np.ndarray | None",
107
+ ctx: Context,
108
+ ) -> np.ndarray:
109
+ if isinstance(source, np.ndarray):
110
+ return source
111
+
112
+ if source is not None:
113
+ path = Path(source)
114
+ if not path.is_absolute():
115
+ path = (self._base_dir / path).resolve()
116
+ ctx.frame_path = path
117
+ return _read_image(path)
118
+
119
+ # Fall back to the load step's params.
120
+ load_step = next((s for s in self._spec.steps if s.fnId == "load"), None)
121
+ if load_step is None:
122
+ raise RuntimeError(
123
+ "Pipeline has no Load step and no source was passed to run()"
124
+ )
125
+ kind = load_step.params.get("kind", "file")
126
+ if kind == "file":
127
+ raw = load_step.params.get("path", "")
128
+ if not raw:
129
+ raise RuntimeError(
130
+ "Load step has no file path. Pass one to run(\"frame.png\") "
131
+ "or set it in the editor."
132
+ )
133
+ path = Path(raw)
134
+ if not path.is_absolute():
135
+ path = (self._base_dir / path).resolve()
136
+ ctx.frame_path = path
137
+ return _read_image(path)
138
+ if kind == "webcam":
139
+ return _capture_webcam(load_step.params)
140
+ raise RuntimeError(f"Unknown Load kind: {kind!r}")
141
+
142
+ def _execute_step(
143
+ self, step: Step, img: np.ndarray, ctx: Context
144
+ ) -> np.ndarray:
145
+ op = REGISTRY.get(step.fnId)
146
+ if op is None:
147
+ raise RuntimeError(f"Unknown step fnId: {step.fnId!r}")
148
+ result = op(img, step.params, ctx)
149
+ self._bind_outputs(step, result, result.image)
150
+ return result.image
151
+
152
+ def _bind_outputs(
153
+ self,
154
+ step: Step,
155
+ result: Any,
156
+ final_image: np.ndarray,
157
+ ) -> None:
158
+ if not step.outputs:
159
+ return
160
+ for binding in step.outputs:
161
+ extractor = EXTRACTORS.get((step.fnId, binding.outputId))
162
+ if extractor is None:
163
+ # Unknown binding — usually a stale .simplevision pointing
164
+ # at an output we no longer support. Skip rather than
165
+ # crash so the rest of the pipeline still runs.
166
+ continue
167
+ value = extractor(step.params, result, final_image)
168
+ setattr(self.outputs, binding.variableName, value)
169
+
170
+
171
+ def _read_image(path: Path) -> np.ndarray:
172
+ img = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
173
+ if img is None:
174
+ raise FileNotFoundError(f"Cannot read image: {path}")
175
+ return img
176
+
177
+
178
+ def _capture_webcam(params: dict[str, Any]) -> np.ndarray:
179
+ """Minimal webcam capture mirroring the editor's Load form. Honours
180
+ the same auto/manual settings the user picked. Lives here rather than
181
+ in the runtime so the runtime stays headless-only."""
182
+ import sys
183
+
184
+ device = int(params.get("device", 0))
185
+ settings = params.get("settings", {}) or {}
186
+ cap = cv2.VideoCapture(device)
187
+ if not cap.isOpened():
188
+ raise RuntimeError(f"Cannot open webcam device {device}")
189
+ try:
190
+ ae = settings.get("autoExposure")
191
+ if ae is not None:
192
+ if sys.platform == "win32":
193
+ cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0.75 if ae else 0.25)
194
+ else:
195
+ cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 3 if ae else 1)
196
+ af = settings.get("autoFocus")
197
+ if af is not None:
198
+ cap.set(cv2.CAP_PROP_AUTOFOCUS, 1 if af else 0)
199
+ for prop_name, cv_prop in [
200
+ ("exposure", cv2.CAP_PROP_EXPOSURE),
201
+ ("focus", cv2.CAP_PROP_FOCUS),
202
+ ("brightness", cv2.CAP_PROP_BRIGHTNESS),
203
+ ("gain", cv2.CAP_PROP_GAIN),
204
+ ]:
205
+ v = settings.get(prop_name)
206
+ if v is not None:
207
+ cap.set(cv_prop, float(v))
208
+ # Warm-up so the sensor applies the settings before we grab.
209
+ for _ in range(4):
210
+ cap.read()
211
+ ok, frame = cap.read()
212
+ finally:
213
+ cap.release()
214
+ if not ok or frame is None:
215
+ raise RuntimeError(f"Failed to read frame from webcam {device}")
216
+ return frame
217
+
218
+
219
+ class _LoadResult:
220
+ """Stand-in ApplyResult for the load step — we don't run the op (we
221
+ read the image ourselves), but the extractor table still expects
222
+ something with the `rows` shape. Load has no exposed outputs today,
223
+ so this stays empty; here only to keep the binding path uniform."""
224
+
225
+ __slots__ = ("image", "rows")
226
+
227
+ def __init__(self, image: np.ndarray):
228
+ self.image = image
229
+ self.rows = []