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.
- {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.2.0.dist-info}/METADATA +13 -5
- {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.2.0.dist-info}/RECORD +23 -33
- {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.2.0.dist-info}/WHEEL +1 -1
- machinevisiontoolbox/BundleAdjust.py +1 -1
- machinevisiontoolbox/Camera.py +95 -80
- machinevisiontoolbox/ImageBlobs.py +10 -4
- machinevisiontoolbox/ImageCore.py +105 -97
- machinevisiontoolbox/ImageFiducials.py +62 -10
- machinevisiontoolbox/ImageIO.py +5 -1
- machinevisiontoolbox/ImageLineFeatures.py +9 -1
- machinevisiontoolbox/ImagePointFeatures.py +29 -8
- machinevisiontoolbox/ImageProcessing.py +1 -1
- machinevisiontoolbox/ImageRegionFeatures.py +25 -6
- machinevisiontoolbox/ImageWholeFeatures.py +113 -72
- machinevisiontoolbox/Sources.py +1 -1
- machinevisiontoolbox/__init__.py +1 -1
- machinevisiontoolbox/base/imageio.py +68 -33
- machinevisiontoolbox/bin/_iminteractive.py +461 -0
- machinevisiontoolbox/bin/bagtool.py +120 -6
- machinevisiontoolbox/bin/imtool.py +34 -5
- machinevisiontoolbox/bin/mvtbtool.py +93 -18
- machinevisiontoolbox/ImageCore.docstring +0 -2877
- machinevisiontoolbox/aruco0.pdf +0 -0
- machinevisiontoolbox/aruco50.pdf +0 -0
- machinevisiontoolbox/bin/labeltool.py +0 -147
- machinevisiontoolbox/blocks/Icons/icons.sh +0 -4
- machinevisiontoolbox/cvfuncs.py +0 -40
- machinevisiontoolbox/docbugs.py +0 -47
- machinevisiontoolbox/newcameras.py +0 -55
- machinevisiontoolbox/test_skimage.py +0 -12
- machinevisiontoolbox/testblobplots.py +0 -17
- machinevisiontoolbox/x.json +0 -296
- {machinevision_toolbox_python-2.0.1.dist-info → machinevision_toolbox_python-2.2.0.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
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
|
-
|
|
156
|
-
|
|
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",
|
|
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=
|
|
244
|
+
img = Image.Read(file, alpha=args.alpha)
|
|
216
245
|
print(img)
|
|
217
246
|
except ValueError:
|
|
218
247
|
print(f"File {file} not found")
|