machinevision-toolbox-python 2.0.1__py3-none-any.whl → 2.1.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 (32) hide show
  1. {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.1.0.dist-info}/METADATA +13 -5
  2. {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.1.0.dist-info}/RECORD +21 -31
  3. {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.1.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/ImageIO.py +5 -1
  9. machinevisiontoolbox/ImagePointFeatures.py +8 -6
  10. machinevisiontoolbox/ImageProcessing.py +1 -1
  11. machinevisiontoolbox/ImageRegionFeatures.py +9 -6
  12. machinevisiontoolbox/ImageWholeFeatures.py +113 -72
  13. machinevisiontoolbox/Sources.py +1 -1
  14. machinevisiontoolbox/__init__.py +1 -1
  15. machinevisiontoolbox/base/imageio.py +68 -33
  16. machinevisiontoolbox/bin/_iminteractive.py +461 -0
  17. machinevisiontoolbox/bin/bagtool.py +120 -6
  18. machinevisiontoolbox/bin/imtool.py +34 -5
  19. machinevisiontoolbox/bin/mvtbtool.py +12 -5
  20. machinevisiontoolbox/ImageCore.docstring +0 -2877
  21. machinevisiontoolbox/aruco0.pdf +0 -0
  22. machinevisiontoolbox/aruco50.pdf +0 -0
  23. machinevisiontoolbox/bin/labeltool.py +0 -147
  24. machinevisiontoolbox/blocks/Icons/icons.sh +0 -4
  25. machinevisiontoolbox/cvfuncs.py +0 -40
  26. machinevisiontoolbox/docbugs.py +0 -47
  27. machinevisiontoolbox/newcameras.py +0 -55
  28. machinevisiontoolbox/test_skimage.py +0 -12
  29. machinevisiontoolbox/testblobplots.py +0 -17
  30. machinevisiontoolbox/x.json +0 -296
  31. {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.1.0.dist-info}/entry_points.txt +0 -0
  32. {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.1.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
@@ -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
 
@@ -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,7 @@ class LUCIDFeature(BaseFeature2D):
2012
2014
  pass
2013
2015
 
2014
2016
 
2015
- class ImagePointFeaturesMixin(_ImageBase):
2017
+ class ImagePointFeaturesMixin(_ImageBase if TYPE_CHECKING else object):
2016
2018
  def _image2feature(
2017
2019
  self,
2018
2020
  cls,
@@ -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
@@ -239,7 +242,7 @@ class MSERFeature:
239
242
 
240
243
  return new
241
244
 
242
- def __str__(self):
245
+ def __str__(self) -> str:
243
246
  """
244
247
  String representation of MSER
245
248
 
@@ -262,7 +265,7 @@ class MSERFeature:
262
265
  s = f"MSER feature: u: {self._bboxes[0,0]} - {self._bboxes[0,2]}, v: {self._bboxes[0,1]} - {self._bboxes[0,3]}"
263
266
  return s
264
267
 
265
- def __repr__(self):
268
+ def __repr__(self) -> str:
266
269
  """
267
270
  Representation of MSER
268
271
 
@@ -370,7 +373,7 @@ class OCRWord:
370
373
  for key in ocr.keys():
371
374
  self.dict[key] = ocr[key][i]
372
375
 
373
- def __str__(self):
376
+ def __str__(self) -> str:
374
377
  """
375
378
  String representation of MSER
376
379
 
@@ -379,7 +382,7 @@ class OCRWord:
379
382
  """
380
383
  return f"{self.dict['text']} ({self.dict['conf']}%)"
381
384
 
382
- def __repr__(self):
385
+ def __repr__(self) -> str:
383
386
  return str(self)
384
387
 
385
388
  @property
@@ -11,6 +11,7 @@ import cv2
11
11
  import matplotlib.pyplot as plt
12
12
  import numpy as np
13
13
  import scipy as sp
14
+ from matplotlib.axes import Axes
14
15
  from matplotlib.patches import Polygon
15
16
  from matplotlib.ticker import ScalarFormatter
16
17
  from spatialmath import SE3, base
@@ -519,6 +520,11 @@ class ImageWholeFeaturesMixin(_ImageBase if TYPE_CHECKING else object):
519
520
  .. deprecated:: 2.0.0
520
521
  Use ``hist().cdf`` instead.
521
522
  """
523
+ warnings.warn(
524
+ "Deprecated in 2.0.0: use hist().cdf instead of ncdf.",
525
+ DeprecationWarning,
526
+ stacklevel=2,
527
+ )
522
528
  hist = self._default_hist()
523
529
  return hist.cdf
524
530
 
@@ -1193,26 +1199,31 @@ class Histogram:
1193
1199
  :rtype: ndarray(N) or ndarray(N,P)
1194
1200
 
1195
1201
  .. deprecated:: 2.0.0
1196
- Use ``hist().cdf`` instead.
1202
+ Use ``cdf`` instead.
1197
1203
  """
1204
+ warnings.warn(
1205
+ "Deprecated in 2.0.0: use .cdf instead of .ncdf.",
1206
+ DeprecationWarning,
1207
+ stacklevel=2,
1208
+ )
1198
1209
  return self.cdf
1199
1210
 
1200
1211
  def plot(
1201
1212
  self,
1202
- type="frequency",
1203
- block=False,
1204
- filled=None,
1205
- stats=True,
1206
- style="stack",
1207
- cursor=False,
1208
- alpha=0.5,
1209
- title=None,
1210
- log=False,
1211
- samescale=False,
1212
- ax=None,
1213
- bar=None,
1214
- **kwargs,
1215
- ):
1213
+ type: str = "frequency",
1214
+ block: bool = False,
1215
+ filled: bool | None = None,
1216
+ stats: bool = True,
1217
+ style: str = "stack",
1218
+ cursor: bool = False,
1219
+ alpha: float = 0.5,
1220
+ title: str | None = None,
1221
+ log: bool = False,
1222
+ samescale: bool = False,
1223
+ ax: Axes | None = None,
1224
+ bar: bool | None = None,
1225
+ **kwargs: Any,
1226
+ ) -> None:
1216
1227
  """
1217
1228
  Plot histogram
1218
1229
 
@@ -1343,38 +1354,9 @@ class Histogram:
1343
1354
  samescale = True
1344
1355
 
1345
1356
  # figure vertical axis labels and scaling based on histogram type
1346
- if type == "frequency":
1347
- y = self.h
1348
- if samescale:
1349
- maxy = np.max(y)
1350
- else:
1351
- maxy = np.max(y, axis=0)
1352
- ylabel1 = "frequency"
1353
- ylabel2 = "frequency"
1354
- if filled is None:
1355
- filled = True
1356
- elif type in ("pdf", "probability"):
1357
- y = self.pdf
1358
- if samescale:
1359
- maxy = np.max(y)
1360
- else:
1361
- maxy = np.max(y, axis=0)
1362
- ylabel1 = "PDF"
1363
- ylabel2 = "probability density"
1364
- elif type in ("cf", "cumulative"):
1365
- y = self.cf
1366
- maxy = y[
1367
- -1, 0
1368
- ] # last row values are all the same, total number of pixels in plane
1369
- ylabel1 = "cumulative frequency"
1370
- ylabel2 = "cumulative frequency"
1371
- elif type in ("cdf", "normalized"):
1372
- y = self.cdf
1373
- maxy = 1
1374
- ylabel1 = "CDF"
1375
- ylabel2 = "normalized cumulative frequency"
1376
- else:
1377
- raise ValueError("unknown type")
1357
+ y, maxy, ylabel1, ylabel2 = self._compute_plot_series(type, samescale)
1358
+ if type == "frequency" and filled is None:
1359
+ filled = True
1378
1360
 
1379
1361
  if self.nplanes == 1:
1380
1362
  y = y[..., np.newaxis]
@@ -1391,17 +1373,6 @@ class Histogram:
1391
1373
  if self.nplanes == 1:
1392
1374
  hist_counts = hist_counts[..., np.newaxis]
1393
1375
 
1394
- def plane_stats(counts: np.ndarray) -> tuple[float | None, float | None]:
1395
- total = float(np.sum(counts))
1396
- if total <= 0:
1397
- return None, None
1398
-
1399
- mean = float(np.sum(self.x * counts) / total)
1400
- cdf = np.cumsum(counts)
1401
- median_idx = int(np.searchsorted(cdf, 0.5 * total, side="left"))
1402
- median = float(self.x[min(median_idx, len(self.x) - 1)])
1403
- return mean, median
1404
-
1405
1376
  if self._sorted:
1406
1377
  xrange = (self.x[0], self.x[-1])
1407
1378
  xlabel = "bin rank"
@@ -1448,7 +1419,7 @@ class Histogram:
1448
1419
  ScalarFormatter(useOffset=False, useMathText=True)
1449
1420
  )
1450
1421
  if stats:
1451
- mean, median = plane_stats(hist_counts[:, i])
1422
+ mean, median = self._histogram_plane_stats(hist_counts[:, i])
1452
1423
  if mean is not None and median is not None:
1453
1424
  ax.axvline(
1454
1425
  mean,
@@ -1549,18 +1520,7 @@ class Histogram:
1549
1520
  x = np.r_[xrange[0], x, xrange[1]]
1550
1521
  _, ax = plt.subplots(1, 1)
1551
1522
 
1552
- patchcolor = []
1553
- goodcolors = [c for c in "rgbykcm"]
1554
- if self.colordict is None:
1555
- self.colordict = {c: i for i, c in enumerate(goodcolors[:n])}
1556
- colors = list(self.colordict.keys())
1557
- patchcolor = [c.lower() for c in colors]
1558
- else:
1559
- for color, i in self.colordict.items():
1560
- if color.lower() in "rgbykcm":
1561
- patchcolor.append(color.lower())
1562
- else:
1563
- patchcolor.append(goodcolors.pop(0))
1523
+ colors, patchcolor = self._resolve_overlay_colors(n, colors)
1564
1524
 
1565
1525
  if filled:
1566
1526
  for i in range(n):
@@ -1580,7 +1540,7 @@ class Histogram:
1580
1540
  ax.plot(x, np.r_[0, y[:, i], 0], color=patchcolor[i], **kwargs)
1581
1541
 
1582
1542
  if stats:
1583
- mean, median = plane_stats(np.sum(hist_counts, axis=1))
1543
+ mean, median = self._histogram_plane_stats(np.sum(hist_counts, axis=1))
1584
1544
  if mean is not None and median is not None:
1585
1545
  ax.axvline(
1586
1546
  mean,
@@ -1617,6 +1577,87 @@ class Histogram:
1617
1577
  set_window_title(title)
1618
1578
  safe_plt_show(block=block)
1619
1579
 
1580
+ def _compute_plot_series(
1581
+ self, type: str, samescale: bool
1582
+ ) -> tuple[np.ndarray, np.ndarray | float, str, str]:
1583
+ """Compute plot()'s y-values, y-axis max, and y-axis labels for a
1584
+ given ``type``. Pure computation over self.h/self.pdf/self.cf/
1585
+ self.cdf -- no mutation."""
1586
+ if type == "frequency":
1587
+ y = self.h
1588
+ if samescale:
1589
+ maxy = np.max(y)
1590
+ else:
1591
+ maxy = np.max(y, axis=0)
1592
+ ylabel1 = "frequency"
1593
+ ylabel2 = "frequency"
1594
+ elif type in ("pdf", "probability"):
1595
+ y = self.pdf
1596
+ if samescale:
1597
+ maxy = np.max(y)
1598
+ else:
1599
+ maxy = np.max(y, axis=0)
1600
+ ylabel1 = "PDF"
1601
+ ylabel2 = "probability density"
1602
+ elif type in ("cf", "cumulative"):
1603
+ y = self.cf
1604
+ maxy = y[
1605
+ -1, 0
1606
+ ] # last row values are all the same, total number of pixels in plane
1607
+ ylabel1 = "cumulative frequency"
1608
+ ylabel2 = "cumulative frequency"
1609
+ elif type in ("cdf", "normalized"):
1610
+ y = self.cdf
1611
+ maxy = 1
1612
+ ylabel1 = "CDF"
1613
+ ylabel2 = "normalized cumulative frequency"
1614
+ else:
1615
+ raise ValueError("unknown type")
1616
+
1617
+ return y, maxy, ylabel1, ylabel2
1618
+
1619
+ def _histogram_plane_stats(
1620
+ self, counts: np.ndarray
1621
+ ) -> tuple[float | None, float | None]:
1622
+ """Mean and median of a single plane's histogram counts, or
1623
+ (None, None) if the plane is empty. Used by both plot()'s stack
1624
+ and overlay styles."""
1625
+ total = float(np.sum(counts))
1626
+ if total <= 0:
1627
+ return None, None
1628
+
1629
+ mean = float(np.sum(self.x * counts) / total)
1630
+ cdf = np.cumsum(counts)
1631
+ median_idx = int(np.searchsorted(cdf, 0.5 * total, side="left"))
1632
+ median = float(self.x[min(median_idx, len(self.x) - 1)])
1633
+ return mean, median
1634
+
1635
+ def _resolve_overlay_colors(
1636
+ self, n: int, colors: list[str]
1637
+ ) -> tuple[list[str], list[str]]:
1638
+ """Resolve per-plane display colors for plot()'s style="overlay".
1639
+
1640
+ If self.colordict is unset, assigns a default one as a side effect
1641
+ (preserved intentionally -- this is existing behavior, not
1642
+ introduced by extracting this into its own method) and returns the
1643
+ colors derived from it; otherwise maps the existing colordict's
1644
+ color names onto a fallback palette for any non-standard names.
1645
+ """
1646
+ patchcolor = []
1647
+ goodcolors = [c for c in "rgbykcm"]
1648
+ if self.colordict is None:
1649
+ self.colordict = {c: i for i, c in enumerate(goodcolors[:n])}
1650
+ colors = list(self.colordict.keys())
1651
+ patchcolor = [c.lower() for c in colors]
1652
+ else:
1653
+ for color, i in self.colordict.items():
1654
+ if color.lower() in "rgbykcm":
1655
+ patchcolor.append(color.lower())
1656
+ else:
1657
+ patchcolor.append(goodcolors.pop(0))
1658
+
1659
+ return colors, patchcolor
1660
+
1620
1661
  def peaks(self, **kwargs: Any) -> np.ndarray | list[np.ndarray]:
1621
1662
  r"""
1622
1663
  Histogram peaks
@@ -3113,7 +3113,7 @@ class ROSBag(ImageSource):
3113
3113
  reader.close()
3114
3114
  self.reader = None
3115
3115
 
3116
- def _open_reader(self):
3116
+ def _open_reader(self) -> Any:
3117
3117
  if self.reader is not None:
3118
3118
  return self.reader
3119
3119
 
@@ -188,5 +188,5 @@ try:
188
188
  import importlib.metadata
189
189
 
190
190
  __version__ = importlib.metadata.version("machinevision-toolbox-python")
191
- except:
191
+ except importlib.metadata.PackageNotFoundError:
192
192
  pass