machinevision-toolbox-python 2.0.1__py3-none-any.whl → 2.2.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 (34) hide show
  1. {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.2.0.dist-info}/METADATA +13 -5
  2. {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.2.0.dist-info}/RECORD +23 -33
  3. {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.2.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/ImageFiducials.py +62 -10
  9. machinevisiontoolbox/ImageIO.py +5 -1
  10. machinevisiontoolbox/ImageLineFeatures.py +9 -1
  11. machinevisiontoolbox/ImagePointFeatures.py +29 -8
  12. machinevisiontoolbox/ImageProcessing.py +1 -1
  13. machinevisiontoolbox/ImageRegionFeatures.py +25 -6
  14. machinevisiontoolbox/ImageWholeFeatures.py +113 -72
  15. machinevisiontoolbox/Sources.py +1 -1
  16. machinevisiontoolbox/__init__.py +1 -1
  17. machinevisiontoolbox/base/imageio.py +68 -33
  18. machinevisiontoolbox/bin/_iminteractive.py +461 -0
  19. machinevisiontoolbox/bin/bagtool.py +120 -6
  20. machinevisiontoolbox/bin/imtool.py +34 -5
  21. machinevisiontoolbox/bin/mvtbtool.py +93 -18
  22. machinevisiontoolbox/ImageCore.docstring +0 -2877
  23. machinevisiontoolbox/aruco0.pdf +0 -0
  24. machinevisiontoolbox/aruco50.pdf +0 -0
  25. machinevisiontoolbox/bin/labeltool.py +0 -147
  26. machinevisiontoolbox/blocks/Icons/icons.sh +0 -4
  27. machinevisiontoolbox/cvfuncs.py +0 -40
  28. machinevisiontoolbox/docbugs.py +0 -47
  29. machinevisiontoolbox/newcameras.py +0 -55
  30. machinevisiontoolbox/test_skimage.py +0 -12
  31. machinevisiontoolbox/testblobplots.py +0 -17
  32. machinevisiontoolbox/x.json +0 -296
  33. {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.2.0.dist-info}/entry_points.txt +0 -0
  34. {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,461 @@
1
+ """
2
+ Shared interactive rectangle / line-profile overlay for MVTB image display tools.
3
+
4
+ Provides :class:`ImageInteractor`, which wires keyboard and mouse callbacks for
5
+ rectangle selection, edge-handle dragging, intensity line profiling, and ROI
6
+ histogram display onto any matplotlib figure that is showing an
7
+ :class:`~machinevisiontoolbox.Image`.
8
+ """
9
+
10
+ import faulthandler
11
+ import matplotlib.pyplot as plt
12
+ from matplotlib.patches import Circle, Rectangle
13
+ import numpy as np
14
+
15
+ # Enable C-level crash tracebacks (SIGSEGV, SIGBUS, etc.)
16
+ faulthandler.enable()
17
+
18
+
19
+ def _guard_popup_close(fig) -> None:
20
+ """
21
+ Defuse pending draw_idle timers when a popup figure window is closed.
22
+
23
+ macOS-specific race: ``draw_idle()`` schedules a one-shot NSTimer whose
24
+ Python callback is ``_draw_idle()``. ``_draw_idle()`` gates itself with
25
+ the ``_draw_pending`` flag: when the flag is *False* it returns immediately
26
+ without touching the C-level canvas. Setting the flag to *False* inside
27
+ the ``close_event`` handler (which fires while the canvas is still fully
28
+ alive) is therefore enough to neutralise any timer that is still in flight
29
+ after the window is destroyed -- the timer fires, ``_draw_idle`` sees
30
+ ``_draw_pending=False`` and exits without calling ``draw()``, so no
31
+ CoreGraphics access occurs on a freed canvas.
32
+
33
+ This is backend-agnostic: on Qt/Tk/GTK ``_draw_pending`` is either absent
34
+ or ``False`` already, so the assignment is a harmless no-op.
35
+ """
36
+
37
+ def _on_close(event):
38
+ event.canvas._draw_pending = False # defuse pending draw_idle timer
39
+
40
+ fig.canvas.mpl_connect("close_event", _on_close)
41
+
42
+
43
+ # Named colours for standard RGBA image planes.
44
+ _PLANE_COLORS: dict[str, str] = {
45
+ "R": "red",
46
+ "G": "yellowgreen",
47
+ "B": "blue",
48
+ "A": "black",
49
+ }
50
+
51
+ _HELP_HEADER = (
52
+ "\n"
53
+ "Image interactive shortcuts\n"
54
+ "───────────────────────────────────────\n"
55
+ " left-drag draw a selection rectangle\n"
56
+ " (only when toolbar zoom/pan is inactive)\n"
57
+ " drag handle resize rectangle edge\n"
58
+ " shift + left-drag draw a line for intensity profile\n"
59
+ "\n"
60
+ " h histogram of selection\n"
61
+ " c cumulative histogram of selection\n"
62
+ " p pop selection out into new window\n"
63
+ "\n"
64
+ " ? show this help\n"
65
+ "\n"
66
+ "Note: popup windows are unstable with the macOS backend.\n"
67
+ " Use --backend Qt5Agg for reliable operation.\n"
68
+ )
69
+
70
+
71
+ class ImageInteractor:
72
+ """
73
+ Attach interactive rectangle / line-profile callbacks to a matplotlib
74
+ figure currently displaying a :class:`~machinevisiontoolbox.Image`.
75
+
76
+ :param fig: matplotlib figure
77
+ :param ax: axes within *fig* that display the image
78
+ :param image: the Image currently displayed
79
+ :param nav_help: optional extra help text appended to the standard
80
+ shortcut table when the user presses ``?``
81
+
82
+ After construction the interactor is immediately live. Call
83
+ :meth:`update_image` whenever the displayed frame changes (e.g. when
84
+ stepping through a bag file) to reset selection state. Call
85
+ :meth:`detach` to disconnect all callbacks.
86
+ """
87
+
88
+ def __init__(self, fig, ax, image, nav_help: str = "") -> None:
89
+ self._fig = fig
90
+ self._ax = ax
91
+ self._image = image
92
+ self._nav_help = nav_help
93
+
94
+ self._s: dict = {
95
+ "rect": None, # [u0, v0, u1, v1] raw drag coords
96
+ "rect_patch": None, # matplotlib Rectangle patch
97
+ "handles": {}, # edge-name -> Circle patch
98
+ "drag_start": None, # (u, v) on press
99
+ "drag_mode": None, # 'rect' | 'line' | 'handle' | None
100
+ "active_handle": None, # 'left' | 'right' | 'top' | 'bottom'
101
+ "line_patch": None, # Line2D for live line preview
102
+ "line_start": None, # (u, v) of line press
103
+ }
104
+
105
+ self._alt_down: bool = False # tracked via key press/release events
106
+
107
+ # Strong references to popup figures. matplotlib's Gcf drops its
108
+ # reference when a popup window is closed, which frees the C-level
109
+ # FigureCanvas (NSView). Any pending draw_idle NSTimer then fires on
110
+ # freed memory → SIGSEGV. Keeping the Figure alive here keeps the
111
+ # NSView alive until detach() so the timer fires safely on a
112
+ # detached-but-valid view.
113
+ self._popup_figs: list = []
114
+
115
+ self._cids = [
116
+ fig.canvas.mpl_connect("button_press_event", self._on_press),
117
+ fig.canvas.mpl_connect("motion_notify_event", self._on_motion),
118
+ fig.canvas.mpl_connect("button_release_event", self._on_release),
119
+ fig.canvas.mpl_connect("key_press_event", self._on_key),
120
+ fig.canvas.mpl_connect("key_release_event", self._on_key_release),
121
+ ]
122
+
123
+ # Keep a strong reference on the figure so the interactor is not
124
+ # garbage-collected while the figure is open (matplotlib uses
125
+ # WeakMethod internally for bound-method callbacks).
126
+ fig._iminteractor = self
127
+
128
+ # ------------------------------------------------------------------
129
+ # Public interface
130
+ # ------------------------------------------------------------------
131
+
132
+ def update_image(self, image) -> None:
133
+ """Reset selection state and update the image reference for a new frame."""
134
+ self._image = image
135
+ if self._s["rect"] is not None:
136
+ self._clear_selection()
137
+ if self._s["line_patch"] is not None:
138
+ self._s["line_patch"].remove()
139
+ self._s["line_patch"] = None
140
+ self._s["drag_mode"] = None
141
+ self._s["drag_start"] = None
142
+ self._s["line_start"] = None
143
+ self._fig.canvas.draw_idle()
144
+
145
+ def detach(self) -> None:
146
+ """Disconnect all event callbacks from the figure."""
147
+ for cid in self._cids:
148
+ self._fig.canvas.mpl_disconnect(cid)
149
+ self._cids.clear()
150
+ if getattr(self._fig, "_iminteractor", None) is self:
151
+ del self._fig._iminteractor
152
+ self._popup_figs.clear() # allow GC once event loop has stopped
153
+
154
+ # ------------------------------------------------------------------
155
+ # Private helpers
156
+ # ------------------------------------------------------------------
157
+
158
+ @property
159
+ def _handle_r(self) -> float:
160
+ return max(self._image.width, self._image.height) * 0.010
161
+
162
+ def _toolbar_active(self) -> bool:
163
+ """Return True when the MPL toolbar has zoom or pan active."""
164
+ try:
165
+ mode = str(self._fig.canvas.manager.toolbar.mode)
166
+ return bool(mode)
167
+ except Exception:
168
+ return False
169
+
170
+ def _norm_rect(self):
171
+ u0, v0, u1, v1 = self._s["rect"]
172
+ return min(u0, u1), min(v0, v1), max(u0, u1), max(v0, v1)
173
+
174
+ def _update_rect_patch(self) -> None:
175
+ u0, v0, u1, v1 = self._norm_rect()
176
+ p = self._s["rect_patch"]
177
+ if p is None:
178
+ p = Rectangle(
179
+ (u0, v0),
180
+ u1 - u0,
181
+ v1 - v0,
182
+ linewidth=1.5,
183
+ edgecolor="yellow",
184
+ facecolor="none",
185
+ linestyle="--",
186
+ )
187
+ self._ax.add_patch(p)
188
+ self._s["rect_patch"] = p
189
+ else:
190
+ p.set_xy((u0, v0))
191
+ p.set_width(u1 - u0)
192
+ p.set_height(v1 - v0)
193
+
194
+ def _update_handles(self) -> None:
195
+ u0, v0, u1, v1 = self._norm_rect()
196
+ cx, cy = (u0 + u1) / 2, (v0 + v1) / 2
197
+ positions = {
198
+ "left": (u0, cy),
199
+ "right": (u1, cy),
200
+ "top": (cx, v0),
201
+ "bottom": (cx, v1),
202
+ }
203
+ r = self._handle_r
204
+ for hname, pos in positions.items():
205
+ if hname in self._s["handles"]:
206
+ self._s["handles"][hname].center = pos
207
+ else:
208
+ c = Circle(
209
+ pos,
210
+ r,
211
+ edgecolor="yellow",
212
+ facecolor="white",
213
+ linewidth=1.5,
214
+ zorder=5,
215
+ )
216
+ self._ax.add_patch(c)
217
+ self._s["handles"][hname] = c
218
+
219
+ def _clear_selection(self) -> None:
220
+ if self._s["rect_patch"] is not None:
221
+ self._s["rect_patch"].remove()
222
+ self._s["rect_patch"] = None
223
+ for c in self._s["handles"].values():
224
+ c.remove()
225
+ self._s["handles"].clear()
226
+ self._s["rect"] = None
227
+
228
+ def _hit_handle(self, u: float, v: float):
229
+ r = self._handle_r
230
+ for hname, c in self._s["handles"].items():
231
+ cx, cy = c.center
232
+ if (u - cx) ** 2 + (v - cy) ** 2 <= r**2:
233
+ return hname
234
+ return None
235
+
236
+ def _roi_image(self):
237
+ image = self._image
238
+ u0, v0, u1, v1 = self._norm_rect()
239
+ u0 = max(0, min(int(round(u0)), image.width - 1))
240
+ v0 = max(0, min(int(round(v0)), image.height - 1))
241
+ u1 = max(0, min(int(round(u1)), image.width - 1))
242
+ v1 = max(0, min(int(round(v1)), image.height - 1))
243
+ return image.roi([u0, u1, v0, v1])
244
+
245
+ def _show_profile(self, u0: float, v0: float, u1: float, v1: float) -> None:
246
+ image = self._image
247
+ length = max(2, int(round(np.hypot(u1 - u0, v1 - v0))))
248
+ us = np.clip(np.linspace(u0, u1, length), 0, image.width - 1)
249
+ vs = np.clip(np.linspace(v0, v1, length), 0, image.height - 1)
250
+ ui = np.round(us).astype(int)
251
+ vi = np.round(vs).astype(int)
252
+
253
+ arr = image.array
254
+ pfig, pax = plt.subplots()
255
+ if pfig.canvas.manager is not None:
256
+ pfig.canvas.manager.set_window_title("Intensity profile")
257
+
258
+ if arr.ndim == 2:
259
+ pax.plot(arr[vi, ui])
260
+ else:
261
+ names = (image.colororder_str or "").split(":")
262
+ for p in range(arr.shape[2]):
263
+ label = names[p] if p < len(names) else f"plane {p}"
264
+ color = _PLANE_COLORS.get(label)
265
+ pax.plot(arr[vi, ui, p], label=label, color=color)
266
+ pax.legend()
267
+
268
+ pax.set_xlabel("Distance along line (pixels)")
269
+ pax.set_ylabel("Intensity")
270
+ pax.set_xlim(0, length - 1)
271
+ pax.grid(True)
272
+ pax.set_title(
273
+ f"Profile ({u0:.0f},\u202f{v0:.0f}) \u2192 ({u1:.0f},\u202f{v1:.0f})"
274
+ )
275
+ pfig.canvas.draw_idle() # safe from event callbacks – just schedules NSTimer
276
+ self._popup_figs.append(pfig) # keep Python Figure alive (timer closure guard)
277
+ _guard_popup_close(pfig)
278
+ pfig.show()
279
+
280
+ # ------------------------------------------------------------------
281
+ # Event callbacks
282
+ # ------------------------------------------------------------------
283
+
284
+ def _on_press(self, event) -> None:
285
+ if event.inaxes is not self._ax or event.button != 1:
286
+ return
287
+ u, v = event.xdata, event.ydata
288
+ if u is None or v is None:
289
+ return
290
+
291
+ if event.key == "shift":
292
+ self._s["drag_mode"] = "line"
293
+ self._s["line_start"] = (u, v)
294
+ if self._s["line_patch"] is not None:
295
+ self._s["line_patch"].remove()
296
+ self._s["line_patch"] = None
297
+ elif not self._toolbar_active():
298
+ hit = self._hit_handle(u, v) if self._s["rect"] is not None else None
299
+ if hit:
300
+ self._s["drag_mode"] = "handle"
301
+ self._s["active_handle"] = hit
302
+ else:
303
+ self._clear_selection()
304
+ self._s["drag_mode"] = "rect"
305
+ self._s["drag_start"] = (u, v)
306
+
307
+ def _on_motion(self, event) -> None:
308
+ if event.inaxes is not self._ax or event.button != 1:
309
+ return
310
+ u, v = event.xdata, event.ydata
311
+ if u is None or v is None:
312
+ return
313
+
314
+ if self._s["drag_mode"] == "rect":
315
+ u0, v0 = self._s["drag_start"]
316
+ self._s["rect"] = [u0, v0, u, v]
317
+ self._update_rect_patch()
318
+ self._fig.canvas.draw_idle()
319
+
320
+ elif self._s["drag_mode"] == "line":
321
+ u0, v0 = self._s["line_start"]
322
+ if self._s["line_patch"] is None:
323
+ (line,) = self._ax.plot(
324
+ [u0, u],
325
+ [v0, v],
326
+ color="yellow",
327
+ linestyle="--",
328
+ linewidth=1.5,
329
+ zorder=5,
330
+ )
331
+ self._s["line_patch"] = line
332
+ else:
333
+ self._s["line_patch"].set_data([u0, u], [v0, v])
334
+ self._fig.canvas.draw_idle()
335
+
336
+ elif self._s["drag_mode"] == "handle":
337
+ ha = self._s["active_handle"]
338
+ if ha == "left":
339
+ self._s["rect"][0] = u
340
+ elif ha == "right":
341
+ self._s["rect"][2] = u
342
+ elif ha == "top":
343
+ self._s["rect"][1] = v
344
+ elif ha == "bottom":
345
+ self._s["rect"][3] = v
346
+ self._update_rect_patch()
347
+ self._update_handles()
348
+ self._fig.canvas.draw_idle()
349
+
350
+ def _on_release(self, event) -> None:
351
+ if event.button != 1:
352
+ return
353
+ mode = self._s["drag_mode"]
354
+ self._s["drag_mode"] = None
355
+
356
+ if mode == "rect" and self._s["rect"] is not None:
357
+ u0, v0, u1, v1 = self._norm_rect()
358
+ if (u1 - u0) > 3 and (v1 - v0) > 3:
359
+ self._update_handles()
360
+ else:
361
+ self._clear_selection()
362
+ self._fig.canvas.draw_idle()
363
+
364
+ elif mode == "line":
365
+ u, v = event.xdata, event.ydata
366
+ if self._s["line_patch"] is not None and u is not None and v is not None:
367
+ u0, v0 = self._s["line_start"]
368
+ if np.hypot(u - u0, v - v0) > 3:
369
+ self._show_profile(u0, v0, u, v)
370
+ # Always clear the guide line on release so a subsequent
371
+ # plain left-drag creates a fresh rectangle rather than
372
+ # updating the old line.
373
+ if self._s["line_patch"] is not None:
374
+ self._s["line_patch"].remove()
375
+ self._s["line_patch"] = None
376
+ self._s["line_start"] = None
377
+ self._fig.canvas.draw_idle()
378
+
379
+ elif mode == "handle":
380
+ self._fig.canvas.draw_idle()
381
+
382
+ def _on_key(self, event) -> None:
383
+ if event.key in ("alt", "Alt_L", "Alt_R"):
384
+ self._alt_down = True
385
+ return
386
+
387
+ if event.key == "?":
388
+ msg = _HELP_HEADER
389
+ if self._nav_help:
390
+ msg += self._nav_help
391
+ print(msg)
392
+ return
393
+
394
+ if self._s["rect"] is not None:
395
+ roi = self._roi_image()
396
+ # strip modifier prefix: macOS reports 'alt+h', 'ctrl+h', etc.
397
+ base_key = event.key.split("+")[-1] if event.key else ""
398
+ if base_key == "h":
399
+ self._popup_hist(roi, cumulative=False)
400
+ elif base_key == "c":
401
+ self._popup_hist(roi, cumulative=True)
402
+ elif base_key == "p":
403
+ self._popup_roi(roi)
404
+
405
+ def _defer(self, fn) -> None:
406
+ """Run *fn* after the current event callback returns via a one-shot timer."""
407
+ timer = self._fig.canvas.new_timer(interval=0)
408
+
409
+ def _fire():
410
+ try:
411
+ fn()
412
+ except Exception:
413
+ import traceback
414
+
415
+ traceback.print_exc()
416
+ finally:
417
+ timer.stop()
418
+
419
+ timer.add_callback(_fire)
420
+ timer.start()
421
+
422
+ def _popup_hist(self, roi, *, cumulative: bool) -> None:
423
+ try:
424
+ label = "Cumulative histogram" if cumulative else "Histogram"
425
+ title = f"{label} \u2013 ROI"
426
+ kwargs = dict(block=None, cursor=True, title=title)
427
+ if cumulative:
428
+ kwargs["type"] = "cdf"
429
+ roi.hist().plot(**kwargs)
430
+ fig = plt.gcf()
431
+ if fig.canvas.manager is not None:
432
+ fig.canvas.manager.set_window_title(title)
433
+ fig.canvas.draw_idle() # safe from event callbacks – just schedules NSTimer
434
+ self._popup_figs.append(
435
+ fig
436
+ ) # keep Python Figure alive (timer closure guard)
437
+ _guard_popup_close(fig)
438
+ fig.show()
439
+ except Exception:
440
+ import traceback
441
+
442
+ traceback.print_exc()
443
+
444
+ def _popup_roi(self, roi) -> None:
445
+ try:
446
+ roi.disp(block=None, title="ROI")
447
+ fig = plt.gcf()
448
+ fig.canvas.draw_idle() # safe from event callbacks – just schedules NSTimer
449
+ self._popup_figs.append(
450
+ fig
451
+ ) # keep Python Figure alive (timer closure guard)
452
+ _guard_popup_close(fig)
453
+ fig.show()
454
+ except Exception:
455
+ import traceback
456
+
457
+ traceback.print_exc()
458
+
459
+ def _on_key_release(self, event) -> None:
460
+ if event.key in ("alt", "Alt_L", "Alt_R"):
461
+ self._alt_down = False
@@ -194,6 +194,117 @@ def getargs():
194
194
  return parser.parse_args()
195
195
 
196
196
 
197
+ def _display_frames_interactive(frames, title, args) -> None:
198
+ """
199
+ Step through a pre-collected frame list with interactive selection overlay.
200
+
201
+ Navigation keys (space / numbers / q / x) are wired to a local handler;
202
+ the :class:`~machinevisiontoolbox.bin._iminteractive.ImageInteractor`
203
+ provides rectangle, line-profile, and histogram keys on top.
204
+
205
+ Keys 'c' and 'h' are deliberately excluded from the jump-key table to
206
+ avoid conflicts with the interactor's cumulative-histogram and histogram
207
+ shortcuts.
208
+ """
209
+ from datetime import datetime, timezone
210
+ import matplotlib.pyplot as plt
211
+ from machinevisiontoolbox.bin._iminteractive import ImageInteractor
212
+
213
+ _jump = {
214
+ "1": 1,
215
+ "2": 2,
216
+ "3": 3,
217
+ "4": 4,
218
+ "5": 5,
219
+ "6": 6,
220
+ "7": 7,
221
+ "8": 8,
222
+ "9": 9,
223
+ "0": 10,
224
+ "l": 50,
225
+ "d": 500,
226
+ }
227
+
228
+ def _label(item, i):
229
+ ts = getattr(item, "timestamp", None)
230
+ if ts is not None:
231
+ dt = datetime.fromtimestamp(ts / 1e9, tz=timezone.utc).astimezone()
232
+ return dt.isoformat(timespec="milliseconds") + f" [{i}]"
233
+ return f"[{i}]"
234
+
235
+ view_state = {"next": False, "quit": False, "skip": 0}
236
+
237
+ def on_key_nav(event, s=view_state):
238
+ if event.key == " ":
239
+ s["next"] = True
240
+ elif event.key in ("q", "x"):
241
+ s["quit"] = True
242
+ elif event.key in _jump:
243
+ s["skip"] = _jump[event.key]
244
+ s["next"] = True
245
+
246
+ fig = plt.figure()
247
+ ax = fig.add_subplot(111)
248
+ ts_text = fig.text(
249
+ 0,
250
+ 0,
251
+ "",
252
+ backgroundcolor="black",
253
+ color="white",
254
+ horizontalalignment="left",
255
+ verticalalignment="bottom",
256
+ )
257
+ fig.canvas.mpl_connect("key_press_event", on_key_nav)
258
+
259
+ _nav_help = (
260
+ "\nNavigation\n"
261
+ "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"
262
+ " space next frame\n"
263
+ " 1-9 / 0 jump 1-9 / 10 frames\n"
264
+ " l / d jump 50 / 500 frames\n"
265
+ " q / x quit\n"
266
+ )
267
+ print("\nKeys: [space] next [1-9/0] jump [l/d] jump 50/500 [q/x] quit [?] help")
268
+
269
+ interactor = None
270
+ it = iter(frames)
271
+ i = 0
272
+ while True:
273
+ try:
274
+ x = next(it)
275
+ except StopIteration:
276
+ break
277
+ if view_state["quit"]:
278
+ break
279
+
280
+ frame_title = title or getattr(x, "topic", None) or ""
281
+ x.disp(title=frame_title, ax=ax, reuse=True, grid=args.grid, badcolor="red")
282
+ ts_text.set_text(_label(x, i))
283
+
284
+ if interactor is None:
285
+ interactor = ImageInteractor(fig, ax, x, nav_help=_nav_help)
286
+ else:
287
+ interactor.update_image(x)
288
+
289
+ view_state["next"] = False
290
+ while not view_state["next"] and not view_state["quit"]:
291
+ plt.pause(0.05)
292
+
293
+ skip = view_state["skip"]
294
+ view_state["skip"] = 0
295
+ for _ in range(skip):
296
+ try:
297
+ next(it)
298
+ i += 1
299
+ except StopIteration:
300
+ break
301
+ i += 1
302
+
303
+ if interactor is not None:
304
+ interactor.detach()
305
+ plt.close(fig)
306
+
307
+
197
308
  def main():
198
309
  args = getargs()
199
310
 
@@ -274,12 +385,15 @@ def main():
274
385
  title=os.path.basename(filename),
275
386
  )
276
387
  else:
277
- ImageSequence(frames).disp(
278
- animate=args.animate,
279
- title=filename,
280
- grid=args.grid,
281
- badcolor="red",
282
- )
388
+ if args.animate:
389
+ ImageSequence(frames).disp(
390
+ animate=True,
391
+ title=filename,
392
+ grid=args.grid,
393
+ badcolor="red",
394
+ )
395
+ else:
396
+ _display_frames_interactive(frames, filename, args)
283
397
 
284
398
 
285
399
  if __name__ == "__main__":
@@ -14,7 +14,7 @@ from colored import Fore, Style
14
14
  from machinevisiontoolbox import Image
15
15
  from machinevisiontoolbox.bin._bintools import CustomDefaultsHelpFormatter, MVTB_LINK
16
16
  from ansitable import ANSITable, Column
17
- import matplotlib.pyplot as plt
17
+ import matplotlib
18
18
  import numpy as np
19
19
 
20
20
  # Set by figure keypress callback when the user requests immediate exit.
@@ -62,11 +62,28 @@ def getargs():
62
62
  parser.add_argument(
63
63
  "--verbose", "-v", help="Show image details", action="store_true"
64
64
  )
65
+ parser.add_argument(
66
+ "--backend",
67
+ "-B",
68
+ default=None,
69
+ metavar="BACKEND",
70
+ help="Matplotlib backend to use, e.g. TkAgg, Qt5Agg, MacOSX (default: system default)",
71
+ )
72
+ parser.add_argument(
73
+ "--alpha",
74
+ "-a",
75
+ action="store_true",
76
+ default=False,
77
+ help="Read and display the alpha channel if present (default: strip alpha)",
78
+ )
65
79
 
66
80
  return parser.parse_args()
67
81
 
68
82
 
69
83
  def visualize_image(image, args, block):
84
+ import matplotlib.pyplot as plt
85
+ from machinevisiontoolbox.bin._iminteractive import ImageInteractor
86
+
70
87
  if args.verbose:
71
88
  print(image)
72
89
 
@@ -151,15 +168,21 @@ def visualize_image(image, args, block):
151
168
 
152
169
  # 'q' keeps existing figure-close behaviour; 'x' exits all remaining files.
153
170
  fig = plt.gcf()
171
+ ax = fig.gca()
154
172
 
155
- def on_key(event):
156
- global exit_requested
173
+ # Attach interactive rectangle / line-profile overlay.
174
+ _nav_help = (
175
+ " x close image and advance to next file\n" " q close window\n"
176
+ )
177
+ ImageInteractor(fig, ax, image, nav_help=_nav_help)
157
178
 
179
+ def _on_exit(event):
180
+ global exit_requested
158
181
  if event.key == "x":
159
182
  exit_requested = True
160
183
  plt.close(event.canvas.figure)
161
184
 
162
- fig.canvas.mpl_connect("key_press_event", on_key)
185
+ fig.canvas.mpl_connect("key_press_event", _on_exit)
163
186
 
164
187
  if block:
165
188
  plt.show(block=True)
@@ -207,12 +230,18 @@ def main():
207
230
 
208
231
  args = getargs()
209
232
 
233
+ if args.backend is not None:
234
+ matplotlib.use(args.backend)
235
+
236
+ import matplotlib.pyplot as plt # noqa: F401 — initialises backend, used via globals
237
+ from machinevisiontoolbox.bin._iminteractive import ImageInteractor # noqa: F401
238
+
210
239
  if len(args.files) > 10:
211
240
  args.block = True
212
241
 
213
242
  for i, file in enumerate(args.files):
214
243
  try:
215
- img = Image.Read(file, alpha=True)
244
+ img = Image.Read(file, alpha=args.alpha)
216
245
  print(img)
217
246
  except ValueError:
218
247
  print(f"File {file} not found")