machinevision-toolbox-python 2.1.0__py3-none-any.whl → 2.3.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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: machinevision-toolbox-python
3
- Version: 2.1.0
3
+ Version: 2.3.0
4
4
  Summary: Object-oriented machine vision for Python.
5
5
  Project-URL: Homepage, https://github.com/petercorke/machinevision-toolbox-python
6
6
  Project-URL: Bug Tracker, https://github.com/pypa/sampleproject/issues
@@ -99,20 +99,24 @@ Description-Content-Type: text/markdown
99
99
 
100
100
  ---
101
101
 
102
- ### Status & Ecosystem
103
- [![A Python Robotics Package](https://raw.githubusercontent.com/petercorke/robotics-toolbox-python/master/.github/svg/py_collection.min.svg)](https://github.com/petercorke/robotics-toolbox-python)
104
- [![QUT Centre for Robotics Open Source](https://github.com/qcr/qcr.github.io/raw/master/misc/badge.svg)](https://qcr.github.io)
102
+ ### Status & Project Health
103
+
105
104
  [![Build Status](https://github.com/petercorke/machinevision-toolbox-python/actions/workflows/ci.yml/badge.svg)](https://github.com/petercorke/machinevision-toolbox-python/actions/workflows/ci.yml)
106
- [![Coverage](https://codecov.io/gh/petercorke/machinevision-toolbox-python/branch/main/graph/badge.svg)](https://codecov.io/gh/petercorke/machinevision-toolbox-python)
105
+ [![Downloads](https://static.pepy.tech/badge/machinevision-toolbox-python/month)](https://pepy.tech/projects/machinevision-toolbox-python)
107
106
  ![Python Version](https://img.shields.io/pypi/pyversions/machinevision-toolbox-python.svg)
107
+ [![Coverage](https://codecov.io/gh/petercorke/machinevision-toolbox-python/branch/main/graph/badge.svg)](https://codecov.io/gh/petercorke/machinevision-toolbox-python)
108
108
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
109
- [![PyPI - Downloads](https://img.shields.io/pypi/dw/machinevision-toolbox-python)](https://pypistats.org/packages/machinevision-toolbox-python)
110
109
 
111
- ### Powered by
112
- [![Powered by Spatial Maths](https://raw.githubusercontent.com/petercorke/spatialmath-python/master/.github/svg/sm_powered.min.svg)](https://github.com/petercorke/spatialmath-python)
113
- [![Powered by NumPy](https://img.shields.io/badge/powered_by-NumPy-013243?logo=numpy&logoColor=white)](https://numpy.org)
110
+ ### Ecosystem & Dependencies
111
+ [![A Python Robotics Package](https://raw.githubusercontent.com/petercorke/robotics-toolbox-python/master/.github/svg/py_collection.min.svg)](https://github.com/petercorke/robotics-toolbox-python)
112
+ [![QUT Centre for Robotics Open Source](https://github.com/qcr/qcr.github.io/raw/master/misc/badge.svg)](https://qcr.github.io)
113
+
114
+ [![powered by NumPy](https://img.shields.io/badge/powered_by-NumPy-013243?logo=numpy&logoColor=white)](https://numpy.org)
115
+ [![powered by SciPy](https://img.shields.io/badge/powered_by-SciPy-0054a6?logo=scipy&logoColor=white)](https://scipy.org)
116
+ [![powered by Matplotlib](https://img.shields.io/badge/powered_by-Matplotlib-11557c?logo=matplotlib&logoColor=white)](https://matplotlib.org)
114
117
  [![Powered by OpenCV](https://raw.githubusercontent.com/petercorke/machinevision-toolbox-python/main/.github/svg/opencv_powered.svg)](https://opencv.org)
115
118
  [![Powered by Open3D](https://raw.githubusercontent.com/petercorke/machinevision-toolbox-python/main/.github/svg/open3d_powered.svg)](https://open3d.org)
119
+ [![Powered by Spatial Maths](https://raw.githubusercontent.com/petercorke/spatialmath-python/master/.github/svg/sm_powered.min.svg)](https://github.com/petercorke/spatialmath-python)
116
120
 
117
121
  ## Synopsis
118
122
 
@@ -5,14 +5,14 @@ machinevisiontoolbox/ImageBlobs.py,sha256=TsXaJpqUN1WrPg0zHRmLQvP3wUBxcHHUBt9qDg
5
5
  machinevisiontoolbox/ImageColor.py,sha256=fueFYDNlvqSJPgcKsG5v2wBxaW7mYNa6OA-_rK-UJK0,17577
6
6
  machinevisiontoolbox/ImageConstants.py,sha256=R0sc9z8sbraruI6qkixLso6fTgxUAC5cQMSw4ms9m2k,38774
7
7
  machinevisiontoolbox/ImageCore.py,sha256=ufDI7ynOGRwM8TSSDEdM4LMQxBhclc0ve976tsprBW8,140594
8
- machinevisiontoolbox/ImageFiducials.py,sha256=0CXDgRVY8cA8sIsqd9liPLDRBFRXkolxZwu8rj8TxsM,24124
8
+ machinevisiontoolbox/ImageFiducials.py,sha256=wrCevhrkgdHzBMMRoDqADZE9OOD7L6nQCd3UDA_e3rU,26903
9
9
  machinevisiontoolbox/ImageIO.py,sha256=MFUwjxdQ8oGttGMgzon9xOom-IGsVUFkiEWDrVEEgMs,27019
10
- machinevisiontoolbox/ImageLineFeatures.py,sha256=5D4MfbNTaRSErdD-HIVlxG9RrtfPAO7WpG4FhESJb4w,10338
10
+ machinevisiontoolbox/ImageLineFeatures.py,sha256=Kq0rCzPER2eB9MhEBNn6hAHpGY-G_WPZd657RmuRryA,10855
11
11
  machinevisiontoolbox/ImageMorph.py,sha256=WioUD2Jr9Ejnk7Let4Vf3iTFz5DhMkZ7l_ZBCbvgl4U,30237
12
12
  machinevisiontoolbox/ImageMultiview.py,sha256=swzNsxX39dijPdPa-a4LFOl7T7liTcQeP7NJf5ODGQU,15605
13
- machinevisiontoolbox/ImagePointFeatures.py,sha256=ZvgXAnyOM-0Lx6yGCYlkfBkRTN-RycNbpu9JzLLritI,77224
13
+ machinevisiontoolbox/ImagePointFeatures.py,sha256=HYgBUs931OOYsM9kx4p_zFU5vAc9dOJsNPuUyTKxoY4,78209
14
14
  machinevisiontoolbox/ImageProcessing.py,sha256=HBR6cbTlgfAdOiqXU8DPSFKZQXWTBIVufCyRaifky7g,51669
15
- machinevisiontoolbox/ImageRegionFeatures.py,sha256=qh0NcTLmSVsLe_sKfnY2Jt3d2w1k1Cno-P8K5H9cLV4,15073
15
+ machinevisiontoolbox/ImageRegionFeatures.py,sha256=nZAdKFSpxmMZKwlbF13MOxBIUwXvPvDtIxNJUyp7pvg,16092
16
16
  machinevisiontoolbox/ImageReshape.py,sha256=F8eNrJ1to6d5jrIJeT1D2RqX-7EnARrQ03UGUCvljXg,50204
17
17
  machinevisiontoolbox/ImageSpatial.py,sha256=nzgScDprv7UQXhKcOps80gKjx4Ve6y498lCRm21sEtw,55622
18
18
  machinevisiontoolbox/ImageTensor.py,sha256=ohNbThdsQid2AQbDIHaCoxj00tpRggFWv0TTpIHAJQk,9006
@@ -41,7 +41,7 @@ machinevisiontoolbox/bin/_bintools.py,sha256=WwzyOqXEv4T-TB2GMBym4pGU2NACM5ACDCg
41
41
  machinevisiontoolbox/bin/_iminteractive.py,sha256=9fh2QcuO9RFfO9uGA05MePbe6cUtlh-F19z2zbG4pLA,17063
42
42
  machinevisiontoolbox/bin/bagtool.py,sha256=b-Ap2AynpXe0FtcydyfkXIgHDaumAkGBJgDZ6lgdZBU,12359
43
43
  machinevisiontoolbox/bin/imtool.py,sha256=la9m9LuMnthJzXxjMRWqGY_l7rJUZS5wiVgZsw84SO4,7897
44
- machinevisiontoolbox/bin/mvtbtool.py,sha256=P2-wnZXQMf7cWE3SVvmvc4wDn5ApJbMOfl0DyNLHNkU,11348
44
+ machinevisiontoolbox/bin/mvtbtool.py,sha256=NuVm2lLCJSUT4_RvlSplqeuUb0duDxAoupL1UIrjjuQ,13785
45
45
  machinevisiontoolbox/bin/ocrtool.py,sha256=HtkbLnjij40oy83nBtZlbJSFJ2pn7lcQAmOifvCLw0I,4719
46
46
  machinevisiontoolbox/bin/tagtool.py,sha256=io-NCIMdMc5zIFYMa6U2ITTv2T-Q8Y0bwh_pB1qZ4IQ,8523
47
47
  machinevisiontoolbox/blocks/__init__.py,sha256=EsdfK2WxYLSo0SA5HSmlgsivLT1kDnoH9v-RTBDnWwc,142
@@ -51,8 +51,8 @@ machinevisiontoolbox/blocks/Icons/camera.png,sha256=CGcTB_llMoucN-rfnP6ZP_7AfE_9
51
51
  machinevisiontoolbox/blocks/Icons/estpose_p.png,sha256=1XrwL4OkotGWMwKmhnar3tt8cQWUdZiO7PxXD354T-E,4320
52
52
  machinevisiontoolbox/blocks/Icons/imageplane.png,sha256=34FYQMzkq6foxvRU8pn0KkcHxpHRvsJRJSYKfNu13Eg,3902
53
53
  machinevisiontoolbox/blocks/Icons/visjac_p.png,sha256=8X023l-C9Y5DfAN8wCdWYjSjq6wp1vRzuZ85sUbwxv8,3819
54
- machinevision_toolbox_python-2.1.0.dist-info/METADATA,sha256=Prpajt2dDvAN5u3yxRUm0-RYQGGyD-06t8avYEiOpj8,21453
55
- machinevision_toolbox_python-2.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
56
- machinevision_toolbox_python-2.1.0.dist-info/entry_points.txt,sha256=R0sRa7JufKCG1VPkgo5KcL7j7pW4-E0Ch2Og4AqmXg4,310
57
- machinevision_toolbox_python-2.1.0.dist-info/licenses/LICENSE,sha256=niCzY_CzbIMKuUZlUkGimjrKp0vkLAtcwFmoSuSKrdw,1068
58
- machinevision_toolbox_python-2.1.0.dist-info/RECORD,,
54
+ machinevision_toolbox_python-2.3.0.dist-info/METADATA,sha256=GIbwk-9OjmGJcOWpGeEDlp1tuH0qTk-9YUtHFLdc9Nw,21733
55
+ machinevision_toolbox_python-2.3.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
56
+ machinevision_toolbox_python-2.3.0.dist-info/entry_points.txt,sha256=R0sRa7JufKCG1VPkgo5KcL7j7pW4-E0Ch2Og4AqmXg4,310
57
+ machinevision_toolbox_python-2.3.0.dist-info/licenses/LICENSE,sha256=niCzY_CzbIMKuUZlUkGimjrKp0vkLAtcwFmoSuSKrdw,1068
58
+ machinevision_toolbox_python-2.3.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.31.0
2
+ Generator: hatchling 1.32.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -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
@@ -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"""
@@ -2014,6 +2014,25 @@ class LUCIDFeature(BaseFeature2D):
2014
2014
  pass
2015
2015
 
2016
2016
 
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
+
2017
2036
  class ImagePointFeaturesMixin(_ImageBase if TYPE_CHECKING else object):
2018
2037
  def _image2feature(
2019
2038
  self,
@@ -2030,8 +2049,8 @@ class ImagePointFeaturesMixin(_ImageBase if TYPE_CHECKING else object):
2030
2049
  "SIFT": getattr(cv2, "SIFT_create"),
2031
2050
  "ORB": getattr(cv2, "ORB_create"),
2032
2051
  "Harris": _Harris_create,
2033
- "BRISK": getattr(cv2, "BRISK_create"),
2034
- "AKAZE": getattr(cv2, "AKAZE_create"),
2052
+ "BRISK": _resolve_feature_create("BRISK"),
2053
+ "AKAZE": _resolve_feature_create("AKAZE"),
2035
2054
  # 'FREAK': (cv2.FREAK_create, FREAKFeature),
2036
2055
  # 'DAISY': (cv2.DAISY_create, DAISYFeature),
2037
2056
  }
@@ -173,6 +173,22 @@ class MSERFeature:
173
173
  self._points = [
174
174
  mser.T for mser in msers
175
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)
176
192
  bboxes[:, 2:] = bboxes[:, 0:2] + bboxes[:, 2:] # convert to lrtb
177
193
  self._bboxes = bboxes
178
194
 
@@ -148,6 +148,15 @@ def parse_arguments():
148
148
  help="images to load on startup. These appear in the variable img; or img[0], img[1], ... if multiple are specified",
149
149
  )
150
150
 
151
+ parser.add_argument(
152
+ "--test",
153
+ default=False,
154
+ action="store_true",
155
+ help="non-interactive environment smoke test: print package versions, "
156
+ "exercise one real numeric code path per package, exit 0/1 "
157
+ "instead of starting an interactive shell",
158
+ )
159
+
151
160
  argv = env_arguments(parser) + sys.argv[1:]
152
161
  return parser.parse_known_args(argv)
153
162
 
@@ -183,23 +192,29 @@ def optional_torch_imports(enable):
183
192
  return modules, warnings
184
193
 
185
194
 
195
+ def get_versions() -> list[str]:
196
+ """Package version strings shown in the banner and by --test."""
197
+ versions = [
198
+ f"Python=={sys.version.split('|')[0].strip()}",
199
+ f"MVTB=={version('machinevision-toolbox-python')}",
200
+ f"SMTB=={version('spatialmath-python')}",
201
+ f"NumPy=={version('numpy')}",
202
+ f"SciPy=={version('scipy')}",
203
+ f"Matplotlib=={version('matplotlib')}",
204
+ f"OpenCV=={cv2.__version__}",
205
+ ]
206
+ try:
207
+ versions.append(f"Open3D=={version('open3d')}")
208
+ except PackageNotFoundError:
209
+ versions.append("Open3D==not installed")
210
+ return versions
211
+
212
+
186
213
  def make_banner(args, optional_modules=None):
187
214
  if optional_modules is None:
188
215
  optional_modules = {}
189
216
 
190
- versions = []
191
-
192
- versions.append(f"Python=={sys.version.split('|')[0].strip()}")
193
- versions.append(f"MVTB=={version('machinevision-toolbox-python')}")
194
- versions.append(f"SMTB=={version('spatialmath-python')}")
195
- versions.append(f"NumPy=={version('numpy')}")
196
- versions.append(f"SciPy=={version('scipy')}")
197
- versions.append(f"Matplotlib=={version('matplotlib')}")
198
- versions.append(f"OpenCV=={cv2.__version__}")
199
- try:
200
- versions.append(f"Open3D=={version('open3d')}")
201
- except PackageNotFoundError:
202
- pass
217
+ versions = get_versions()
203
218
 
204
219
  if "torch" in optional_modules:
205
220
  versions.append(
@@ -260,7 +275,61 @@ func/object?? - show source code"""
260
275
  print(banner)
261
276
 
262
277
 
278
+ def run_smoke_test() -> bool:
279
+ """Non-interactive environment sanity check, used by --test.
280
+
281
+ Not a substitute for the pytest suite -- a fast, human- or script-run
282
+ "did this environment actually come together correctly" check: real
283
+ versions, and one real numeric result per OpenCV- and Open3D-backed
284
+ code path, checked against a sanity condition rather than just "it
285
+ didn't raise". Missing optional dependencies (e.g. Open3D) are
286
+ reported as a FAIL with the reason, not silently skipped.
287
+ """
288
+ print(", ".join(get_versions()))
289
+
290
+ checks: list[tuple[str, bool]] = []
291
+
292
+ try:
293
+ img = Image.Read("monalisa.png", mono=True)
294
+ smoothed = img.smooth(sigma=2)
295
+ ok = smoothed.shape == img.shape and not np.array_equal(
296
+ smoothed.array, img.array
297
+ )
298
+ checks.append(("Image.Read + smooth() (OpenCV-backed)", ok))
299
+ except Exception as e:
300
+ checks.append((f"Image.Read + smooth() (OpenCV-backed): {e}", False))
301
+
302
+ try:
303
+ import open3d # noqa: F401 -- presence check; PointCloud does the real work
304
+ except ImportError as e:
305
+ checks.append(
306
+ (f"PointCloud.Read('bunny.ply') + voxel_grid(): Open3D not installed ({e})", False)
307
+ )
308
+ else:
309
+ try:
310
+ bunny = PointCloud.Read("bunny.ply")
311
+ voxels = bunny.voxel_grid(voxel_size=0.01)
312
+ ok = len(bunny) > 0 and len(voxels._voxels.get_voxels()) > 0
313
+ checks.append(("PointCloud.Read('bunny.ply') + voxel_grid() (Open3D-backed)", ok))
314
+ except Exception as e:
315
+ checks.append(
316
+ (f"PointCloud.Read('bunny.ply') + voxel_grid() (Open3D-backed): {e}", False)
317
+ )
318
+
319
+ for name, passed in checks:
320
+ print(f"[{'PASS' if passed else 'FAIL'}] {name}")
321
+
322
+ n_passed = sum(1 for _, passed in checks if passed)
323
+ print(f"mvtbtool --test: {n_passed}/{len(checks)} checks passed")
324
+ return n_passed == len(checks)
325
+
326
+
263
327
  def main():
328
+ args, ipython_args = parse_arguments()
329
+
330
+ if args.test:
331
+ sys.exit(0 if run_smoke_test() else 1)
332
+
264
333
  try:
265
334
  import IPython
266
335
  from IPython.terminal.prompts import Prompts
@@ -273,7 +342,6 @@ def main():
273
342
  " pip install machinevision-toolbox-python[tool]\n"
274
343
  )
275
344
 
276
- args, ipython_args = parse_arguments()
277
345
  torch_modules, torch_warnings = optional_torch_imports(args.torch)
278
346
 
279
347
  if args.backend is not None: