machinevision-toolbox-python 2.0.0__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.0.dist-info → machinevision_toolbox_python-2.1.0.dist-info}/METADATA +13 -5
  2. {machinevision_toolbox_python-2.0.0.dist-info → machinevision_toolbox_python-2.1.0.dist-info}/RECORD +21 -31
  3. {machinevision_toolbox_python-2.0.0.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 +209 -29
  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 +20 -4
  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.0.dist-info → machinevision_toolbox_python-2.1.0.dist-info}/entry_points.txt +0 -0
  32. {machinevision_toolbox_python-2.0.0.dist-info → machinevision_toolbox_python-2.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -3,13 +3,15 @@ Detection and description of 2D blob (connected region) features in images.
3
3
  """
4
4
 
5
5
  import copy
6
+ import io
7
+ import json
6
8
  import subprocess
7
9
  import sys
8
10
  import tempfile
9
11
  import webbrowser
10
12
  from collections import UserList, namedtuple
11
13
  from dataclasses import dataclass
12
- from typing import Any, cast
14
+ from typing import TYPE_CHECKING, Any, cast
13
15
 
14
16
  import cv2
15
17
  import matplotlib.pyplot as plt
@@ -22,6 +24,9 @@ import spatialmath.base as smb
22
24
  from machinevisiontoolbox.base import plot_labelbox, mpl_styling
23
25
  from machinevisiontoolbox.decorators import array_result, scalar_result
24
26
 
27
+ if TYPE_CHECKING:
28
+ from machinevisiontoolbox._image_typing import _ImageBase
29
+
25
30
  """
26
31
  NOTES
27
32
 
@@ -456,7 +461,10 @@ class Blobs(UserList): # lgtm[py/missing-equals]
456
461
 
457
462
  ## Equivalent ellipse
458
463
  J = np.array([[M["mu20"], M["mu11"]], [M["mu11"], M["mu02"]]])
459
- e, X = np.linalg.eig(J)
464
+ # J is real-symmetric, so use eigh (not eig): eig can return
465
+ # complex128 for a symmetric matrix due to floating-point
466
+ # noise even though the eigenvalues are mathematically real.
467
+ e, X = np.linalg.eigh(J)
460
468
  blob_params["a"] = 2.0 * np.sqrt(e.max() / M["m00"])
461
469
  blob_params["b"] = 2.0 * np.sqrt(e.min() / M["m00"])
462
470
 
@@ -629,7 +637,7 @@ class Blobs(UserList): # lgtm[py/missing-equals]
629
637
  return self[:]
630
638
  m: np.ndarray = np.array(mask).all(axis=0) # type: ignore[assignment]
631
639
 
632
- return self[m]
640
+ return self[m.flatten()]
633
641
 
634
642
  def sort(self, by: str = "area", reverse: bool = False) -> "Blobs": # type: ignore[override]
635
643
  """
@@ -1194,7 +1202,7 @@ class Blobs(UserList): # lgtm[py/missing-equals]
1194
1202
  >>> blobs[2].level
1195
1203
  >>> blobs.level
1196
1204
 
1197
- :seealso: :meth:`color` :meth:`parent` :meth:`children` :meth:`dotfile`
1205
+ :seealso: :meth:`color` :meth:`parent` :meth:`children` :meth:`dotfile` :meth:`graph`
1198
1206
  """
1199
1207
  return [b.level for b in self.data]
1200
1208
 
@@ -1247,7 +1255,7 @@ class Blobs(UserList): # lgtm[py/missing-equals]
1247
1255
 
1248
1256
  A parent of -1 is the image background.
1249
1257
 
1250
- :seealso: :meth:`id` :meth:`children` :meth:`level` :meth:`dotfile`
1258
+ :seealso: :meth:`id` :meth:`children` :meth:`level` :meth:`dotfile` :meth:`graph`
1251
1259
  """
1252
1260
  return [b.parent.id if b.parent is not None else -1 for b in self.data]
1253
1261
 
@@ -1272,7 +1280,7 @@ class Blobs(UserList): # lgtm[py/missing-equals]
1272
1280
  >>> blobs[6].id
1273
1281
 
1274
1282
 
1275
- :seealso: :meth:`parent` :meth:`children` :meth:`level` :meth:`dotfile`
1283
+ :seealso: :meth:`parent` :meth:`children` :meth:`level` :meth:`dotfile` :meth:`graph`
1276
1284
  """
1277
1285
  return [b.id for b in self.data]
1278
1286
 
@@ -1293,7 +1301,7 @@ class Blobs(UserList): # lgtm[py/missing-equals]
1293
1301
  >>> blobs = im.blobs()
1294
1302
  >>> blobs[5].children
1295
1303
 
1296
- :seealso: :meth:`parent` :meth:`level` :meth:`dotfile`
1304
+ :seealso: :meth:`parent` :meth:`level` :meth:`dotfile` :meth:`graph`
1297
1305
  """
1298
1306
  return [[c.id for c in b.children] for b in self.data]
1299
1307
 
@@ -2380,6 +2388,180 @@ class Blobs(UserList): # lgtm[py/missing-equals]
2380
2388
 
2381
2389
  return image.__class__(labels)
2382
2390
 
2391
+ def graph(
2392
+ self,
2393
+ format: str = "dot",
2394
+ filename: str | io.TextIOBase | None = None,
2395
+ direction: str | None = None,
2396
+ ) -> str:
2397
+ """
2398
+ Render blob hierarchy graph text.
2399
+
2400
+ :param format: graph output format: ``"dot"``, ``"mermaid"``,
2401
+ ``"mermaid_fenced"``, ``"graphml"`` or ``"elk"``
2402
+ :type format: str, optional
2403
+ :param filename: destination path or open text stream, defaults to None
2404
+ :type filename: str | file handle | None, optional
2405
+ :param direction: graph direction for DOT output, defaults to None
2406
+ :type direction: str, optional
2407
+ :return: rendered graph text
2408
+ :rtype: str
2409
+
2410
+ Creates graph text representing the blob hierarchy. If ``filename`` is
2411
+ provided, the rendered text is also written to it.
2412
+
2413
+ Mermaid outputs:
2414
+
2415
+ - ``"mermaid"`` returns plain Mermaid text.
2416
+ - ``"mermaid_fenced"`` returns Markdown fenced text suitable for
2417
+ direct display via ``IPython.display.Markdown``.
2418
+
2419
+ The blob hierarchy is represented as a directed graph where each node is
2420
+ a blob id and each edge points from parent blob to child blob.
2421
+
2422
+ :seealso: :meth:`dotfile` :meth:`parent` :meth:`children` :meth:`level`
2423
+ """
2424
+
2425
+ graph_format = format.lower().replace("-", "_")
2426
+
2427
+ def _parent_id(blob: Any) -> int:
2428
+ parent = blob.parent
2429
+ if parent is None:
2430
+ return -1
2431
+ if isinstance(parent, int):
2432
+ return parent
2433
+ return parent.id
2434
+
2435
+ hierarchy_edges = [(_parent_id(blob), blob.id) for blob in self]
2436
+ node_ids_set = {blob.id for blob in self}
2437
+ if any(parent_id == -1 for parent_id, _ in hierarchy_edges):
2438
+ node_ids_set.add(-1)
2439
+
2440
+ if graph_format == "dot":
2441
+ lines = ["digraph {"]
2442
+ if direction is not None:
2443
+ lines.append(f"rankdir = {direction}")
2444
+
2445
+ for node_id in sorted(node_ids_set):
2446
+ lines.append(f' "{node_id}"')
2447
+
2448
+ for parent_id, child_id in hierarchy_edges:
2449
+ lines.append(f' "{parent_id}" -> "{child_id}"')
2450
+
2451
+ lines.append("}")
2452
+ text = "\n".join(lines) + "\n"
2453
+
2454
+ elif graph_format in ("mermaid", "mermaid_fenced"):
2455
+ lines = ["flowchart TB"]
2456
+ node_ids: dict[Any, str] = {}
2457
+ sorted_nodes = sorted(node_ids_set)
2458
+
2459
+ for i, node in enumerate(sorted_nodes):
2460
+ node_alias = f"b{i}"
2461
+ node_ids[node] = node_alias
2462
+ lines.append(f' {node_alias}["{node}"]')
2463
+
2464
+ for parent_id, child_id in hierarchy_edges:
2465
+ lines.append(f" {node_ids[parent_id]} --> {node_ids[child_id]}")
2466
+
2467
+ mermaid_text = "\n".join(lines) + "\n"
2468
+ if graph_format == "mermaid_fenced":
2469
+ text = f"```mermaid\n{mermaid_text}```\n"
2470
+ else:
2471
+ text = mermaid_text
2472
+
2473
+ elif graph_format == "graphml":
2474
+
2475
+ def _xml_escape(value: str) -> str:
2476
+ return (
2477
+ value.replace("&", "&")
2478
+ .replace("<", "&lt;")
2479
+ .replace(">", "&gt;")
2480
+ .replace('"', "&quot;")
2481
+ .replace("'", "&apos;")
2482
+ )
2483
+
2484
+ lines = [
2485
+ '<?xml version="1.0" encoding="UTF-8"?>',
2486
+ '<graphml xmlns="http://graphml.graphdrawing.org/xmlns"',
2487
+ ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"',
2488
+ ' xsi:schemaLocation="http://graphml.graphdrawing.org/xmlns',
2489
+ ' http://graphml.graphdrawing.org/xmlns/1.0/graphml.xsd">',
2490
+ ' <key id="node_label" for="node" attr.name="label" attr.type="string"/>',
2491
+ ' <graph id="G" edgedefault="directed">',
2492
+ ]
2493
+
2494
+ for node in sorted(node_ids_set):
2495
+ node_id = f"n{node}"
2496
+ lines.extend(
2497
+ [
2498
+ f' <node id="{node_id}">',
2499
+ f' <data key="node_label">{_xml_escape(str(node))}</data>',
2500
+ " </node>",
2501
+ ]
2502
+ )
2503
+
2504
+ edge_id = 0
2505
+ for parent_id, child_id in hierarchy_edges:
2506
+ lines.append(
2507
+ f' <edge id="e{edge_id}" source="n{parent_id}" target="n{child_id}"/>'
2508
+ )
2509
+ edge_id += 1
2510
+
2511
+ lines.extend([" </graph>", "</graphml>"])
2512
+ text = "\n".join(lines) + "\n"
2513
+
2514
+ elif graph_format == "elk":
2515
+ children = []
2516
+ edges = []
2517
+
2518
+ for node in sorted(node_ids_set):
2519
+ children.append(
2520
+ {
2521
+ "id": f"n{node}",
2522
+ "labels": [{"text": str(node)}],
2523
+ "width": 60,
2524
+ "height": 40,
2525
+ }
2526
+ )
2527
+
2528
+ edge_id = 0
2529
+ for parent_id, child_id in hierarchy_edges:
2530
+ edges.append(
2531
+ {
2532
+ "id": f"e{edge_id}",
2533
+ "sources": [f"n{parent_id}"],
2534
+ "targets": [f"n{child_id}"],
2535
+ }
2536
+ )
2537
+ edge_id += 1
2538
+
2539
+ elk_graph = {
2540
+ "id": "root",
2541
+ "layoutOptions": {
2542
+ "org.eclipse.elk.algorithm": "layered",
2543
+ "org.eclipse.elk.direction": "DOWN",
2544
+ },
2545
+ "children": children,
2546
+ "edges": edges,
2547
+ }
2548
+ text = json.dumps(elk_graph, indent=2) + "\n"
2549
+
2550
+ else:
2551
+ raise ValueError(
2552
+ "unsupported graph format "
2553
+ f"'{format}', expected 'dot', 'mermaid', 'mermaid_fenced', 'graphml' or 'elk'"
2554
+ )
2555
+
2556
+ if filename is not None:
2557
+ if isinstance(filename, str):
2558
+ with open(filename, "w") as file:
2559
+ file.write(text)
2560
+ else:
2561
+ filename.write(text)
2562
+
2563
+ return text
2564
+
2383
2565
  def dotfile(
2384
2566
  self, filename: Any = None, direction: str | None = None, show: bool = False
2385
2567
  ) -> None:
@@ -2400,53 +2582,51 @@ class Blobs(UserList): # lgtm[py/missing-equals]
2400
2582
  .. note:: If ``filename`` is a file object then the file will *not*
2401
2583
  be closed after the GraphViz model is written.
2402
2584
 
2403
- :seealso: :meth:`child` :meth:`parent` :meth:`level`
2585
+ :seealso: :meth:`graph` :meth:`child` :meth:`parent` :meth:`level`
2404
2586
  """
2405
2587
 
2406
2588
  if show:
2407
2589
  # create the temporary dotfile
2408
2590
  filename = tempfile.TemporaryFile(mode="w")
2591
+ dot_text = self.graph(format="dot", direction=direction)
2592
+
2409
2593
  if filename is None:
2410
2594
  f = sys.stdout
2595
+ f.write(dot_text)
2411
2596
  elif isinstance(filename, str):
2412
- f = open(filename, "w")
2597
+ with open(filename, "w") as f:
2598
+ f.write(dot_text)
2413
2599
  else:
2414
2600
  f = filename
2415
-
2416
- print("digraph {", file=f)
2417
-
2418
- if direction is not None:
2419
- print(f"rankdir = {direction}", file=f)
2420
-
2421
- # add the nodes including name and position
2422
- for id, blob in enumerate(self):
2423
- print(' "{:d}"'.format(id), file=f)
2424
- print(
2425
- ' "{:d}" -> "{:d}"'.format(blob.parent if blob.parent else -1, id),
2426
- file=f,
2427
- )
2428
-
2429
- print("}", file=f)
2601
+ f.write(dot_text)
2430
2602
 
2431
2603
  if show:
2432
2604
  # rewind the dot file, create PDF file in the filesystem, run dot
2433
- f.seek(0)
2605
+ if filename is None:
2606
+ dot_stream = tempfile.TemporaryFile(mode="w+")
2607
+ dot_stream.write(dot_text)
2608
+ dot_stream.seek(0)
2609
+ elif isinstance(filename, str):
2610
+ dot_stream = open(filename, "r")
2611
+ else:
2612
+ f.seek(0)
2613
+ dot_stream = f
2614
+
2434
2615
  pdffile = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False)
2435
2616
  subprocess.run(
2436
2617
  ["dot", "-Tpdf"],
2437
- stdin=cast(Any, f),
2618
+ stdin=cast(Any, dot_stream),
2438
2619
  stdout=pdffile,
2439
2620
  check=True,
2440
2621
  )
2441
2622
 
2442
2623
  # open the PDF file in browser (hopefully portable), then cleanup
2443
2624
  webbrowser.open(f"file://{pdffile.name}")
2444
- else:
2445
2625
  if filename is None or isinstance(filename, str):
2446
- f.close() # noqa
2626
+ dot_stream.close()
2447
2627
 
2448
2628
 
2449
- class ImageBlobsMixin:
2629
+ class ImageBlobsMixin(_ImageBase if TYPE_CHECKING else object):
2450
2630
  def blobs(self, **kwargs) -> Blobs:
2451
2631
  """
2452
2632
  Find and describe blobs in image
@@ -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]