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.
Files changed (34) hide show
  1. {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.2.0.dist-info}/METADATA +13 -5
  2. {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.2.0.dist-info}/RECORD +23 -33
  3. {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.2.0.dist-info}/WHEEL +1 -1
  4. machinevisiontoolbox/BundleAdjust.py +1 -1
  5. machinevisiontoolbox/Camera.py +95 -80
  6. machinevisiontoolbox/ImageBlobs.py +10 -4
  7. machinevisiontoolbox/ImageCore.py +105 -97
  8. machinevisiontoolbox/ImageFiducials.py +62 -10
  9. machinevisiontoolbox/ImageIO.py +5 -1
  10. machinevisiontoolbox/ImageLineFeatures.py +9 -1
  11. machinevisiontoolbox/ImagePointFeatures.py +29 -8
  12. machinevisiontoolbox/ImageProcessing.py +1 -1
  13. machinevisiontoolbox/ImageRegionFeatures.py +25 -6
  14. machinevisiontoolbox/ImageWholeFeatures.py +113 -72
  15. machinevisiontoolbox/Sources.py +1 -1
  16. machinevisiontoolbox/__init__.py +1 -1
  17. machinevisiontoolbox/base/imageio.py +68 -33
  18. machinevisiontoolbox/bin/_iminteractive.py +461 -0
  19. machinevisiontoolbox/bin/bagtool.py +120 -6
  20. machinevisiontoolbox/bin/imtool.py +34 -5
  21. machinevisiontoolbox/bin/mvtbtool.py +93 -18
  22. machinevisiontoolbox/ImageCore.docstring +0 -2877
  23. machinevisiontoolbox/aruco0.pdf +0 -0
  24. machinevisiontoolbox/aruco50.pdf +0 -0
  25. machinevisiontoolbox/bin/labeltool.py +0 -147
  26. machinevisiontoolbox/blocks/Icons/icons.sh +0 -4
  27. machinevisiontoolbox/cvfuncs.py +0 -40
  28. machinevisiontoolbox/docbugs.py +0 -47
  29. machinevisiontoolbox/newcameras.py +0 -55
  30. machinevisiontoolbox/test_skimage.py +0 -12
  31. machinevisiontoolbox/testblobplots.py +0 -17
  32. machinevisiontoolbox/x.json +0 -296
  33. {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.2.0.dist-info}/entry_points.txt +0 -0
  34. {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
- if binary:
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 self.colororder is not None:
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
- if image.ndim not in (2, 3):
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 = fixdims(out, self._A.shape, keys)
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 = fixdims(out, self._A.shape, keys)
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
- # ids is ndarray of shape (N,1) holding the tag ids
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
- rvecs, tvecs, p3d = cv2.aruco.estimatePoseSingleMarkers(
143
- corners=cornerss, markerLength=side, cameraMatrix=K, distCoeffs=None
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
- for id, rvec, tvec, corners in zip(ids, rvecs, tvecs, cornerss):
146
- fiducials.append(Fiducial(id[0], corners[0].T, K, rvec, tvec, p3d))
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[0], corners[0].T))
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[0] in self._ids
436
+ if id in self._ids
397
437
  ]
398
- ids = [id[0] for corners, id in zip(cornerss, ids) if id[0] in self._ids]
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
- return self._board.matchImagePoints(
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
@@ -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="RGB" if self.isrgb else "BGR",
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
- else:
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
- class ImagePointFeaturesMixin(_ImageBase):
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": getattr(cv2, "BRISK_create"),
2032
- "AKAZE": getattr(cv2, "AKAZE_create"),
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
- class ImageRegionFeaturesMixin:
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