machinevision-toolbox-python 2.3.0__py3-none-any.whl → 2.4.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.
@@ -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)
@@ -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
 
@@ -115,6 +115,35 @@ class Kernel:
115
115
  def shape(self) -> tuple[int, int]:
116
116
  return self.K.shape
117
117
 
118
+ @property
119
+ def ndim(self) -> int:
120
+ return 2
121
+
122
+ def __array__(self, dtype: Dtype | None = None, copy: bool | None = None) -> np.ndarray:
123
+ """
124
+ Convert to a plain NumPy array
125
+
126
+ :param dtype: dtype of the returned array, defaults to the kernel's own dtype
127
+ :type dtype: numpy dtype, optional
128
+ :param copy: force a copy of the underlying data, defaults to None
129
+ :type copy: bool, optional
130
+ :return: kernel weighting matrix
131
+ :rtype: ndarray(N,M)
132
+
133
+ Implements the NumPy array protocol, used by ``np.asarray(K)`` and by
134
+ any NumPy/SciPy/Matplotlib function that calls ``np.asarray()``
135
+ internally on whatever it's given (e.g. ``scipy.signal.convolve2d``,
136
+ ``Axes3D.plot_surface``, ``np.linalg.svd``). Without this, such a call
137
+ would silently coerce a :class:`Kernel` into a useless 0-d object
138
+ array instead of its weighting matrix.
139
+ """
140
+ arr = self.K
141
+ if dtype is not None:
142
+ arr = arr.astype(dtype)
143
+ if copy:
144
+ arr = arr.copy()
145
+ return arr
146
+
118
147
  def print(
119
148
  self, fmt: str | None = None, separator: str = " ", precision: int = 2
120
149
  ) -> None:
@@ -2,6 +2,7 @@
2
2
  Thin wrapper around Open3D point-cloud objects with MVTB look and feel.
3
3
  """
4
4
 
5
+ from pathlib import Path
5
6
  from warnings import warn
6
7
  from typing import Any, Callable
7
8
 
@@ -26,7 +27,7 @@ class PointCloud:
26
27
  :type arg: :obj:`o3d.geometry.PointCloud`, :obj:`~machinevisiontoolbox.PointCloud`, ndarray(3,N)
27
28
  :param depth_scale: depth scale factor, defaults to 1.0
28
29
  :type depth_scale: float, optional
29
- :raises RuntimeError: PointCloud class requires Open3D to be installed: pip install open3d-python or pip install machinevision-toolbox-python[open3d]
30
+ :raises RuntimeError: PointCloud class requires Open3D to be installed: pip install open3d or pip install machinevision-toolbox-python[open3d]
30
31
  :raises ValueError: bad arguments
31
32
 
32
33
  This object wraps an Open3D :obj:`open3d.geometry.PointCloud` object. It can be
@@ -50,7 +51,7 @@ class PointCloud:
50
51
  if not _open3d:
51
52
  raise RuntimeError(
52
53
  "PointCloud class requires Open3D to be installed: "
53
- "pip install open3d-python or "
54
+ "pip install open3d or "
54
55
  "pip install machinevision-toolbox-python[open3d]\n"
55
56
  " ** Note that Open3d typically does not run on the latest Python version(s)"
56
57
  )
@@ -60,7 +61,7 @@ class PointCloud:
60
61
  if not _open3d:
61
62
  raise RuntimeError(
62
63
  "PointCloud class requires Open3D to be installed: "
63
- "pip install open3d-python or "
64
+ "pip install open3d or "
64
65
  "pip install machinevision-toolbox-python[open3d]"
65
66
  )
66
67
 
@@ -244,10 +245,22 @@ class PointCloud:
244
245
  the ``data`` folder of the
245
246
  `mvtb_data package <https://github.com/petercorke/machinevision-toolbox-python/tree/master/packages/mvtb-data>`_.
246
247
 
248
+ .. note:: ``filename`` may be given as a bare name (``'bunny.ply'``)
249
+ or with a redundant leading ``data/`` (``'data/bunny.ply'``, the
250
+ older convention some book examples still use) -- both resolve
251
+ to the same file.
252
+
247
253
  """
248
254
 
249
255
  from machinevisiontoolbox import mvtb_path_to_datafile
250
256
 
257
+ # accept a caller-supplied redundant "data/" prefix (older
258
+ # convention) without double-joining it against the "data" this
259
+ # method already prepends
260
+ parts = Path(filename).parts
261
+ if parts and parts[0] == "data":
262
+ filename = str(Path(*parts[1:])) if len(parts) > 1 else ""
263
+
251
264
  filename = mvtb_path_to_datafile("data", filename, string=True)
252
265
  pcd = o3d.io.read_point_cloud(filename, *args, **kwargs)
253
266
  return cls(pcd)
@@ -273,7 +286,7 @@ class PointCloud:
273
286
  :type rgb: :class:`~machinevisiontoolbox.Image`, optional
274
287
  :param depth_scale: depth scale factor, defaults to 1.0
275
288
  :type depth_scale: float, optional
276
- :raises RuntimeError: PointCloud class requires Open3D to be installed: pip install open3d-python or pip install machinevision-toolbox-python[open3d]
289
+ :raises RuntimeError: PointCloud class requires Open3D to be installed: pip install open3d or pip install machinevision-toolbox-python[open3d]
277
290
  :raises ValueError: input images has wrong number of planes
278
291
 
279
292
  The depth image is converted to a point cloud using the Open3D, and the pixel
@@ -25,7 +25,6 @@ import warnings
25
25
  import zipfile
26
26
  from collections import Counter, deque
27
27
  from collections.abc import Iterator
28
- from numpy.char import array
29
28
  from tqdm import tqdm
30
29
  from datetime import datetime, timezone
31
30
  from typing import TYPE_CHECKING, IO, Any, Literal, cast
@@ -2346,10 +2345,10 @@ class ROSTopic(ImageSource):
2346
2345
  if not _open3d_available:
2347
2346
  raise ImportError(
2348
2347
  "open3d is required for PointCloud publish support. "
2349
- "Install it with: pip install open3d-python "
2348
+ "Install it with: pip install open3d "
2350
2349
  "or pip install machinevision-toolbox-python[open3d]"
2351
2350
  )
2352
- assert o3d is not None
2351
+ assert o3d is not None
2353
2352
 
2354
2353
  points = np.asarray(pc._pcd.points)
2355
2354
  if points.ndim != 2 or points.shape[1] != 3:
@@ -3573,7 +3572,7 @@ class ROSBag(ImageSource):
3573
3572
  if not _open3d_available:
3574
3573
  raise ImportError(
3575
3574
  "open3d is required to read PointCloud2 messages. "
3576
- "Install it with: pip install open3d-python "
3575
+ "Install it with: pip install open3d "
3577
3576
  "or pip install machinevision-toolbox-python[open3d]"
3578
3577
  )
3579
3578
  assert o3d is not None
@@ -3770,7 +3769,7 @@ class PointCloudSequence:
3770
3769
  if not _open3d_available:
3771
3770
  raise ImportError(
3772
3771
  "open3d is required for point cloud display. "
3773
- "Install it with: pip install open3d-python "
3772
+ "Install it with: pip install open3d "
3774
3773
  "or pip install machinevision-toolbox-python[open3d]"
3775
3774
  )
3776
3775
  assert o3d is not None
@@ -56,6 +56,7 @@ __all__ = [
56
56
  # types
57
57
  "int_image",
58
58
  "float_image",
59
+ "DTYPE_ALIASES",
59
60
  # data
60
61
  "mvtb_path_to_datafile",
61
62
  "mvtb_load_data",
@@ -17,6 +17,7 @@ import matplotlib.path as mpath
17
17
  import matplotlib.pyplot as plt
18
18
  import numpy as np
19
19
  import spatialmath.base as smb
20
+ from spatialmath import Polygon2
20
21
  from scipy import interpolate
21
22
 
22
23
  # import io as io
@@ -814,8 +815,10 @@ def name2color(
814
815
  color = (color * np.iinfo(dtype).max).astype(dtype)
815
816
  if colororder is not None:
816
817
  # reorder the elements of the color if colororder is given
817
- # colororder is a dict mapping plane name to index
818
- color = [color[colororder[c]] for c in colorspace]
818
+ # colororder is a dict mapping plane name to index -- fancy
819
+ # index rather than a list comprehension, to keep the
820
+ # ndarray return type promised by this function's contract
821
+ color = color[[colororder[c] for c in colorspace]]
819
822
  return color
820
823
  except ValueError:
821
824
  return None
@@ -1777,8 +1780,8 @@ def shadow_invariant(
1777
1780
  return gs
1778
1781
 
1779
1782
 
1780
- def esttheta(im: Any, sharpen: np.ndarray | None = None) -> None:
1781
- """
1783
+ def esttheta(im: Any, sharpen: np.ndarray | None = None) -> float:
1784
+ r"""
1782
1785
  Estimate theta for shadow invariance
1783
1786
 
1784
1787
  :param im: input image
@@ -1790,24 +1793,27 @@ def esttheta(im: Any, sharpen: np.ndarray | None = None) -> None:
1790
1793
 
1791
1794
  This is an interactive procedure where the image is displayed and the user
1792
1795
  selects a region of homogeneous material (eg. grass or road) that includes
1793
- areas that are directly lit by the sun and in shadow.
1796
+ areas that are directly lit by the sun and in shadow. The invariant angle
1797
+ θ is found by sweeping candidate angles and choosing the one that
1798
+ minimizes the variance of the resulting invariant image over the selected
1799
+ region -- at the true invariant angle, lit and shadowed pixels of the same
1800
+ material map to (nearly) the same greyscale value.
1794
1801
 
1795
1802
  .. note:: The user selects boundary points by clicking the mouse. After the
1796
1803
  last point, hit the Enter key and the region will be closed.
1804
+
1805
+ :seealso: :func:`shadow_invariant`
1797
1806
  """
1798
1807
 
1799
1808
  def pickregion(im: Any) -> np.ndarray:
1800
- im.disp()
1809
+ h = im.disp()
1801
1810
 
1802
- clicks = plt.ginput(n=-1)
1811
+ clicks = h.figure.ginput(n=-1)
1803
1812
 
1804
1813
  xy = np.array(clicks)
1805
- print(xy)
1806
1814
 
1807
- smb.plot_poly(xy.T, "g", close=True)
1808
-
1809
- polygon = smb.Polygon2(xy.T)
1810
- polygon.plot("g")
1815
+ polygon = Polygon2(xy.T)
1816
+ polygon.plot(color="g")
1811
1817
 
1812
1818
  X, Y = im.meshgrid()
1813
1819
  inside = polygon.contains(np.c_[X.ravel(), Y.ravel()].T)
@@ -1816,13 +1822,33 @@ def esttheta(im: Any, sharpen: np.ndarray | None = None) -> None:
1816
1822
 
1817
1823
  k_region = pickregion(im)
1818
1824
 
1819
- imcol = im.column()
1820
-
1825
+ imcol = im.view1d()
1821
1826
  z = imcol[k_region, :]
1822
- print(z.shape)
1823
- # k = find(in);
1827
+ print(f"{z.shape[0]} pixels selected in the region")
1828
+
1829
+ theta_v = np.arange(0, np.pi, 0.02)
1830
+ sim = np.empty_like(theta_v)
1831
+ for i, θ in enumerate(theta_v):
1832
+ gs = shadow_invariant(z.reshape(-1, 1, 3), θ, sharpen=sharpen).ravel()
1833
+ bad = np.isinf(gs) | np.isnan(gs)
1834
+ sim[i] = np.std(gs[~bad])
1835
+
1836
+ k = np.argmin(sim)
1837
+ theta = theta_v[k]
1838
+
1839
+ plt.figure()
1840
+ plt.plot(theta_v, sim)
1841
+ plt.plot(theta, sim[k], "o")
1842
+ plt.xlabel("invariant line angle (rad)")
1843
+ plt.ylabel("invariance image variance")
1844
+ plt.grid(True)
1845
+
1846
+ print(f"best angle = {theta:.4f} rad")
1847
+
1824
1848
  _safe_show(block=True)
1825
1849
 
1850
+ return theta
1851
+
1826
1852
 
1827
1853
  if __name__ == "__main__": # pragma: no cover
1828
1854
  import os.path