machinevision-toolbox-python 2.3.0__py3-none-any.whl → 2.4.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.
@@ -350,7 +350,8 @@ class CameraBase(ABC):
350
350
 
351
351
  :seealso: :meth:`nv` :meth:`width` :meth:`imagesize`
352
352
  """
353
- assert self._imagesize is not None, "imagesize not set"
353
+ if self._imagesize is None:
354
+ raise ValueError("imagesize not set")
354
355
  return self._imagesize[0]
355
356
 
356
357
  @property
@@ -370,7 +371,8 @@ class CameraBase(ABC):
370
371
 
371
372
  :seealso: :meth:`nu` :meth:`height` :meth:`imagesize`
372
373
  """
373
- assert self._imagesize is not None, "imagesize not set"
374
+ if self._imagesize is None:
375
+ raise ValueError("imagesize not set")
374
376
  return self._imagesize[1]
375
377
 
376
378
  @property
@@ -390,7 +392,8 @@ class CameraBase(ABC):
390
392
 
391
393
  :seealso: :meth:`nu` :meth:`height`
392
394
  """
393
- assert self._imagesize is not None, "imagesize not set"
395
+ if self._imagesize is None:
396
+ raise ValueError("imagesize not set")
394
397
  return self._imagesize[0]
395
398
 
396
399
  @property
@@ -410,7 +413,8 @@ class CameraBase(ABC):
410
413
 
411
414
  :seealso: :meth:`nv` :meth:`width`
412
415
  """
413
- assert self._imagesize is not None, "imagesize not set"
416
+ if self._imagesize is None:
417
+ raise ValueError("imagesize not set")
414
418
  return self._imagesize[1]
415
419
 
416
420
  @property
@@ -1152,7 +1156,7 @@ class CameraBase(ABC):
1152
1156
  """
1153
1157
  Plot 3D camera icon in world view (base method)
1154
1158
 
1155
- :param pose: camera pose
1159
+ :param pose: camera pose, required when called unbound (see below)
1156
1160
  :type pose: :class:`~spatialmath.pose3d.SE3`
1157
1161
  :param scale: scale factor, defaults to 1
1158
1162
  :type scale: float
@@ -1170,6 +1174,8 @@ class CameraBase(ABC):
1170
1174
  :type projection: str, optional
1171
1175
  :param ax: axes to draw in, defaults to current 3D axes
1172
1176
  :type ax: :class:`~matplotlib.Axes3D`, optional
1177
+ :raises ValueError: called unbound (eg. ``CentralCamera.plot(...)``)
1178
+ without a ``pose``
1173
1179
  :return: axes drawn into
1174
1180
  :rtype: :class:`~matplotlib.Axes3D`
1175
1181
 
@@ -1177,8 +1183,19 @@ class CameraBase(ABC):
1177
1183
  plot. Two icons are supported: the traditional frustum, and a
1178
1184
  simplistic camera comprising a box and cylinder.
1179
1185
 
1180
- .. note:: If ``pose`` is not given it defaults to the pose of the
1181
- instance.
1186
+ This method can be called two ways:
1187
+
1188
+ - **Bound**, ``camera.plot(...)`` -- draws an icon for ``camera``.
1189
+ ``pose`` defaults to the instance's own pose if not given.
1190
+ - **Unbound**, ``CentralCamera.plot(pose=..., ...)`` -- called on
1191
+ the class itself, with no camera instance involved (``self`` is
1192
+ ``None``). Useful for drawing icons at a set of poses that don't
1193
+ correspond to any single camera instance -- e.g. plotting the
1194
+ calibration frames returned by :meth:`images2C`. ``pose`` is
1195
+ required in this form.
1196
+
1197
+ .. note:: If ``pose`` is not given, it defaults to the pose of
1198
+ ``self`` -- this only applies to the bound form.
1182
1199
  """
1183
1200
 
1184
1201
  # if (fig is None) and (ax is None):
@@ -1195,6 +1212,11 @@ class CameraBase(ABC):
1195
1212
  ax = smb.axes_logic(ax, 3, projection=projection)
1196
1213
 
1197
1214
  if pose is None:
1215
+ if self is None:
1216
+ raise ValueError(
1217
+ "pose is required when plot() is called unbound, eg. "
1218
+ "CentralCamera.plot(pose=...)"
1219
+ )
1198
1220
  pose = self.pose
1199
1221
 
1200
1222
  # Matplotlib >=3.10 can raise while autoscaling ragged 3D line
@@ -1216,26 +1238,27 @@ class CameraBase(ABC):
1216
1238
  try:
1217
1239
  # draw camera-like object:
1218
1240
  if shape == "frustum":
1219
- self._plot_frustum(ax, pose, scale, alpha)
1241
+ CameraBase._plot_frustum(ax, pose, scale, alpha)
1220
1242
 
1221
1243
  elif shape == "camera":
1222
- self._plot_camera_icon(ax, pose, scale, solid, color, alpha, label)
1244
+ CameraBase._plot_camera_icon(ax, pose, scale, solid, color, alpha, label)
1223
1245
 
1224
1246
  if frame is True:
1225
- self.pose.plot(
1247
+ pose.plot(
1226
1248
  length=scale * 1.5,
1227
1249
  style="line",
1228
1250
  color=color,
1229
1251
  flo=(0.07, 0, -0.01),
1230
1252
  )
1231
1253
  elif frame is not False:
1232
- self.pose.plot(**frame)
1254
+ pose.plot(**frame)
1233
1255
  finally:
1234
1256
  ax.add_collection3d = _orig_add_collection3d
1235
1257
 
1236
1258
  return ax
1237
1259
 
1238
- def _plot_frustum(self, ax: Axes, pose: SE3, scale: float, alpha: float) -> None:
1260
+ @staticmethod
1261
+ def _plot_frustum(ax: Axes, pose: SE3, scale: float, alpha: float) -> None:
1239
1262
  """Draw the ``shape="frustum"`` camera icon into ``ax``."""
1240
1263
  # TODO make this kwargs or optional inputs
1241
1264
  # side colors:
@@ -1279,8 +1302,8 @@ class CameraBase(ABC):
1279
1302
  poly = Poly3DCollection(points, facecolors=["r", "g", "r", "y"], alpha=alpha)
1280
1303
  ax.add_collection3d(poly)
1281
1304
 
1305
+ @staticmethod
1282
1306
  def _plot_camera_icon(
1283
- self,
1284
1307
  ax: Axes,
1285
1308
  pose: SE3,
1286
1309
  scale: float,
@@ -2684,8 +2707,41 @@ class CentralCamera(CameraBase):
2684
2707
  :type p2: ndarray(2,N)
2685
2708
  :param method: algorithm '7p', '8p' [default], 'ransac', 'lmeds'
2686
2709
  :type method: str, optional
2687
- :param kwargs: optional arguments as required for ransac', 'lmeds'
2688
- methods
2710
+ :param residual: also return the reprojection residual, defaults to True
2711
+ :type residual: bool, optional
2712
+ :param seed: seed the RNG used by 'ransac'/'lmeds' for repeatable
2713
+ results, defaults to None (not seeded)
2714
+ :type seed: int, optional
2715
+ :param kwargs: passed through to ``cv2.findFundamentalMat``.
2716
+ ``ransacReprojThreshold`` (float, defaults to 3.0) applies only
2717
+ when ``method='ransac'`` -- max distance in pixels from a point
2718
+ to its epipolar line for the point to still count as an inlier;
2719
+ ``method='lmeds'`` ignores it entirely (verified empirically --
2720
+ identical result regardless of value). ``confidence`` (float in
2721
+ (0,1), defaults to 0.99) and ``maxIters`` (int, defaults to
2722
+ 1000) apply to either ``method='ransac'`` or ``method='lmeds'``
2723
+ -- ``method`` only ever selects one algorithm at a time (a
2724
+ combined "ransac+lmeds" method isn't a real OpenCV feature;
2725
+ verified empirically that OR-ing the two method flags together
2726
+ doesn't error, but silently reduces to plain LMedS -- an
2727
+ implementation accident, not a documented hybrid mode).
2728
+ Defaults verified empirically 2026-08-16 (identical inlier
2729
+ masks with/without them explicit, same RNG seed) -- not
2730
+ otherwise documented by the ``cv2`` Python bindings. Same
2731
+ parameter set and defaults on OpenCV 4.10 and 5.0.
2732
+
2733
+ .. warning:: ``maxIters`` only works when ``ransacReprojThreshold``
2734
+ and ``confidence`` are *also* given explicitly -- passing
2735
+ only ``maxIters=`` hits a ``cv2.findFundamentalMat``
2736
+ overload-resolution failure (a confusing low-level OpenCV
2737
+ error, not a clear Python one), since the 3-argument
2738
+ overload this method otherwise uses has no ``maxIters``
2739
+ parameter at all.
2740
+ :type kwargs: dict, optional
2741
+ :raises ValueError: fewer than the minimum number of point
2742
+ correspondences for ``method`` (7 for '7p', 8 otherwise), or
2743
+ the points are too degenerate (eg. coplanar, collinear) for
2744
+ ``cv2.findFundamentalMat`` to estimate F from
2689
2745
  :return: fundamental matrix and residual
2690
2746
  :rtype: ndarray(3,3), float
2691
2747
 
@@ -2719,13 +2775,40 @@ class CentralCamera(CameraBase):
2719
2775
  "ransac": cv2.FM_RANSAC,
2720
2776
  "lmeds": cv2.FM_LMEDS,
2721
2777
  }
2778
+ min_points = 7 if method == "7p" else 8
2779
+ if p1.shape[1] < min_points:
2780
+ raise ValueError(
2781
+ f"points2F() needs at least {min_points} point correspondences "
2782
+ f"for method={method!r}, got {p1.shape[1]}"
2783
+ )
2722
2784
 
2723
2785
  if seed is not None:
2724
2786
  cv2.setRNGSeed(seed)
2725
2787
 
2726
- F, mask = cv2.findFundamentalMat(
2727
- points1=p1.T, points2=p2.T, method=points2F_dict[method], **kwargs
2728
- )
2788
+ try:
2789
+ F, mask = cv2.findFundamentalMat(
2790
+ points1=p1.T, points2=p2.T, method=points2F_dict[method], **kwargs
2791
+ )
2792
+ except cv2.error as e:
2793
+ # on some degenerate point configurations cv2.findFundamentalMat
2794
+ # raises a raw C++ assertion (eg. a Mat rowRange error) instead
2795
+ # of returning None -- same underlying cause as the F is None
2796
+ # case below, just surfaced differently by OpenCV
2797
+ raise ValueError(
2798
+ f"cv2.findFundamentalMat could not estimate F from "
2799
+ f"{p1.shape[1]} point correspondences using method={method!r} "
2800
+ f"-- points are likely too degenerate (eg. coplanar, collinear): {e}"
2801
+ ) from e
2802
+
2803
+ if F is None or mask is None:
2804
+ # already know p1.shape[1] >= min_points (checked above), so a
2805
+ # failure here means the points themselves are degenerate (eg.
2806
+ # coplanar, collinear), not a raw-count problem
2807
+ raise ValueError(
2808
+ f"cv2.findFundamentalMat could not estimate F from "
2809
+ f"{p1.shape[1]} point correspondences using method={method!r} "
2810
+ "-- points are likely too degenerate (eg. coplanar, collinear)"
2811
+ )
2729
2812
 
2730
2813
  mask = mask.ravel().astype(bool)
2731
2814
 
@@ -23,7 +23,7 @@ from spatialmath import base as smb
23
23
  from spatialmath.base import islistof, isscalar
24
24
 
25
25
  # from numpy.lib.arraysetops import isin
26
- from machinevisiontoolbox.base import float_image, int_image, name2color
26
+ from machinevisiontoolbox.base import DTYPE_ALIASES, float_image, int_image, name2color
27
27
  from machinevisiontoolbox.base.imageio import convert, idisp, iread, iwrite
28
28
  from machinevisiontoolbox.Kernel import Kernel
29
29
  from machinevisiontoolbox.mvtb_types import Dtype
@@ -110,6 +110,9 @@ def _resolve_pattern_options(
110
110
  else:
111
111
  dtype = default_dtype
112
112
 
113
+ if isinstance(dtype, str):
114
+ dtype = DTYPE_ALIASES.get(dtype, dtype)
115
+
113
116
  if colororder is None and like is not None and like.iscolor:
114
117
  colororder = like.colororder_str.replace(":", "")
115
118
 
@@ -121,7 +124,7 @@ def _pattern_image(cls, image: np.ndarray, colororder: str | None):
121
124
  image = np.repeat(
122
125
  image[..., np.newaxis], len(cls.colordict(colororder)), axis=2
123
126
  )
124
- return cls(image, colororder=colororder)
127
+ return cls(image, colororder=colororder, dtype=True)
125
128
 
126
129
 
127
130
  class ImageConstantsMixin(_ImageBase if TYPE_CHECKING else object):
@@ -145,6 +148,8 @@ class ImageConstantsMixin(_ImageBase if TYPE_CHECKING else object):
145
148
  :type colororder: str
146
149
  :param dtype: NumPy datatype, defaults to 'uint8'
147
150
  :type dtype: str or NumPy dtype, optional
151
+
152
+ |dtype_aliases|
148
153
  :param like: template image supplying default ``size`` and ``colororder``
149
154
  when those are not given explicitly
150
155
  :type like: :class:`Image` or None, optional
@@ -179,7 +184,7 @@ class ImageConstantsMixin(_ImageBase if TYPE_CHECKING else object):
179
184
  )
180
185
 
181
186
  shape = _getshape(cls, None, None, colororder, size)
182
- return cls(np.zeros(shape, dtype=dtype), colororder=colororder)
187
+ return cls(np.zeros(shape, dtype=dtype), colororder=colororder, dtype=True)
183
188
 
184
189
  @classmethod
185
190
  def Constant(
@@ -202,6 +207,8 @@ class ImageConstantsMixin(_ImageBase if TYPE_CHECKING else object):
202
207
  :type colororder: str
203
208
  :param dtype: NumPy datatype, defaults to 'uint8'
204
209
  :type dtype: str or NumPy dtype, optional
210
+
211
+ |dtype_aliases|
205
212
  :param like: template image supplying default ``size``, ``dtype`` and
206
213
  ``colororder`` when those are not given explicitly
207
214
  :type like: :class:`Image` or None, optional
@@ -258,11 +265,11 @@ class ImageConstantsMixin(_ImageBase if TYPE_CHECKING else object):
258
265
  planes = []
259
266
  for bg in value:
260
267
  planes.append(np.full(shape[:2], bg, dtype=dtype))
261
- return cls(np.stack(planes, axis=2), colororder=colororder)
268
+ return cls(np.stack(planes, axis=2), colororder=colororder, dtype=True)
262
269
 
263
270
  else:
264
271
  # scalar
265
- return cls(np.full(shape, value, dtype=dtype))
272
+ return cls(np.full(shape, value, dtype=dtype), dtype=True)
266
273
 
267
274
  @classmethod
268
275
  def String(
@@ -406,6 +413,8 @@ class ImageConstantsMixin(_ImageBase if TYPE_CHECKING else object):
406
413
  :type colororder: str
407
414
  :param dtype: NumPy datatype, defaults to 'uint8'
408
415
  :type dtype: str or NumPy dtype, optional
416
+
417
+ |dtype_aliases|
409
418
  :param maxval: maximum value for random values, defaults to None
410
419
  :type maxval: same as ``dtype``, optional
411
420
  :param pdf: probability density function for pixel values, defaults to None
@@ -535,7 +544,7 @@ class ImageConstantsMixin(_ImageBase if TYPE_CHECKING else object):
535
544
  else:
536
545
  raise ValueError("pdf must be a 1D or 2D array")
537
546
 
538
- return cls(im, colororder=colororder)
547
+ return cls(im, colororder=colororder, dtype=True)
539
548
 
540
549
  @classmethod
541
550
  def Squares(
@@ -562,6 +571,8 @@ class ImageConstantsMixin(_ImageBase if TYPE_CHECKING else object):
562
571
  :type bg: int, optional
563
572
  :param dtype: NumPy datatype, defaults to 'uint8'
564
573
  :type dtype: str or NumPy dtype, optional
574
+
575
+ |dtype_aliases|
565
576
  :param colororder: color plane names for the output image, defaults to None
566
577
  :type colororder: str or None, optional
567
578
  :param like: template image supplying default ``size``, ``dtype`` and
@@ -654,6 +665,8 @@ class ImageConstantsMixin(_ImageBase if TYPE_CHECKING else object):
654
665
  :type bg: int, optional
655
666
  :param dtype: NumPy datatype, defaults to 'uint8'
656
667
  :type dtype: str or NumPy dtype, optional
668
+
669
+ |dtype_aliases|
657
670
  :param colororder: color plane names for the output image, defaults to None
658
671
  :type colororder: str or None, optional
659
672
  :param like: template image supplying default ``size``, ``dtype`` and
@@ -746,6 +759,8 @@ class ImageConstantsMixin(_ImageBase if TYPE_CHECKING else object):
746
759
  :type cycles: int, optional
747
760
  :param dtype: NumPy datatype, defaults to 'float32'
748
761
  :type dtype: str or NumPy dtype, optional
762
+
763
+ |dtype_aliases|
749
764
  :param colororder: color plane names for the output image, defaults to None
750
765
  :type colororder: str or None, optional
751
766
  :param like: template image supplying default ``size``, ``dtype`` and
@@ -842,6 +857,8 @@ class ImageConstantsMixin(_ImageBase if TYPE_CHECKING else object):
842
857
  :type cycles: int, optional
843
858
  :param dtype: NumPy datatype, defaults to 'float32'
844
859
  :type dtype: str or NumPy dtype, optional
860
+
861
+ |dtype_aliases|
845
862
  :param colororder: color plane names for the output image, defaults to None
846
863
  :type colororder: str or None, optional
847
864
  :param like: template image supplying default ``size``, ``dtype`` and
@@ -922,6 +939,8 @@ class ImageConstantsMixin(_ImageBase if TYPE_CHECKING else object):
922
939
  :type square: int, optional
923
940
  :param dtype: image data type, defaults to "uint8"
924
941
  :type dtype: str or NumPy dtype, optional
942
+
943
+ |dtype_aliases|
925
944
  :param colororder: color plane names for the output image, defaults to None
926
945
  :type colororder: str or None, optional
927
946
  :param like: template image supplying default ``size``, ``dtype`` and
@@ -1012,6 +1031,8 @@ class ImageConstantsMixin(_ImageBase if TYPE_CHECKING else object):
1012
1031
  :type shift: int, optional
1013
1032
  :param dtype: image data type, defaults to "uint8"
1014
1033
  :type dtype: str or NumPy dtype, optional
1034
+
1035
+ |dtype_aliases|
1015
1036
  :param colororder: color plane names for the output image, defaults to None
1016
1037
  :type colororder: str or None, optional
1017
1038
  :param like: template image supplying default ``size``, ``dtype`` and
@@ -20,6 +20,7 @@ from spatialmath.base import islistof, isscalar
20
20
 
21
21
  # from numpy.lib.arraysetops import isin
22
22
  from machinevisiontoolbox.base import (
23
+ DTYPE_ALIASES,
23
24
  draw_box,
24
25
  draw_circle,
25
26
  draw_labelbox,
@@ -98,8 +99,13 @@ class Image(
98
99
  :type copy: bool, optional
99
100
  :param size: new size for the image, defaults to None
100
101
  :type size: tuple, optional
101
- :param dtype: data type for image, defaults to same type as ``image``
102
- :type dtype: str or NumPy dtype, optional
102
+ :param dtype: data type for image; ``None`` [default] auto-detects (any
103
+ floating input becomes ``float32``; integer input becomes the
104
+ smallest unsigned/signed integer type that holds all its values);
105
+ ``True`` keeps ``image``'s own dtype as-is; ``False`` raises
106
+ ``ValueError``; otherwise a NumPy dtype string (``"uint8"``,
107
+ ``"float32"``, ...) or NumPy type. |dtype_aliases|
108
+ :type dtype: str, NumPy dtype, bool, or None, optional
103
109
  :param name: name of image, defaults to None
104
110
  :type name: str, optional
105
111
  :param id: numeric id of image, typically a sequence number, defaults to None
@@ -301,13 +307,22 @@ class Image(
301
307
  )
302
308
 
303
309
  # if dtype is not given, determine the appropriate type for the data
310
+ dtype_given = dtype is not None
304
311
  dtype = self._infer_dtype(image, dtype)
305
312
 
306
313
  if binary:
307
314
  image = image > 0
308
315
 
309
- # change type of array to the determined dtype
310
- if dtype is not None:
316
+ # Change type of array to the determined dtype -- unless the caller
317
+ # also passed convert()-only options (eg. maxintval) alongside an
318
+ # explicit dtype=, in which case defer the cast to convert() below.
319
+ # convert()'s int_image()/float_image() know how to *scale* a value
320
+ # using maxintval; a bare .astype() here would instead silently
321
+ # truncate (eg. uint16 0..4095 -> uint8 keeps only the low byte,
322
+ # producing a corrupted "bottom byte" image instead of the intended
323
+ # rescaled 0..255).
324
+ defer_dtype_to_convert = dtype_given and bool(kwargs)
325
+ if dtype is not None and not defer_dtype_to_convert:
311
326
  image = image.astype(dtype, copy=False)
312
327
 
313
328
  self.name = name
@@ -324,7 +339,7 @@ class Image(
324
339
  image = image[:, :, 0] # squeeze out singleton plane
325
340
 
326
341
  if kwargs:
327
- image = convert(image, **kwargs)
342
+ image = convert(image, dtype=dtype if defer_dtype_to_convert else None, **kwargs)
328
343
 
329
344
  # assign the image to the object, copying if requested
330
345
  if copy:
@@ -381,6 +396,8 @@ class Image(
381
396
  raise ValueError("bad dtype argument passed to Image constructor")
382
397
  else:
383
398
  # dtype is given, convert to a NumPy dtype
399
+ if isinstance(dtype, str):
400
+ dtype = DTYPE_ALIASES.get(dtype, dtype)
384
401
  try:
385
402
  dtype = np.dtype(dtype)
386
403
  except TypeError:
@@ -475,6 +492,37 @@ class Image(
475
492
  """Iterate over image planes."""
476
493
  return self.planes()
477
494
 
495
+ def __array__(self, dtype: Dtype | None = None, copy: bool | None = None) -> np.ndarray:
496
+ """
497
+ Convert to a plain NumPy array
498
+
499
+ :param dtype: dtype of the returned array, defaults to the image's own dtype
500
+ :type dtype: numpy dtype, optional
501
+ :param copy: force a copy of the underlying data, defaults to None
502
+ :type copy: bool, optional
503
+ :return: image pixel data
504
+ :rtype: ndarray(H,W) or ndarray(H,W,3)
505
+
506
+ Implements the NumPy array protocol, used by ``np.asarray(img)`` and by
507
+ any NumPy/SciPy/Matplotlib function that isn't ufunc- or array-function-
508
+ aware (those instead go through :meth:`__array_ufunc__` /
509
+ :meth:`__array_function__`) and calls ``np.asarray()`` internally on
510
+ whatever it's given. Without this, such a call would silently coerce an
511
+ :class:`Image` into a useless 0-d object array instead of its pixel data.
512
+
513
+ Returns the same read-only view as the :attr:`array` property unless a
514
+ dtype conversion or an explicit copy is requested, either of which
515
+ produces a new, independent (writeable) array.
516
+
517
+ :seealso: :attr:`array`
518
+ """
519
+ arr = self.array
520
+ if dtype is not None:
521
+ arr = arr.astype(dtype)
522
+ if copy:
523
+ arr = arr.copy()
524
+ return arr
525
+
478
526
  def __array_ufunc__(self, ufunc, method: str, *inputs, **kwargs):
479
527
  """
480
528
  Support NumPy ufunc calls on image data.
@@ -1927,6 +1975,9 @@ class Image(
1927
1975
 
1928
1976
  :param dtype: Numpy data type
1929
1977
  :type dtype: str or NumPy dtype
1978
+
1979
+ |dtype_aliases|
1980
+
1930
1981
  :return: image
1931
1982
  :rtype: :class:`Image`
1932
1983
 
@@ -1949,8 +2000,9 @@ class Image(
1949
2000
  :seealso: :meth:`astype` :meth:`to_int` :meth:`to_float`
1950
2001
  """
1951
2002
  # convert image to different type, does rescaling
1952
- # as just changes type
1953
- dtype = np.dtype(dtype) # convert to dtype if it's a string
2003
+ # as just changes type. array_as() and the constructor both
2004
+ # resolve dtype (including the short-name aliases) themselves,
2005
+ # so just pass it through unchanged rather than resolving twice.
1954
2006
  return self.__class__(self.array_as(dtype), dtype=dtype)
1955
2007
 
1956
2008
  def astype(self, dtype: Dtype) -> "Image":
@@ -1959,6 +2011,9 @@ class Image(
1959
2011
 
1960
2012
  :param dtype: Numpy data type
1961
2013
  :type dtype: str or NumPy dtype
2014
+
2015
+ |dtype_aliases|
2016
+
1962
2017
  :return: image
1963
2018
  :rtype: :class:`Image`
1964
2019
 
@@ -1979,6 +2034,8 @@ class Image(
1979
2034
 
1980
2035
  :seealso: :meth:`to`
1981
2036
  """
2037
+ if isinstance(dtype, str):
2038
+ dtype = DTYPE_ALIASES.get(dtype, dtype)
1982
2039
  return self.__class__(self._A.astype(dtype), dtype=dtype)
1983
2040
 
1984
2041
  # ---- NumPy array access ---- #
@@ -2254,7 +2311,9 @@ class Image(
2254
2311
  Convert Image to NumPy array of specified type
2255
2312
 
2256
2313
  :param dtype: data type of the output array
2257
- :type dtype: str or np.dtype
2314
+ :type dtype: str or np.dtype, optional
2315
+
2316
+ |dtype_aliases|
2258
2317
  :return: NumPy array with specified type
2259
2318
  :rtype: ndarray(H,W) or ndarray(H,W,P)
2260
2319
 
@@ -2296,6 +2355,8 @@ class Image(
2296
2355
  if dtype is None:
2297
2356
  return self._A
2298
2357
  else:
2358
+ if isinstance(dtype, str):
2359
+ dtype = DTYPE_ALIASES.get(dtype, dtype)
2299
2360
  dtype = np.dtype(dtype) # convert to dtype if it's a string
2300
2361
  if np.issubdtype(dtype, np.integer) or np.issubdtype(dtype, np.bool_):
2301
2362
  return int_image(self._A, dtype)
@@ -429,14 +429,22 @@ class FiducialCollection:
429
429
  # -------------------------------------------------------------------
430
430
  ids = ids.reshape(-1) if ids is not None else ids
431
431
 
432
- # filter tags by ID
433
- cornerss = [
434
- corners.T.squeeze()
435
- for corners, id in zip(cornerss, ids)
436
- if id in self._ids
437
- ]
438
- ids = [id for corners, id in zip(cornerss, ids) if id in self._ids]
439
- # ids = np.reshape(ids, (-1, 1))
432
+ # filter tags by ID, keeping each marker's corners and id together in
433
+ # a single pass so the two lists cannot get out of step (detectMarkers
434
+ # returns ids=None when it finds nothing)
435
+ matches = (
436
+ []
437
+ if ids is None
438
+ else [
439
+ (corners.T.squeeze(), id)
440
+ for corners, id in zip(cornerss, ids)
441
+ if id in self._ids
442
+ ]
443
+ )
444
+ if not matches:
445
+ raise ValueError("no markers belonging to this board were detected")
446
+ cornerss = [corners for corners, _ in matches]
447
+ ids = [id for _, id in matches]
440
448
 
441
449
  # match the markers to the board
442
450
  # print(f"{len(ids)} markers found")
@@ -267,6 +267,14 @@ class ImageIOMixin(_ImageBase if TYPE_CHECKING else object):
267
267
  # map tag number to tag name
268
268
  exif[TAGS[tag]] = value
269
269
 
270
+ # Camera-specific tags (FocalLength, ExposureTime, FNumber,
271
+ # ISOSpeedRatings, LensModel, ...) live in a separate "Exif" sub-IFD
272
+ # that Image.getexif() does not flatten into the top-level dict --
273
+ # 0x8769 is the fixed EXIF-spec tag id for the pointer to it.
274
+ for tag, value in meta.get_ifd(0x8769).items():
275
+ if tag in TAGS:
276
+ exif[TAGS[tag]] = value
277
+
270
278
  if key is None:
271
279
  return exif
272
280
  else:
@@ -669,7 +669,7 @@ class BaseFeature2D:
669
669
  other: "BaseFeature2D",
670
670
  ratio: float = 0.75,
671
671
  crosscheck: bool = False,
672
- metric: str = "L2",
672
+ metric: str | None = None,
673
673
  sort: bool = True,
674
674
  top: int | None = None,
675
675
  thresh: float | None = None,
@@ -683,7 +683,9 @@ class BaseFeature2D:
683
683
  :type ratio: float, optional
684
684
  :param crosscheck: perform left-right cross check, defaults to False
685
685
  :type crosscheck: bool, optional
686
- :param metric: distance metric, one of: 'L1', 'L2' [default], 'hamming', 'hamming2'
686
+ :param metric: distance metric, one of: 'L1', 'L2', 'hamming', 'hamming2'.
687
+ Defaults to ``None``, which auto-selects 'hamming' for binary
688
+ descriptors (eg. ORB, BRISK, AKAZE) and 'L2' otherwise (eg. SIFT).
687
689
  :type metric: str, optional
688
690
  :param sort: sort features by strength, defaults to True
689
691
  :type sort: bool, optional
@@ -694,6 +696,12 @@ class BaseFeature2D:
694
696
  Return a match object that contains pairs of putative corresponding points.
695
697
  If ``crosscheck`` is True the ratio test is disabled
696
698
 
699
+ .. note:: Binary descriptors (eg. ORB, BRISK, AKAZE) are ``uint8``
700
+ arrays, for which 'L2' distance is close to meaningless -- it
701
+ will silently return very few matches rather than raising an
702
+ error, which is why this is auto-selected rather than left for
703
+ the caller to always remember.
704
+
697
705
  Example:
698
706
 
699
707
  .. runblock:: pycon
@@ -720,6 +728,9 @@ class BaseFeature2D:
720
728
  # sorting
721
729
  # return
722
730
 
731
+ if metric is None:
732
+ metric = "hamming" if self.descriptor.dtype == np.uint8 else "L2"
733
+
723
734
  metricdict = {
724
735
  "L1": cv2.NORM_L1,
725
736
  "L2": cv2.NORM_L2,
@@ -285,7 +285,7 @@ class ImageWholeFeaturesMixin(_ImageBase if TYPE_CHECKING else object):
285
285
  :seealso: :meth:`stats`
286
286
  """
287
287
 
288
- stats = self._stats
288
+ stats = self.stats
289
289
  if self.iscolor and self.colororder is not None:
290
290
  colororder = self.colororder
291
291
  for k in sorted(stats.keys(), key=lambda x: colororder[x]):
@@ -1210,7 +1210,9 @@ class Histogram:
1210
1210
 
1211
1211
  def plot(
1212
1212
  self,
1213
- type: str = "frequency",
1213
+ type: Literal[
1214
+ "frequency", "pdf", "probability", "cf", "cumulative", "cdf", "normalized", "ncdf"
1215
+ ] = "frequency",
1214
1216
  block: bool = False,
1215
1217
  filled: bool | None = None,
1216
1218
  stats: bool = True,
@@ -1227,8 +1229,9 @@ class Histogram:
1227
1229
  """
1228
1230
  Plot histogram
1229
1231
 
1230
- :param type: histogram type, one of: 'frequency' [default], 'cdf', 'ncdf'
1231
- :type type: str, optional
1232
+ :param type: histogram type, one of: 'frequency' [default], 'pdf'/'probability',
1233
+ 'cf'/'cumulative', 'cdf'/'normalized'; 'ncdf' is accepted as a deprecated
1234
+ alias for 'cdf'
1232
1235
  :param block: hold plot, defaults to False
1233
1236
  :type block: bool, optional
1234
1237
  :param filled: use a filled stairs plot, defaults to True for frequency plot, False for
@@ -1258,7 +1261,9 @@ class Histogram:
1258
1261
 
1259
1262
  Plots the histogram using Matplotlib. For a color image, the histograms of each
1260
1263
  plane are plotted separately. The ``type`` option selects the type of histogram
1261
- to plot: ``frequency``, ``cdf`` or ``ncdf`` (normalized cumulative in the range 0 to 1).
1264
+ to plot: ``frequency``, ``pdf``/``probability``, ``cf``/``cumulative``, or
1265
+ ``cdf``/``normalized`` (normalized cumulative, range 0 to 1). ``ncdf`` is
1266
+ accepted as a deprecated alias for ``cdf``.
1262
1267
 
1263
1268
  The ``style`` option selects the style for plotting multiple planes:
1264
1269
 
@@ -1342,6 +1347,14 @@ class Histogram:
1342
1347
  if filled is None:
1343
1348
  filled = bar
1344
1349
 
1350
+ if type == "ncdf":
1351
+ warnings.warn(
1352
+ "Deprecated in 2.0.0: use type='cdf' instead of type='ncdf'.",
1353
+ DeprecationWarning,
1354
+ stacklevel=2,
1355
+ )
1356
+ type = "cdf"
1357
+
1345
1358
  if type not in ("frequency", "pdf"):
1346
1359
  stats = False # only show stats for frequency and pdf plot
1347
1360