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.
- simplevision/__init__.py +23 -0
- simplevision/_extractors.py +321 -0
- simplevision/_version.py +1 -0
- simplevision/outputs.py +74 -0
- simplevision/pipeline.py +229 -0
- simplevision/runtime/__init__.py +72 -0
- simplevision/runtime/__main__.py +51 -0
- simplevision/runtime/capture.py +136 -0
- simplevision/runtime/context.py +22 -0
- simplevision/runtime/executor.py +73 -0
- simplevision/runtime/list_cameras.py +50 -0
- simplevision/runtime/ops/__init__.py +31 -0
- simplevision/runtime/ops/_common.py +35 -0
- simplevision/runtime/ops/_registry.py +54 -0
- simplevision/runtime/ops/barcode.py +115 -0
- simplevision/runtime/ops/binary_ops.py +211 -0
- simplevision/runtime/ops/caliper.py +182 -0
- simplevision/runtime/ops/circles.py +62 -0
- simplevision/runtime/ops/color_ops.py +126 -0
- simplevision/runtime/ops/convolve.py +46 -0
- simplevision/runtime/ops/cspace.py +73 -0
- simplevision/runtime/ops/geomatch.py +205 -0
- simplevision/runtime/ops/grayscale_ops.py +160 -0
- simplevision/runtime/ops/image_ops.py +146 -0
- simplevision/runtime/ops/lines.py +71 -0
- simplevision/runtime/ops/lut.py +40 -0
- simplevision/runtime/ops/mask.py +107 -0
- simplevision/runtime/ops/ocr.py +145 -0
- simplevision/runtime/ops/patternmatch.py +142 -0
- simplevision/runtime/ops/pfilter.py +168 -0
- simplevision/runtime/ops/qrcode.py +101 -0
- simplevision/runtime/ops/rangethr.py +103 -0
- simplevision/runtime/ops/shapematch.py +148 -0
- simplevision/runtime/ops/skeleton.py +168 -0
- simplevision/runtime/ops/warp.py +74 -0
- simplevision/runtime/ops/watershed.py +110 -0
- simplevision/runtime/types.py +77 -0
- simplevision-0.5.1.dist-info/METADATA +102 -0
- simplevision-0.5.1.dist-info/RECORD +42 -0
- simplevision-0.5.1.dist-info/WHEEL +4 -0
- simplevision-0.5.1.dist-info/licenses/LICENSE +201 -0
- simplevision-0.5.1.dist-info/licenses/NOTICE +44 -0
simplevision/__init__.py
ADDED
|
@@ -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
|
+
}
|
simplevision/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.1"
|
simplevision/outputs.py
ADDED
|
@@ -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)
|
simplevision/pipeline.py
ADDED
|
@@ -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 = []
|