machinevision-toolbox-python 2.0.1__py3-none-any.whl → 2.2.0__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.
- {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.2.0.dist-info}/METADATA +13 -5
- {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.2.0.dist-info}/RECORD +23 -33
- {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.2.0.dist-info}/WHEEL +1 -1
- machinevisiontoolbox/BundleAdjust.py +1 -1
- machinevisiontoolbox/Camera.py +95 -80
- machinevisiontoolbox/ImageBlobs.py +10 -4
- machinevisiontoolbox/ImageCore.py +105 -97
- machinevisiontoolbox/ImageFiducials.py +62 -10
- machinevisiontoolbox/ImageIO.py +5 -1
- machinevisiontoolbox/ImageLineFeatures.py +9 -1
- machinevisiontoolbox/ImagePointFeatures.py +29 -8
- machinevisiontoolbox/ImageProcessing.py +1 -1
- machinevisiontoolbox/ImageRegionFeatures.py +25 -6
- machinevisiontoolbox/ImageWholeFeatures.py +113 -72
- machinevisiontoolbox/Sources.py +1 -1
- machinevisiontoolbox/__init__.py +1 -1
- machinevisiontoolbox/base/imageio.py +68 -33
- machinevisiontoolbox/bin/_iminteractive.py +461 -0
- machinevisiontoolbox/bin/bagtool.py +120 -6
- machinevisiontoolbox/bin/imtool.py +34 -5
- machinevisiontoolbox/bin/mvtbtool.py +93 -18
- machinevisiontoolbox/ImageCore.docstring +0 -2877
- machinevisiontoolbox/aruco0.pdf +0 -0
- machinevisiontoolbox/aruco50.pdf +0 -0
- machinevisiontoolbox/bin/labeltool.py +0 -147
- machinevisiontoolbox/blocks/Icons/icons.sh +0 -4
- machinevisiontoolbox/cvfuncs.py +0 -40
- machinevisiontoolbox/docbugs.py +0 -47
- machinevisiontoolbox/newcameras.py +0 -55
- machinevisiontoolbox/test_skimage.py +0 -12
- machinevisiontoolbox/testblobplots.py +0 -17
- machinevisiontoolbox/x.json +0 -296
- {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.2.0.dist-info}/entry_points.txt +0 -0
- {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -301,6 +301,57 @@ class Image(
|
|
|
301
301
|
)
|
|
302
302
|
|
|
303
303
|
# if dtype is not given, determine the appropriate type for the data
|
|
304
|
+
dtype = self._infer_dtype(image, dtype)
|
|
305
|
+
|
|
306
|
+
if binary:
|
|
307
|
+
image = image > 0
|
|
308
|
+
|
|
309
|
+
# change type of array to the determined dtype
|
|
310
|
+
if dtype is not None:
|
|
311
|
+
image = image.astype(dtype, copy=False)
|
|
312
|
+
|
|
313
|
+
self.name = name
|
|
314
|
+
|
|
315
|
+
color_dict = Image.colororder2dict(colororder)
|
|
316
|
+
|
|
317
|
+
image = self._reshape_to_size(image, size)
|
|
318
|
+
|
|
319
|
+
if image.ndim not in (2, 3):
|
|
320
|
+
raise ValueError(
|
|
321
|
+
"bad ndarray passed to Image constructor: must be 2D or 3D array"
|
|
322
|
+
)
|
|
323
|
+
if image.ndim == 3 and image.shape[2] == 1:
|
|
324
|
+
image = image[:, :, 0] # squeeze out singleton plane
|
|
325
|
+
|
|
326
|
+
if kwargs:
|
|
327
|
+
image = convert(image, **kwargs)
|
|
328
|
+
|
|
329
|
+
# assign the image to the object, copying if requested
|
|
330
|
+
if copy:
|
|
331
|
+
self._A = image.copy()
|
|
332
|
+
else:
|
|
333
|
+
self._A = image
|
|
334
|
+
|
|
335
|
+
# final check that colororder length matches number of planes
|
|
336
|
+
if colororder is not None:
|
|
337
|
+
if len(color_dict) != self.nplanes:
|
|
338
|
+
raise ValueError("colororder length does not match number of planes")
|
|
339
|
+
|
|
340
|
+
if colororder is None:
|
|
341
|
+
if self.nplanes == 3:
|
|
342
|
+
self.colororder = "RGB"
|
|
343
|
+
# warnings.warn("defaulting color to RGB")
|
|
344
|
+
else:
|
|
345
|
+
self.colororder = color_dict
|
|
346
|
+
|
|
347
|
+
self._stats = None
|
|
348
|
+
|
|
349
|
+
self.name = name
|
|
350
|
+
|
|
351
|
+
@staticmethod
|
|
352
|
+
def _infer_dtype(image: np.ndarray, dtype: Dtype | bool | None) -> np.dtype | None:
|
|
353
|
+
"""Determine the pixel dtype for the constructor when ``dtype`` isn't
|
|
354
|
+
given explicitly, or resolve/validate it when it is."""
|
|
304
355
|
if dtype is None:
|
|
305
356
|
# no type given, automatically choose it
|
|
306
357
|
if np.issubdtype(image.dtype, np.floating):
|
|
@@ -335,17 +386,12 @@ class Image(
|
|
|
335
386
|
except TypeError:
|
|
336
387
|
raise ValueError("bad dtype argument passed to Image constructor")
|
|
337
388
|
|
|
338
|
-
|
|
339
|
-
image = image > 0
|
|
340
|
-
|
|
341
|
-
# change type of array to the determined dtype
|
|
342
|
-
if dtype is not None:
|
|
343
|
-
image = image.astype(dtype, copy=False)
|
|
344
|
-
|
|
345
|
-
self.name = name
|
|
346
|
-
|
|
347
|
-
color_dict = Image.colororder2dict(colororder)
|
|
389
|
+
return dtype
|
|
348
390
|
|
|
391
|
+
def _reshape_to_size(
|
|
392
|
+
self, image: np.ndarray, size: tuple | list | None
|
|
393
|
+
) -> np.ndarray:
|
|
394
|
+
"""Reshape 1D/2D pixel data per the constructor's ``size=`` argument."""
|
|
349
395
|
if isinstance(size, self.__class__):
|
|
350
396
|
# size is an Image instance, ignore the size/shape and use the image's shape
|
|
351
397
|
size = size.size
|
|
@@ -354,16 +400,7 @@ class Image(
|
|
|
354
400
|
if size is not None:
|
|
355
401
|
newsize = [size[1], size[0]]
|
|
356
402
|
|
|
357
|
-
if
|
|
358
|
-
nplanes = len(color_dict)
|
|
359
|
-
|
|
360
|
-
if len(size) == 3:
|
|
361
|
-
if nplanes != size[2]:
|
|
362
|
-
raise ValueError(
|
|
363
|
-
"colororder length does not match number of planes in size"
|
|
364
|
-
)
|
|
365
|
-
newsize.append(nplanes)
|
|
366
|
-
elif len(size) == 3:
|
|
403
|
+
if len(size) == 3:
|
|
367
404
|
newsize.append(size[2])
|
|
368
405
|
|
|
369
406
|
if image.ndim == 1:
|
|
@@ -420,37 +457,7 @@ class Image(
|
|
|
420
457
|
|
|
421
458
|
image = image.reshape(*newsize)
|
|
422
459
|
|
|
423
|
-
|
|
424
|
-
raise ValueError(
|
|
425
|
-
"bad ndarray passed to Image constructor: must be 2D or 3D array"
|
|
426
|
-
)
|
|
427
|
-
if image.ndim == 3 and image.shape[2] == 1:
|
|
428
|
-
image = image[:, :, 0] # squeeze out singleton plane
|
|
429
|
-
|
|
430
|
-
if kwargs:
|
|
431
|
-
image = convert(image, **kwargs)
|
|
432
|
-
|
|
433
|
-
# assign the image to the object, copying if requested
|
|
434
|
-
if copy:
|
|
435
|
-
self._A = image.copy()
|
|
436
|
-
else:
|
|
437
|
-
self._A = image
|
|
438
|
-
|
|
439
|
-
# final check that colororder length matches number of planes
|
|
440
|
-
if colororder is not None:
|
|
441
|
-
if len(color_dict) != self.nplanes:
|
|
442
|
-
raise ValueError("colororder length does not match number of planes")
|
|
443
|
-
|
|
444
|
-
if colororder is None:
|
|
445
|
-
if self.nplanes == 3:
|
|
446
|
-
self.colororder = "RGB"
|
|
447
|
-
# warnings.warn("defaulting color to RGB")
|
|
448
|
-
else:
|
|
449
|
-
self.colororder = color_dict
|
|
450
|
-
|
|
451
|
-
self._stats = None
|
|
452
|
-
|
|
453
|
-
self.name = name
|
|
460
|
+
return image
|
|
454
461
|
|
|
455
462
|
@staticmethod
|
|
456
463
|
def _plane_stats(plane: np.ndarray) -> dict[str, float | int]:
|
|
@@ -2711,50 +2718,6 @@ class Image(
|
|
|
2711
2718
|
:seealso: :meth:`red` :meth:`green` :meth:`blue` :meth:`plane` :meth:`roi` :meth:`pixel`
|
|
2712
2719
|
"""
|
|
2713
2720
|
|
|
2714
|
-
def fixdims(
|
|
2715
|
-
out: np.ndarray, shape: tuple[int, ...], keys: tuple[Any, ...]
|
|
2716
|
-
) -> np.ndarray:
|
|
2717
|
-
# deal with the fact that some of the keys may have reduced the
|
|
2718
|
-
# dimensionality of the array, eg. a slice of span 0 or 1, or an integer
|
|
2719
|
-
# key.
|
|
2720
|
-
|
|
2721
|
-
# shape: (nrows, ncols, nplanes) in NumPy order
|
|
2722
|
-
# key: (rowspec, colspec, planespec) in NumPy order
|
|
2723
|
-
|
|
2724
|
-
def lenkey(key: int | slice, max: int) -> int:
|
|
2725
|
-
# compute the span of a particular key
|
|
2726
|
-
if isinstance(key, int):
|
|
2727
|
-
# int key has a span on 1
|
|
2728
|
-
return 1
|
|
2729
|
-
elif isinstance(key, slice):
|
|
2730
|
-
# slice key has a span depending on the slice and the corresponding
|
|
2731
|
-
# array dimension. we have to essentially replicate the logic of
|
|
2732
|
-
# slice() here.
|
|
2733
|
-
start = key.start if key.start is not None else 0
|
|
2734
|
-
stop = key.stop if key.stop is not None else max
|
|
2735
|
-
step = key.step if key.step is not None else 1
|
|
2736
|
-
n = stop - start
|
|
2737
|
-
if n < step:
|
|
2738
|
-
return 1
|
|
2739
|
-
else:
|
|
2740
|
-
return n // step
|
|
2741
|
-
|
|
2742
|
-
dims = list(shape)
|
|
2743
|
-
|
|
2744
|
-
if len(shape) == 2:
|
|
2745
|
-
dims = [lenkey(keys[0], shape[0]), lenkey(keys[1], shape[1])]
|
|
2746
|
-
elif len(shape) == 3:
|
|
2747
|
-
dims = [
|
|
2748
|
-
lenkey(keys[0], shape[0]),
|
|
2749
|
-
lenkey(keys[1], shape[1]),
|
|
2750
|
-
lenkey(keys[2], shape[2]),
|
|
2751
|
-
]
|
|
2752
|
-
# ignore loss of color dimension
|
|
2753
|
-
if dims[2] == 1:
|
|
2754
|
-
dims = dims[:2]
|
|
2755
|
-
|
|
2756
|
-
return out.reshape(tuple(dims))
|
|
2757
|
-
|
|
2758
2721
|
if (
|
|
2759
2722
|
isinstance(keys, tuple)
|
|
2760
2723
|
and len(keys) == 2
|
|
@@ -2791,7 +2754,7 @@ class Image(
|
|
|
2791
2754
|
out = self._A[keys]
|
|
2792
2755
|
|
|
2793
2756
|
if out.ndim < 3:
|
|
2794
|
-
out =
|
|
2757
|
+
out = self._fixdims(out, self._A.shape, keys)
|
|
2795
2758
|
|
|
2796
2759
|
else:
|
|
2797
2760
|
# greyscale image
|
|
@@ -2804,7 +2767,7 @@ class Image(
|
|
|
2804
2767
|
out = self._A[keys]
|
|
2805
2768
|
|
|
2806
2769
|
if out.ndim < 2:
|
|
2807
|
-
out =
|
|
2770
|
+
out = self._fixdims(out, self._A.shape, keys)
|
|
2808
2771
|
|
|
2809
2772
|
# a singleton plane dimensions is a grey scale image
|
|
2810
2773
|
if out.ndim == 3 and out.shape[2] == 1:
|
|
@@ -2825,6 +2788,51 @@ class Image(
|
|
|
2825
2788
|
else:
|
|
2826
2789
|
raise ValueError("invalid slice")
|
|
2827
2790
|
|
|
2791
|
+
@staticmethod
|
|
2792
|
+
def _lenkey(key: int | slice, max: int) -> int:
|
|
2793
|
+
"""Span of a single ``__getitem__`` key component (int or slice)."""
|
|
2794
|
+
if isinstance(key, int):
|
|
2795
|
+
# int key has a span on 1
|
|
2796
|
+
return 1
|
|
2797
|
+
elif isinstance(key, slice):
|
|
2798
|
+
# slice key has a span depending on the slice and the corresponding
|
|
2799
|
+
# array dimension. we have to essentially replicate the logic of
|
|
2800
|
+
# slice() here.
|
|
2801
|
+
start = key.start if key.start is not None else 0
|
|
2802
|
+
stop = key.stop if key.stop is not None else max
|
|
2803
|
+
step = key.step if key.step is not None else 1
|
|
2804
|
+
n = stop - start
|
|
2805
|
+
if n < step:
|
|
2806
|
+
return 1
|
|
2807
|
+
else:
|
|
2808
|
+
return n // step
|
|
2809
|
+
|
|
2810
|
+
@classmethod
|
|
2811
|
+
def _fixdims(
|
|
2812
|
+
cls, out: np.ndarray, shape: tuple[int, ...], keys: tuple[Any, ...]
|
|
2813
|
+
) -> np.ndarray:
|
|
2814
|
+
"""Restore dimensionality lost by ``__getitem__`` keys that collapse
|
|
2815
|
+
an axis (eg. a slice of span 0 or 1, or an integer key).
|
|
2816
|
+
|
|
2817
|
+
``shape``: (nrows, ncols, nplanes) in NumPy order.
|
|
2818
|
+
``keys``: (rowspec, colspec, planespec) in NumPy order.
|
|
2819
|
+
"""
|
|
2820
|
+
dims = list(shape)
|
|
2821
|
+
|
|
2822
|
+
if len(shape) == 2:
|
|
2823
|
+
dims = [cls._lenkey(keys[0], shape[0]), cls._lenkey(keys[1], shape[1])]
|
|
2824
|
+
elif len(shape) == 3:
|
|
2825
|
+
dims = [
|
|
2826
|
+
cls._lenkey(keys[0], shape[0]),
|
|
2827
|
+
cls._lenkey(keys[1], shape[1]),
|
|
2828
|
+
cls._lenkey(keys[2], shape[2]),
|
|
2829
|
+
]
|
|
2830
|
+
# ignore loss of color dimension
|
|
2831
|
+
if dims[2] == 1:
|
|
2832
|
+
dims = dims[:2]
|
|
2833
|
+
|
|
2834
|
+
return out.reshape(tuple(dims))
|
|
2835
|
+
|
|
2828
2836
|
def pixel(self, u: int, v: int) -> int | float | np.ndarray:
|
|
2829
2837
|
"""
|
|
2830
2838
|
Return pixel value
|
|
@@ -133,20 +133,53 @@ class ImageFiducialsMixin(_ImageBase if TYPE_CHECKING else object):
|
|
|
133
133
|
# corners is a list of marker corners, one element per tag
|
|
134
134
|
# each element is 1x4x2 matrix holding corner coordinates
|
|
135
135
|
#
|
|
136
|
-
#
|
|
136
|
+
# --- OpenCV 4/5 compat -------------------------------------------
|
|
137
|
+
# ids is (N,1) on OpenCV 4 but a flat (N,) on OpenCV 5 (verified
|
|
138
|
+
# empirically 2026-08-03). Flatten so each `id` below is always a
|
|
139
|
+
# plain scalar -- on OpenCV 5, id[0] on an already-0-d element
|
|
140
|
+
# raises IndexError: invalid index to scalar variable.
|
|
141
|
+
# -------------------------------------------------------------------
|
|
137
142
|
|
|
138
143
|
fiducials = []
|
|
139
144
|
if ids is None or len(ids) == 0:
|
|
140
145
|
return fiducials # no markers found
|
|
146
|
+
ids = ids.reshape(-1)
|
|
141
147
|
if K is not None and side is not None:
|
|
142
|
-
|
|
143
|
-
|
|
148
|
+
# --- OpenCV 4/5 compat ---------------------------------------
|
|
149
|
+
# cv2.aruco.estimatePoseSingleMarkers was removed entirely in
|
|
150
|
+
# OpenCV 5 -- not moved/renamed, hasattr(cv2.aruco,
|
|
151
|
+
# "estimatePoseSingleMarkers") is False in a real 5.0.0
|
|
152
|
+
# install. Replaced with the per-marker cv2.solvePnP +
|
|
153
|
+
# SOLVEPNP_IPPE_SQUARE call that OpenCV's own migration notes
|
|
154
|
+
# recommend for planar square markers. The object points use
|
|
155
|
+
# the same convention (clockwise from top-left, Z out of the
|
|
156
|
+
# marker) the old function used internally, so this works
|
|
157
|
+
# unchanged on OpenCV 4 too -- no version branching needed.
|
|
158
|
+
# Verified empirically 2026-08-03.
|
|
159
|
+
# -----------------------------------------------------------------
|
|
160
|
+
half = side / 2
|
|
161
|
+
obj_pts = np.array(
|
|
162
|
+
[
|
|
163
|
+
[-half, half, 0],
|
|
164
|
+
[half, half, 0],
|
|
165
|
+
[half, -half, 0],
|
|
166
|
+
[-half, -half, 0],
|
|
167
|
+
],
|
|
168
|
+
dtype=np.float32,
|
|
144
169
|
)
|
|
145
|
-
|
|
146
|
-
|
|
170
|
+
p3d = obj_pts.reshape(4, 1, 3)
|
|
171
|
+
for id, corners in zip(ids, cornerss):
|
|
172
|
+
_, rvec, tvec = cv2.solvePnP(
|
|
173
|
+
obj_pts, corners[0], K, None, flags=cv2.SOLVEPNP_IPPE_SQUARE
|
|
174
|
+
)
|
|
175
|
+
fiducials.append(
|
|
176
|
+
Fiducial(
|
|
177
|
+
id, corners[0].T, K, rvec.reshape(1, 3), tvec.reshape(1, 3), p3d
|
|
178
|
+
)
|
|
179
|
+
)
|
|
147
180
|
else:
|
|
148
181
|
for id, corners in zip(ids, cornerss):
|
|
149
|
-
fiducials.append(Fiducial(id
|
|
182
|
+
fiducials.append(Fiducial(id, corners[0].T))
|
|
150
183
|
|
|
151
184
|
return fiducials
|
|
152
185
|
|
|
@@ -386,16 +419,23 @@ class FiducialCollection:
|
|
|
386
419
|
cornerss, ids, rejected = detector.detectMarkers(image=image.mono()._A)
|
|
387
420
|
|
|
388
421
|
# corners is a list of ndarray(1,4,2) of marker corners
|
|
389
|
-
# ids is ndarray of shape (N,1) holding the tag ids
|
|
390
422
|
# rejected is a list of rejected corners from tags whose inner code is incorrectly coded
|
|
423
|
+
#
|
|
424
|
+
# --- OpenCV 4/5 compat -------------------------------------------
|
|
425
|
+
# ids is (N,1) on OpenCV 4 but a flat (N,) on OpenCV 5 (verified
|
|
426
|
+
# empirically 2026-08-03). Flatten so each `id` below is always a
|
|
427
|
+
# plain scalar -- on OpenCV 5, id[0] on an already-0-d element
|
|
428
|
+
# raises IndexError: invalid index to scalar variable.
|
|
429
|
+
# -------------------------------------------------------------------
|
|
430
|
+
ids = ids.reshape(-1) if ids is not None else ids
|
|
391
431
|
|
|
392
432
|
# filter tags by ID
|
|
393
433
|
cornerss = [
|
|
394
434
|
corners.T.squeeze()
|
|
395
435
|
for corners, id in zip(cornerss, ids)
|
|
396
|
-
if id
|
|
436
|
+
if id in self._ids
|
|
397
437
|
]
|
|
398
|
-
ids = [id
|
|
438
|
+
ids = [id for corners, id in zip(cornerss, ids) if id in self._ids]
|
|
399
439
|
# ids = np.reshape(ids, (-1, 1))
|
|
400
440
|
|
|
401
441
|
# match the markers to the board
|
|
@@ -587,9 +627,21 @@ class ArUcoBoard(FiducialCollection):
|
|
|
587
627
|
:return: 3D object points and 2D image points
|
|
588
628
|
:rtype: ndarray(M,1,3), ndarray(M,1,2), where M is the number of corners found (multiple of 4)
|
|
589
629
|
"""
|
|
590
|
-
|
|
630
|
+
objPoints, imgPoints = self._board.matchImagePoints(
|
|
591
631
|
[corners.T.reshape(1, 4, 2) for corners in cornerss], np.c_[ids]
|
|
592
632
|
)
|
|
633
|
+
# --- OpenCV 4/5 compat -------------------------------------------
|
|
634
|
+
# cv2.aruco.Board.matchImagePoints returns (M,1,3)/(M,1,2) on
|
|
635
|
+
# OpenCV 4 but a true (M,3)/(M,2) on OpenCV 5 (verified
|
|
636
|
+
# empirically 2026-08-03). Normalize back to the documented
|
|
637
|
+
# (M,1,3)/(M,1,2) shape so this method's return contract is
|
|
638
|
+
# stable regardless of the installed OpenCV version.
|
|
639
|
+
# -------------------------------------------------------------------
|
|
640
|
+
if objPoints.ndim == 2:
|
|
641
|
+
objPoints = objPoints[:, np.newaxis, :]
|
|
642
|
+
if imgPoints.ndim == 2:
|
|
643
|
+
imgPoints = imgPoints[:, np.newaxis, :]
|
|
644
|
+
return objPoints, imgPoints
|
|
593
645
|
|
|
594
646
|
def chart(self, filename: str | None = None, dpi: int = 100) -> Image | None:
|
|
595
647
|
"""Write ArUco chart to a file
|
machinevisiontoolbox/ImageIO.py
CHANGED
|
@@ -178,10 +178,14 @@ class ImageIOMixin(_ImageBase if TYPE_CHECKING else object):
|
|
|
178
178
|
self.domain[1][0],
|
|
179
179
|
]
|
|
180
180
|
|
|
181
|
+
if self.colororder_str is not None:
|
|
182
|
+
colororder = self.colororder_str.replace(":", "")
|
|
183
|
+
else:
|
|
184
|
+
colororder = None
|
|
181
185
|
return idisp(
|
|
182
186
|
self._A,
|
|
183
187
|
title=display_title,
|
|
184
|
-
colororder=
|
|
188
|
+
colororder=colororder,
|
|
185
189
|
**kwargs,
|
|
186
190
|
)
|
|
187
191
|
|
|
@@ -156,8 +156,16 @@ class HoughFeature:
|
|
|
156
156
|
)
|
|
157
157
|
if lines is None:
|
|
158
158
|
return np.zeros((0, 4))
|
|
159
|
-
|
|
159
|
+
# --- OpenCV 4/5 compat -----------------------------------------
|
|
160
|
+
# OpenCV 5's HoughLinesP returns a true (N,4) array; OpenCV 4
|
|
161
|
+
# returns (N,1,4) (the old std::vector<T>-wrapping convention).
|
|
162
|
+
# Verified empirically 2026-08-03 (opencv-contrib-python 5.0.0.93
|
|
163
|
+
# vs 4.13.0.92). Drop the singleton middle dim only if present.
|
|
164
|
+
# -----------------------------------------------------------------
|
|
165
|
+
elif lines.ndim == 3 and lines.shape[1] == 1:
|
|
160
166
|
return lines[:, 0, :]
|
|
167
|
+
else:
|
|
168
|
+
return lines
|
|
161
169
|
|
|
162
170
|
def plot_lines(self, lines: np.ndarray, *args: Any, **kwargs: Any) -> None:
|
|
163
171
|
r"""
|
|
@@ -3,7 +3,7 @@ Detection and matching of keypoint and descriptor features (SIFT, ORB, etc.) in
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import math
|
|
6
|
-
from typing import Any, Iterator
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Iterator
|
|
7
7
|
|
|
8
8
|
import cv2
|
|
9
9
|
import matplotlib.lines as mlines
|
|
@@ -21,13 +21,15 @@ from machinevisiontoolbox.base import (
|
|
|
21
21
|
name2color,
|
|
22
22
|
safe_plt_show,
|
|
23
23
|
)
|
|
24
|
-
from machinevisiontoolbox._image_typing import _ImageBase
|
|
25
24
|
from machinevisiontoolbox.decorators import (
|
|
26
25
|
array_result,
|
|
27
26
|
array_result2,
|
|
28
27
|
scalar_result,
|
|
29
28
|
)
|
|
30
29
|
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from machinevisiontoolbox._image_typing import _ImageBase
|
|
32
|
+
|
|
31
33
|
# TODO, either subclass SIFTFeature(BaseFeature2D) or just use BaseFeature2D
|
|
32
34
|
# directly
|
|
33
35
|
|
|
@@ -280,7 +282,7 @@ class BaseFeature2D:
|
|
|
280
282
|
|
|
281
283
|
try: # Sort features into grid
|
|
282
284
|
nw, nh = nbins
|
|
283
|
-
except:
|
|
285
|
+
except (TypeError, ValueError):
|
|
284
286
|
nw = nbins
|
|
285
287
|
nh = nbins
|
|
286
288
|
|
|
@@ -294,8 +296,8 @@ class BaseFeature2D:
|
|
|
294
296
|
bins = np.zeros((nh, nw), dtype="int")
|
|
295
297
|
|
|
296
298
|
for f in self:
|
|
297
|
-
ix = f.p[0] // binwidth
|
|
298
|
-
iy = f.p[1] // binheight
|
|
299
|
+
ix = int(f.p[0].item() // binwidth)
|
|
300
|
+
iy = int(f.p[1].item() // binheight)
|
|
299
301
|
|
|
300
302
|
if bins[iy, ix] < nfeat:
|
|
301
303
|
keep.append(f)
|
|
@@ -2012,7 +2014,26 @@ class LUCIDFeature(BaseFeature2D):
|
|
|
2012
2014
|
pass
|
|
2013
2015
|
|
|
2014
2016
|
|
|
2015
|
-
|
|
2017
|
+
# --- OpenCV 4/5 compat -------------------------------------------------
|
|
2018
|
+
# OpenCV 5 dropped BRISK/AKAZE/KAZE from the main cv2 namespace; they now
|
|
2019
|
+
# live under cv2.xfeatures2d (verified empirically 2026-08-02, opencv-
|
|
2020
|
+
# contrib-python 5.0.0.93 vs 4.13.0.92 -- no OPENCV_ENABLE_NONFREE build
|
|
2021
|
+
# flag needed for these, unlike SURF). SIFT/ORB are unaffected, stay in
|
|
2022
|
+
# main cv2 in both versions. Resolve by attribute existence, not a
|
|
2023
|
+
# version-string check, so this keeps working if a future OpenCV release
|
|
2024
|
+
# moves things back or a build has both.
|
|
2025
|
+
def _resolve_feature_create(name: str) -> Any:
|
|
2026
|
+
if hasattr(cv2, f"{name}_create"):
|
|
2027
|
+
return getattr(cv2, f"{name}_create")
|
|
2028
|
+
if hasattr(cv2, "xfeatures2d") and hasattr(cv2.xfeatures2d, f"{name}_create"):
|
|
2029
|
+
return getattr(cv2.xfeatures2d, f"{name}_create")
|
|
2030
|
+
raise AttributeError(f"{name}_create not found in cv2 or cv2.xfeatures2d")
|
|
2031
|
+
|
|
2032
|
+
|
|
2033
|
+
# -------------------------------------------------------------------------
|
|
2034
|
+
|
|
2035
|
+
|
|
2036
|
+
class ImagePointFeaturesMixin(_ImageBase if TYPE_CHECKING else object):
|
|
2016
2037
|
def _image2feature(
|
|
2017
2038
|
self,
|
|
2018
2039
|
cls,
|
|
@@ -2028,8 +2049,8 @@ class ImagePointFeaturesMixin(_ImageBase):
|
|
|
2028
2049
|
"SIFT": getattr(cv2, "SIFT_create"),
|
|
2029
2050
|
"ORB": getattr(cv2, "ORB_create"),
|
|
2030
2051
|
"Harris": _Harris_create,
|
|
2031
|
-
"BRISK":
|
|
2032
|
-
"AKAZE":
|
|
2052
|
+
"BRISK": _resolve_feature_create("BRISK"),
|
|
2053
|
+
"AKAZE": _resolve_feature_create("AKAZE"),
|
|
2033
2054
|
# 'FREAK': (cv2.FREAK_create, FREAKFeature),
|
|
2034
2055
|
# 'DAISY': (cv2.DAISY_create, DAISYFeature),
|
|
2035
2056
|
}
|
|
@@ -1146,7 +1146,7 @@ class ImageProcessingMixin(_ImageBase if TYPE_CHECKING else object):
|
|
|
1146
1146
|
else:
|
|
1147
1147
|
try:
|
|
1148
1148
|
color = argcheck.getvector(image2, 3)
|
|
1149
|
-
except:
|
|
1149
|
+
except Exception:
|
|
1150
1150
|
raise ValueError("expecting a scalar, string or 3-vector")
|
|
1151
1151
|
if self.isbgr:
|
|
1152
1152
|
color = color[::-1]
|
|
@@ -9,7 +9,7 @@ import matplotlib.pyplot as plt
|
|
|
9
9
|
import numpy as np
|
|
10
10
|
from ansitable import ANSITable, Column
|
|
11
11
|
from spatialmath import SE3
|
|
12
|
-
from typing import Any
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
13
|
|
|
14
14
|
from machinevisiontoolbox.decorators import array_result
|
|
15
15
|
from machinevisiontoolbox.base import plot_labelbox
|
|
@@ -22,8 +22,11 @@ except ImportError:
|
|
|
22
22
|
_pytesseract = None
|
|
23
23
|
_pytesseract_available = False
|
|
24
24
|
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from machinevisiontoolbox._image_typing import _ImageBase
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
|
|
29
|
+
class ImageRegionFeaturesMixin(_ImageBase if TYPE_CHECKING else object):
|
|
27
30
|
def MSER(self, **kwargs: Any) -> "MSERFeature":
|
|
28
31
|
"""
|
|
29
32
|
Find MSER features in image
|
|
@@ -170,6 +173,22 @@ class MSERFeature:
|
|
|
170
173
|
self._points = [
|
|
171
174
|
mser.T for mser in msers
|
|
172
175
|
] # transpose point arrays to be Nx2
|
|
176
|
+
# --- OpenCV 4/5 compat ---------------------------------------
|
|
177
|
+
# When no regions are found, OpenCV 5's detectRegions() returns
|
|
178
|
+
# an empty tuple () for bboxes instead of an empty ndarray(0,4)
|
|
179
|
+
# -- bboxes[:, 2:] then raises TypeError: tuple indices must be
|
|
180
|
+
# integers or slices, not tuple. Normalize first.
|
|
181
|
+
#
|
|
182
|
+
# Separately (not a shape issue, not fixable here): verified
|
|
183
|
+
# empirically 2026-08-03 that with identical default parameters
|
|
184
|
+
# and identical grayscale input, OpenCV 5's MSER finds fewer or
|
|
185
|
+
# zero regions compared to OpenCV 4 on real photos -- an
|
|
186
|
+
# upstream algorithm behavior change, not an API surface
|
|
187
|
+
# change. Logged as its own issue (#54), not something this
|
|
188
|
+
# compat fix can address.
|
|
189
|
+
# -----------------------------------------------------------------
|
|
190
|
+
if len(bboxes) == 0:
|
|
191
|
+
bboxes = np.zeros((0, 4), dtype=int)
|
|
173
192
|
bboxes[:, 2:] = bboxes[:, 0:2] + bboxes[:, 2:] # convert to lrtb
|
|
174
193
|
self._bboxes = bboxes
|
|
175
194
|
|
|
@@ -239,7 +258,7 @@ class MSERFeature:
|
|
|
239
258
|
|
|
240
259
|
return new
|
|
241
260
|
|
|
242
|
-
def __str__(self):
|
|
261
|
+
def __str__(self) -> str:
|
|
243
262
|
"""
|
|
244
263
|
String representation of MSER
|
|
245
264
|
|
|
@@ -262,7 +281,7 @@ class MSERFeature:
|
|
|
262
281
|
s = f"MSER feature: u: {self._bboxes[0,0]} - {self._bboxes[0,2]}, v: {self._bboxes[0,1]} - {self._bboxes[0,3]}"
|
|
263
282
|
return s
|
|
264
283
|
|
|
265
|
-
def __repr__(self):
|
|
284
|
+
def __repr__(self) -> str:
|
|
266
285
|
"""
|
|
267
286
|
Representation of MSER
|
|
268
287
|
|
|
@@ -370,7 +389,7 @@ class OCRWord:
|
|
|
370
389
|
for key in ocr.keys():
|
|
371
390
|
self.dict[key] = ocr[key][i]
|
|
372
391
|
|
|
373
|
-
def __str__(self):
|
|
392
|
+
def __str__(self) -> str:
|
|
374
393
|
"""
|
|
375
394
|
String representation of MSER
|
|
376
395
|
|
|
@@ -379,7 +398,7 @@ class OCRWord:
|
|
|
379
398
|
"""
|
|
380
399
|
return f"{self.dict['text']} ({self.dict['conf']}%)"
|
|
381
400
|
|
|
382
|
-
def __repr__(self):
|
|
401
|
+
def __repr__(self) -> str:
|
|
383
402
|
return str(self)
|
|
384
403
|
|
|
385
404
|
@property
|