pyvale 2025.5.3__cp311-cp311-win32.whl → 2025.7.0__cp311-cp311-win32.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.

Potentially problematic release.


This version of pyvale might be problematic. Click here for more details.

Files changed (93) hide show
  1. pyvale/__init__.py +12 -0
  2. pyvale/blendercalibrationdata.py +3 -1
  3. pyvale/blenderscene.py +7 -5
  4. pyvale/blendertools.py +27 -5
  5. pyvale/camera.py +1 -0
  6. pyvale/cameradata.py +3 -0
  7. pyvale/camerasensor.py +147 -0
  8. pyvale/camerastereo.py +4 -4
  9. pyvale/cameratools.py +23 -61
  10. pyvale/cython/rastercyth.c +1657 -1352
  11. pyvale/cython/rastercyth.cp311-win32.pyd +0 -0
  12. pyvale/cython/rastercyth.py +71 -26
  13. pyvale/data/plate_hole_def0000.tiff +0 -0
  14. pyvale/data/plate_hole_def0001.tiff +0 -0
  15. pyvale/data/plate_hole_ref0000.tiff +0 -0
  16. pyvale/data/plate_rigid_def0000.tiff +0 -0
  17. pyvale/data/plate_rigid_def0001.tiff +0 -0
  18. pyvale/data/plate_rigid_ref0000.tiff +0 -0
  19. pyvale/dataset.py +96 -6
  20. pyvale/dic/cpp/dicbruteforce.cpp +370 -0
  21. pyvale/dic/cpp/dicfourier.cpp +648 -0
  22. pyvale/dic/cpp/dicinterpolator.cpp +559 -0
  23. pyvale/dic/cpp/dicmain.cpp +215 -0
  24. pyvale/dic/cpp/dicoptimizer.cpp +675 -0
  25. pyvale/dic/cpp/dicrg.cpp +137 -0
  26. pyvale/dic/cpp/dicscanmethod.cpp +677 -0
  27. pyvale/dic/cpp/dicsmooth.cpp +138 -0
  28. pyvale/dic/cpp/dicstrain.cpp +383 -0
  29. pyvale/dic/cpp/dicutil.cpp +563 -0
  30. pyvale/dic2d.py +164 -0
  31. pyvale/dic2dcpp.cp311-win32.pyd +0 -0
  32. pyvale/dicchecks.py +476 -0
  33. pyvale/dicdataimport.py +247 -0
  34. pyvale/dicregionofinterest.py +887 -0
  35. pyvale/dicresults.py +55 -0
  36. pyvale/dicspecklegenerator.py +238 -0
  37. pyvale/dicspecklequality.py +305 -0
  38. pyvale/dicstrain.py +387 -0
  39. pyvale/dicstrainresults.py +37 -0
  40. pyvale/errorintegrator.py +10 -8
  41. pyvale/examples/basics/ex1_1_basicscalars_therm2d.py +124 -113
  42. pyvale/examples/basics/ex1_2_sensormodel_therm2d.py +124 -132
  43. pyvale/examples/basics/ex1_3_customsens_therm3d.py +199 -195
  44. pyvale/examples/basics/ex1_4_basicerrors_therm3d.py +125 -121
  45. pyvale/examples/basics/ex1_5_fielderrs_therm3d.py +145 -141
  46. pyvale/examples/basics/ex1_6_caliberrs_therm2d.py +96 -101
  47. pyvale/examples/basics/ex1_7_spatavg_therm2d.py +109 -105
  48. pyvale/examples/basics/ex2_1_basicvectors_disp2d.py +92 -91
  49. pyvale/examples/basics/ex2_2_vectorsens_disp2d.py +96 -90
  50. pyvale/examples/basics/ex2_3_sensangle_disp2d.py +88 -89
  51. pyvale/examples/basics/ex2_4_chainfielderrs_disp2d.py +172 -171
  52. pyvale/examples/basics/ex2_5_vectorfields3d_disp3d.py +88 -86
  53. pyvale/examples/basics/ex3_1_basictensors_strain2d.py +90 -90
  54. pyvale/examples/basics/ex3_2_tensorsens2d_strain2d.py +93 -91
  55. pyvale/examples/basics/ex3_3_tensorsens3d_strain3d.py +172 -160
  56. pyvale/examples/basics/ex4_1_expsim2d_thermmech2d.py +154 -148
  57. pyvale/examples/basics/ex4_2_expsim3d_thermmech3d.py +249 -231
  58. pyvale/examples/dic/ex1_region_of_interest.py +98 -0
  59. pyvale/examples/dic/ex2_plate_with_hole.py +149 -0
  60. pyvale/examples/dic/ex3_plate_with_hole_strain.py +93 -0
  61. pyvale/examples/dic/ex4_dic_blender.py +95 -0
  62. pyvale/examples/dic/ex5_dic_challenge.py +102 -0
  63. pyvale/examples/imagedef2d/ex_imagedef2d_todisk.py +4 -2
  64. pyvale/examples/renderblender/ex1_1_blenderscene.py +152 -105
  65. pyvale/examples/renderblender/ex1_2_blenderdeformed.py +151 -100
  66. pyvale/examples/renderblender/ex2_1_stereoscene.py +183 -116
  67. pyvale/examples/renderblender/ex2_2_stereodeformed.py +185 -112
  68. pyvale/examples/renderblender/ex3_1_blendercalibration.py +164 -109
  69. pyvale/examples/renderrasterisation/ex_rastenp.py +74 -35
  70. pyvale/examples/renderrasterisation/ex_rastercyth_oneframe.py +6 -13
  71. pyvale/examples/renderrasterisation/ex_rastercyth_static_cypara.py +2 -2
  72. pyvale/examples/renderrasterisation/ex_rastercyth_static_pypara.py +2 -4
  73. pyvale/imagedef2d.py +3 -2
  74. pyvale/imagetools.py +137 -0
  75. pyvale/rastercy.py +34 -4
  76. pyvale/rasternp.py +300 -276
  77. pyvale/rasteropts.py +58 -0
  78. pyvale/renderer.py +47 -0
  79. pyvale/rendermesh.py +52 -62
  80. pyvale/renderscene.py +51 -0
  81. pyvale/sensorarrayfactory.py +2 -2
  82. pyvale/sensortools.py +19 -35
  83. pyvale/simcases/case21.i +1 -1
  84. pyvale/simcases/run_1case.py +8 -0
  85. pyvale/simtools.py +2 -2
  86. pyvale/visualsimplotter.py +180 -0
  87. {pyvale-2025.5.3.dist-info → pyvale-2025.7.0.dist-info}/METADATA +11 -57
  88. {pyvale-2025.5.3.dist-info → pyvale-2025.7.0.dist-info}/RECORD +91 -56
  89. {pyvale-2025.5.3.dist-info → pyvale-2025.7.0.dist-info}/WHEEL +1 -1
  90. pyvale/examples/visualisation/ex1_1_plot_traces.py +0 -102
  91. pyvale/examples/visualisation/ex2_1_animate_sim.py +0 -89
  92. {pyvale-2025.5.3.dist-info → pyvale-2025.7.0.dist-info}/licenses/LICENSE +0 -0
  93. {pyvale-2025.5.3.dist-info → pyvale-2025.7.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,887 @@
1
+ # ================================================================================
2
+ # pyvale: the python validation engine
3
+ # License: MIT
4
+ # Copyright (C) 2025 The Computer Aided Validation Team
5
+ # ================================================================================
6
+
7
+ from pyqtgraph.Qt import QtWidgets, QtCore
8
+ import pyqtgraph as pg
9
+ import cv2
10
+ import numpy as np
11
+ import matplotlib.pyplot as plt
12
+ from matplotlib.path import Path as mplPath
13
+ import math
14
+ import yaml
15
+ import os
16
+ from pathlib import Path
17
+
18
+ class DICRegionOfInterest:
19
+ """
20
+ A class for interactively selecting and manipulating ROI of an image before passing to the DIC engine.
21
+
22
+ Users can:
23
+ - Interactively select rectangular, circular, or polygonal regions on the image.
24
+ - Add or subtract selected regions from the mask.
25
+ - Undo and reset the mask changes.
26
+ - read in previously created ROIs
27
+ - save created ROI as text or binary file.
28
+ - Display/save the ROI overlayed over the reference.
29
+ - Programatically select a rectangular ROI for consistency.
30
+
31
+ Public attributes:
32
+ image (np.ndarray): The image on which regions of interest are selected.
33
+ mask (np.ndarray): A binary mask representing the selected regions of interest.
34
+ """
35
+
36
+ def __init__(self, ref_image: str | np.ndarray | Path):
37
+ """
38
+ Parameters
39
+ ----------
40
+ ref_image : str, numpy.ndarray, pathlib.Path
41
+ location of the reference image.
42
+
43
+ Raises
44
+ ------
45
+ ValueError: If the image cannot be loaded or is invalid.
46
+ """
47
+ if isinstance(ref_image, str):
48
+ self.ref_image = cv2.imread(ref_image)
49
+ elif isinstance(ref_image, Path):
50
+ self.ref_image = cv2.imread(str(ref_image))
51
+ else:
52
+ self.ref_image = ref_image.copy()
53
+
54
+ if self.ref_image is None:
55
+ raise ValueError("Invalid image input")
56
+
57
+ self.mask = np.zeros(self.ref_image.shape[:2], dtype=bool)
58
+ self.seed = []
59
+ self.__roi_selected = False
60
+ self.roi_list = []
61
+ self.add_list = []
62
+ self.undo_list = []
63
+
64
+ # Drawing states
65
+ self.drawing_modes = {
66
+ 'seed': False,
67
+ 'rect': False,
68
+ 'circle': False,
69
+ 'poly': False
70
+ }
71
+ self.removing_modes = {
72
+ 'rect': False,
73
+ 'circle': False,
74
+ 'poly': False
75
+ }
76
+
77
+ # GUI elements (initialized in interactive_selection)
78
+ self.main_view = None
79
+ self.fill_layer = None
80
+ self.buttons = {}
81
+ self.poly_points = []
82
+ self.temp_mask = None
83
+ self.fill_array = None
84
+ self.height = None
85
+ self.width = None
86
+ self.subset_size = None
87
+
88
+ def interactive_selection(self, subset_size):
89
+ """
90
+ Interactive GUI to select a region of interest (ROI) in the image using openCV.
91
+ """
92
+ self.subset_size = subset_size
93
+ self.__roi_selected = True
94
+
95
+ # Initialize GUI
96
+ self._setup_gui()
97
+ self._setup_graphics()
98
+ self._connect_signals()
99
+
100
+ # Show and run
101
+ self.main_window.show()
102
+ pg.exec()
103
+
104
+ # Process final mask and seed
105
+ self._finalize_selection()
106
+
107
+ def _setup_gui(self):
108
+ """Setup the main GUI window and sidebar."""
109
+ app = pg.mkQApp("ROI GUI")
110
+ self.main_window = CustomMainWindow(dic_obj=self)
111
+ main_layout = QtWidgets.QHBoxLayout()
112
+ self.main_window.setLayout(main_layout)
113
+ self.main_window.resize(1000, 1000)
114
+
115
+ # Create sidebar
116
+ sidebar = self._create_sidebar()
117
+
118
+ # Create graphics widget
119
+ self.graphics_widget = pg.GraphicsLayoutWidget()
120
+
121
+ main_layout.addLayout(sidebar)
122
+ main_layout.addWidget(self.graphics_widget)
123
+
124
+ def _create_sidebar(self):
125
+ """Create the sidebar with all buttons."""
126
+ sidebar = QtWidgets.QVBoxLayout()
127
+
128
+ # Helper function for styled titles
129
+ def make_title(text):
130
+ label = QtWidgets.QLabel(text)
131
+ label.setStyleSheet("font-weight: bold; font-size: 14px; margin-top: 10px; margin-bottom: 5px;")
132
+ return label
133
+
134
+ # Create all buttons
135
+ button_configs = [
136
+ ("FILE ACTIONS", [("open_roi", "Open ROI..."),
137
+ ("save_roi", "Save Current ROI...")]),
138
+
139
+ ("ADD SHAPES TO ROI", [("add_rect", "Add Rectangle"),
140
+ ("add_circle", "Add Circle"),
141
+ ("add_poly", "Add Polygon")]),
142
+
143
+ ("REMOVE SHAPES FROM ROI", [("sub_rect", "Remove Rectangle"),
144
+ ("sub_circle", "Remove Circle"),
145
+ ("sub_poly", "Remove Polygon")]),
146
+
147
+ ("SEED LOCATION", [("add_seed", "Add Reliability Guided Seed Location")]),
148
+
149
+ ("UNDO / REDO SHAPES", [("undo_prev", "Undo Shape"),
150
+ ("redo_prev", "Redo Shape")]),
151
+
152
+ ("COMPLETION", [("finished", "ROI Completed")])
153
+ ]
154
+
155
+ self.buttons = {}
156
+ for section_title, button_list in button_configs:
157
+ sidebar.addWidget(make_title(section_title))
158
+ for btn_id, btn_text in button_list:
159
+ btn = QtWidgets.QPushButton(btn_text)
160
+ self.buttons[btn_id] = btn
161
+ sidebar.addWidget(btn)
162
+ sidebar.addSpacing(20)
163
+
164
+ # Initial button states
165
+ self.buttons['undo_prev'].setEnabled(False)
166
+ self.buttons['redo_prev'].setEnabled(False)
167
+
168
+ sidebar.addStretch()
169
+ return sidebar
170
+
171
+ def _setup_graphics(self):
172
+ """Setup the graphics view and image display."""
173
+ self.main_view = self.graphics_widget.addViewBox(lockAspect=True)
174
+
175
+ # Setup image
176
+ rotated = np.rot90(self.ref_image, k=-1)
177
+ img = pg.ImageItem(rotated)
178
+ self.main_view.addItem(img)
179
+ self.main_view.disableAutoRange('xy')
180
+ self.main_view.autoRange()
181
+
182
+ # Setup fill layer
183
+ self.fill_layer = pg.ImageItem()
184
+ self.fill_layer.setZValue(1)
185
+ self.main_view.addItem(self.fill_layer)
186
+
187
+ self.height, self.width = rotated.shape[:2]
188
+ self.fill_array = np.zeros((self.height, self.width, 4), dtype=np.uint8)
189
+ self.temp_mask = np.zeros((self.height, self.width), dtype=bool)
190
+
191
+ # Setup drawing overlays
192
+ self._setup_drawing_overlays()
193
+
194
+ def _setup_drawing_overlays(self):
195
+ """Setup scatter plots and lines for polygon drawing."""
196
+ self.add_scatter = pg.ScatterPlotItem(size=10, pen=pg.mkPen(None), brush=pg.mkBrush('b'))
197
+ self.sub_scatter = pg.ScatterPlotItem(size=10, pen=pg.mkPen(None), brush=pg.mkBrush('r'))
198
+ self.add_line = pg.PlotDataItem(pen=pg.mkPen('b', width=3))
199
+ self.sub_line = pg.PlotDataItem(pen=pg.mkPen('r', width=3))
200
+
201
+ for item in [self.add_scatter, self.sub_scatter, self.add_line, self.sub_line]:
202
+ self.main_view.addItem(item)
203
+
204
+ def _connect_signals(self):
205
+ """Connect all button signals to their handlers."""
206
+ signal_map = {
207
+ 'add_seed': lambda: self._start_drawing_mode('seed'),
208
+ 'add_rect': lambda: self._start_drawing_mode('rect'),
209
+ 'add_circle': lambda: self._start_drawing_mode('circle'),
210
+ 'add_poly': lambda: self._start_drawing_mode('poly'),
211
+ 'sub_rect': lambda: self._start_removing_mode('rect'),
212
+ 'sub_circle': lambda: self._start_removing_mode('circle'),
213
+ 'sub_poly': lambda: self._start_removing_mode('poly'),
214
+ 'undo_prev': self._undo_last,
215
+ 'redo_prev': self._redo_last,
216
+ 'save_roi': self._save_interactive_roi,
217
+ 'open_roi': self._open_interactive_roi,
218
+ 'finished': self._finish
219
+ }
220
+
221
+ for btn_id, handler in signal_map.items():
222
+ self.buttons[btn_id].clicked.connect(handler)
223
+
224
+ self.main_view.scene().sigMouseClicked.connect(self._mouse_clicked)
225
+
226
+ def _start_drawing_mode(self, mode):
227
+ """Start drawing mode for specified shape type."""
228
+ self._reset_all_modes()
229
+ self.drawing_modes[mode] = True
230
+ self.main_view.setCursor(QtCore.Qt.CursorShape.CrossCursor)
231
+
232
+ if mode == 'poly':
233
+ self.poly_points = []
234
+ self.add_scatter.setData([], [])
235
+ self.add_line.setData([], [])
236
+ print("Click to add polygon points. Right-click to finish.")
237
+ elif mode == 'seed':
238
+ self.buttons['add_seed'].setEnabled(False)
239
+ print("Click to add seed location...")
240
+ else:
241
+ print(f"Click to add {mode}.")
242
+
243
+ def _start_removing_mode(self, mode):
244
+ """Start removing mode for specified shape type."""
245
+ self._reset_all_modes()
246
+ self.removing_modes[mode] = True
247
+ self.main_view.setCursor(QtCore.Qt.CursorShape.CrossCursor)
248
+
249
+ if mode == 'poly':
250
+ self.poly_points = []
251
+ self.sub_scatter.setData([], [])
252
+ self.sub_line.setData([], [])
253
+ print("Click to add polygon points. Right-click to finish.")
254
+ else:
255
+ print(f"Click to remove {mode}.")
256
+
257
+ def _reset_all_modes(self):
258
+ """Reset all drawing and removing modes."""
259
+ for mode in self.drawing_modes:
260
+ self.drawing_modes[mode] = False
261
+ for mode in self.removing_modes:
262
+ self.removing_modes[mode] = False
263
+
264
+ def _finish_mode(self):
265
+ """Finish current drawing/removing mode."""
266
+ self._reset_all_modes()
267
+ self.main_view.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
268
+ self._clear_redo_stack()
269
+
270
+ def _mouse_clicked(self, event):
271
+ """Handle mouse clicks for drawing shapes."""
272
+ if self.drawing_modes['poly'] or self.removing_modes['poly']:
273
+ self._handle_polygon_click(event)
274
+ elif self.drawing_modes['seed']:
275
+ self._handle_seed_click(event)
276
+ elif any(self.drawing_modes.values()) or any(self.removing_modes.values()):
277
+ self._handle_shape_click(event)
278
+
279
+ def _handle_polygon_click(self, event):
280
+ """Handle polygon drawing clicks."""
281
+ is_adding = self.drawing_modes['poly']
282
+ scatter = self.add_scatter if is_adding else self.sub_scatter
283
+ line = self.add_line if is_adding else self.sub_line
284
+
285
+ if event.button() == QtCore.Qt.MouseButton.LeftButton:
286
+ pos = event.scenePos()
287
+ if self.main_view.sceneBoundingRect().contains(pos):
288
+ mouse_point = self.main_view.mapSceneToView(pos)
289
+ self.poly_points.append([mouse_point.x(), mouse_point.y()])
290
+ scatter.setData([p[0] for p in self.poly_points], [p[1] for p in self.poly_points])
291
+ if len(self.poly_points) > 1:
292
+ line.setData([p[0] for p in self.poly_points], [p[1] for p in self.poly_points])
293
+
294
+ elif event.button() == QtCore.Qt.MouseButton.RightButton:
295
+ self._finish_polygon_drawing(is_adding)
296
+
297
+ def _handle_seed_click(self, event):
298
+ """Handle seed location clicks."""
299
+ if event.button() == QtCore.Qt.MouseButton.LeftButton:
300
+ pos = event.scenePos()
301
+ start_point = self.main_view.mapSceneToView(pos)
302
+
303
+ x = math.floor(start_point.x() - self.subset_size / 2)
304
+ y = math.floor(start_point.y() - self.subset_size / 2)
305
+
306
+ seed_roi = pg.RectROI(
307
+ [x, y], [self.subset_size, self.subset_size],
308
+ pen=pg.mkPen('b', width=3),
309
+ hoverPen=pg.mkPen('y', width=3),
310
+ handlePen='#0000',
311
+ handleHoverPen='#0000'
312
+ )
313
+
314
+ # Remove all handles to make it non-interactive
315
+ for handle in seed_roi.getHandles():
316
+ seed_roi.removeHandle(handle)
317
+
318
+ self.main_view.addItem(seed_roi)
319
+ self.seed_roi = seed_roi
320
+ print(f"Seed initially added at location: [{x}, {self.width-y}]")
321
+ self._finish_mode()
322
+
323
+ def _handle_shape_click(self, event):
324
+ """Handle rectangle and circle drawing clicks."""
325
+ if event.button() != QtCore.Qt.MouseButton.LeftButton:
326
+ return
327
+
328
+ pos = event.scenePos()
329
+ start_point = self.main_view.mapSceneToView(pos)
330
+
331
+ # Determine shape type and add/remove mode
332
+ shape_type = None
333
+ is_adding = True
334
+
335
+ for mode in ['rect', 'circle']:
336
+ if self.drawing_modes[mode]:
337
+ shape_type = mode
338
+ is_adding = True
339
+ break
340
+ elif self.removing_modes[mode]:
341
+ shape_type = mode
342
+ is_adding = False
343
+ break
344
+
345
+ if shape_type:
346
+ roi = self._create_shape_roi(shape_type, start_point, is_adding)
347
+ self._add_roi_to_scene(roi, is_adding)
348
+ self._finish_mode()
349
+
350
+ def _create_shape_roi(self, shape_type, start_point, is_adding):
351
+ """Create ROI object for rectangle or circle."""
352
+ pen = pg.mkPen('g', width=4) if is_adding else pg.mkPen('r', width=4)
353
+ hover_pen = pg.mkPen('b', width=4)
354
+ handle_pen = pg.mkPen('b', width=4)
355
+
356
+ if shape_type == 'rect':
357
+ roi = pg.RectROI(
358
+ start_point, [self.height/6, self.width/6],
359
+ pen=pen, hoverPen=hover_pen, handlePen=handle_pen, handleHoverPen=hover_pen
360
+ )
361
+ roi.addScaleHandle([1, 0], [0.0, 1.0])
362
+ roi.addScaleHandle([0, 1], [1.0, 0.0])
363
+ roi.addScaleHandle([0, 0], [1.0, 1.0])
364
+ roi.addTranslateHandle([0.5, 0.5])
365
+
366
+ elif shape_type == 'circle':
367
+ x = start_point.x() - self.width / 10
368
+ y = start_point.y() - self.width / 10
369
+ roi = pg.CircleROI(
370
+ [x, y], radius=self.width/10,
371
+ pen=pen, hoverPen=hover_pen, handlePen=handle_pen, handleHoverPen=hover_pen
372
+ )
373
+ roi.addTranslateHandle([0.5, 0.5])
374
+
375
+ # Style handles
376
+ for handle in roi.getHandles():
377
+ handle.radius = 10
378
+ handle.buildPath()
379
+ handle.update()
380
+
381
+ return roi
382
+
383
+ def _add_roi_to_scene(self, roi, is_adding):
384
+ """Add ROI to scene and lists."""
385
+ self.roi_list.append(roi)
386
+ self.add_list.append(is_adding)
387
+ self.main_view.addItem(roi)
388
+ roi.sigRegionChanged.connect(self._redraw_fill_layer)
389
+ self._redraw_fill_layer()
390
+ self._update_button_states()
391
+
392
+ def _finish_polygon_drawing(self, is_adding):
393
+ """Finish polygon drawing."""
394
+ if len(self.poly_points) >= 3:
395
+ pen = pg.mkPen('g', width=4) if is_adding else pg.mkPen('r', width=4)
396
+ hover_pen = pg.mkPen('b', width=4)
397
+ handle_pen = pg.mkPen('b', width=4)
398
+
399
+ roi = pg.PolyLineROI(
400
+ self.poly_points, closed=True,
401
+ pen=pen, hoverPen=hover_pen, handlePen=handle_pen, handleHoverPen=hover_pen
402
+ )
403
+
404
+ for handle in roi.getHandles():
405
+ handle.radius = 10
406
+ handle.buildPath()
407
+ handle.update()
408
+
409
+ self._add_roi_to_scene(roi, is_adding)
410
+ print("Polygon added.")
411
+ else:
412
+ print("Need at least 3 points.")
413
+
414
+ # Clean up
415
+ self.poly_points = []
416
+ scatter = self.add_scatter if is_adding else self.sub_scatter
417
+ line = self.add_line if is_adding else self.sub_line
418
+ scatter.setData([], [])
419
+ line.setData([], [])
420
+ self._finish_mode()
421
+
422
+ def _redraw_fill_layer(self):
423
+ """Redraw the fill layer based on current ROIs."""
424
+ if not self.roi_list:
425
+ self.fill_array.fill(0)
426
+ self.temp_mask.fill(False)
427
+ self.fill_layer.setImage(self.fill_array)
428
+ return
429
+
430
+ self.temp_mask.fill(False)
431
+
432
+ for roi, is_adding in zip(self.roi_list, self.add_list):
433
+ if isinstance(roi, pg.RectROI):
434
+ self._apply_rect_mask(roi, is_adding)
435
+ elif isinstance(roi, pg.CircleROI):
436
+ self._apply_circle_mask(roi, is_adding)
437
+ elif isinstance(roi, pg.PolyLineROI):
438
+ self._apply_poly_mask(roi, is_adding)
439
+
440
+ # Update fill array
441
+ self.fill_array[:, :, 0] = 0
442
+ self.fill_array[:, :, 1] = 255
443
+ self.fill_array[:, :, 2] = 0
444
+ self.fill_array[:, :, 3] = self.temp_mask * 80
445
+ self.fill_layer.setImage(self.fill_array)
446
+
447
+ def _apply_rect_mask(self, roi, is_adding):
448
+ """Apply rectangle mask to temp_mask."""
449
+ pos = roi.pos()
450
+ size = roi.size()
451
+ x, y = int(pos[1]), int(pos[0])
452
+ w, h = int(size[1]), int(size[0])
453
+
454
+ # Clamp to image bounds
455
+ x = max(0, min(x, self.width))
456
+ y = max(0, min(y, self.height))
457
+ w = max(0, min(w, self.width - x))
458
+ h = max(0, min(h, self.height - y))
459
+
460
+ if w > 0 and h > 0:
461
+ self.temp_mask[y:y+h, x:x+w] = is_adding
462
+
463
+ def _apply_circle_mask(self, roi, is_adding):
464
+ """Apply circle mask to temp_mask."""
465
+ pos = roi.pos()
466
+ size = roi.size()
467
+ cx, cy = pos[1] + size[1]/2, pos[0] + size[0]/2
468
+ rx, ry = size[1]/2, size[0]/2
469
+
470
+ y_coords, x_coords = np.ogrid[:self.height, :self.width]
471
+ circle_mask = ((x_coords - cx)/rx)**2 + ((y_coords - cy)/ry)**2 <= 1
472
+
473
+ if is_adding:
474
+ self.temp_mask |= circle_mask
475
+ else:
476
+ self.temp_mask &= ~circle_mask
477
+
478
+ def _apply_poly_mask(self, roi, is_adding):
479
+ """Apply polygon mask to temp_mask."""
480
+ points = roi.getState()['points']
481
+ pos = roi.pos()
482
+
483
+ if len(points) >= 3:
484
+ vertices = np.array([(p[1]+pos[1], p[0]+pos[0]) for p in points])
485
+ path = mplPath(vertices)
486
+
487
+ x_min, x_max = int(np.floor(vertices[:, 0].min())), int(np.ceil(vertices[:, 0].max()))
488
+ y_min, y_max = int(np.floor(vertices[:, 1].min())), int(np.ceil(vertices[:, 1].max()))
489
+
490
+ # Clamp to image bounds
491
+ x_min = max(0, x_min)
492
+ x_max = min(self.width, x_max)
493
+ y_min = max(0, y_min)
494
+ y_max = min(self.height, y_max)
495
+
496
+ if x_max > x_min and y_max > y_min:
497
+ xx, yy = np.meshgrid(np.arange(x_min, x_max), np.arange(y_min, y_max))
498
+ points_grid = np.column_stack((xx.ravel(), yy.ravel()))
499
+ inside = path.contains_points(points_grid)
500
+ inside_2d = inside.reshape(y_max - y_min, x_max - x_min)
501
+
502
+ if is_adding:
503
+ self.temp_mask[y_min:y_max, x_min:x_max] |= inside_2d
504
+ else:
505
+ self.temp_mask[y_min:y_max, x_min:x_max] &= ~inside_2d
506
+
507
+ def _update_button_states(self):
508
+ """Update the enabled state of undo and redo buttons."""
509
+ self.buttons['undo_prev'].setEnabled(len(self.roi_list) > 0)
510
+ self.buttons['redo_prev'].setEnabled(len(self.undo_list) > 0)
511
+
512
+ def _clear_redo_stack(self):
513
+ """Clear the redo stack when new shapes are added."""
514
+ self.undo_list = []
515
+ self._update_button_states()
516
+
517
+ def _undo_last(self):
518
+ """Undo the last ROI operation."""
519
+ if self.roi_list:
520
+ roi = self.roi_list.pop()
521
+ add_flag = self.add_list.pop()
522
+ self.main_view.removeItem(roi)
523
+ self.undo_list.append((roi, add_flag))
524
+ self._redraw_fill_layer()
525
+ self._update_button_states()
526
+
527
+ def _redo_last(self):
528
+ """Redo the last undone ROI operation."""
529
+ if self.undo_list:
530
+ roi, add_flag = self.undo_list.pop()
531
+ self.roi_list.append(roi)
532
+ self.add_list.append(add_flag)
533
+ self.main_view.addItem(roi)
534
+ roi.sigRegionChanged.connect(self._redraw_fill_layer)
535
+ self._redraw_fill_layer()
536
+ self._update_button_states()
537
+
538
+ def _save_interactive_roi(self):
539
+ """Save the current ROI to a YAML file."""
540
+ filename, _ = QtWidgets.QFileDialog.getSaveFileName(self.main_window, 'Save ROI', 'roi_interactive.yaml', filter='YAML Files (*.yaml)')
541
+
542
+ if filename:
543
+
544
+ # Ensure extension is added if user doesn't include it
545
+ if filename and not filename.endswith('.yaml'):
546
+ filename += '.yaml'
547
+
548
+ print("Saving to file:", filename)
549
+ serialized = [
550
+ self._get_roi_data(roi, add)
551
+ for roi, add in zip(self.roi_list, self.add_list)
552
+ ]
553
+
554
+ # add ROI to serialized data
555
+ if hasattr(self, 'seed_roi'):
556
+ self._finalize_selection()
557
+ seed_data = {
558
+ 'type': 'SeedROI',
559
+ 'pos': [self.seed[0], self.seed[1]],
560
+ 'size': [self.subset_size, self.subset_size],
561
+ 'add': True
562
+ }
563
+ serialized.append(seed_data)
564
+
565
+ with open(filename, 'w') as f:
566
+ yaml.dump(serialized, f, sort_keys=False)
567
+
568
+ def _open_interactive_roi(self):
569
+ """Open ROI from a YAML file."""
570
+ filename, _ = QtWidgets.QFileDialog.getOpenFileName(
571
+ self.main_window, 'Open ROI', filter='YAML Files (*.yaml)'
572
+ )
573
+ if filename:
574
+ with open(filename, 'r') as f:
575
+ data = yaml.safe_load(f)
576
+
577
+ # Clear existing ROIs
578
+ for roi in self.roi_list:
579
+ self.main_view.removeItem(roi)
580
+ self.roi_list = []
581
+ self.add_list = []
582
+
583
+ self.seed_roi = None # Clear existing seed
584
+
585
+ for entry in data:
586
+ if entry.get('type') == 'SeedROI':
587
+ # Restore the seed ROI
588
+ x, y = entry['pos']
589
+ size = entry.get('size', [10, 10]) # fallback default
590
+ self.seed_roi = pg.RectROI(
591
+ [x, y], size,
592
+ pen=pg.mkPen('b', width=3),
593
+ hoverPen=pg.mkPen('y', width=3),
594
+ handlePen='#0000',
595
+ handleHoverPen='#0000'
596
+ )
597
+ self.main_view.addItem(self.seed_roi)
598
+
599
+ else:
600
+ # Restore standard ROI
601
+ roi = self._create_roi_from_data(entry)
602
+ self.roi_list.append(roi)
603
+ self.add_list.append(entry['add'])
604
+ self.main_view.addItem(roi)
605
+ roi.sigRegionChanged.connect(self._redraw_fill_layer)
606
+
607
+ self._redraw_fill_layer()
608
+ self._update_button_states()
609
+
610
+ def _create_roi_from_data(self, entry):
611
+ """Create ROI object from saved data."""
612
+ roi_type = entry['type']
613
+ is_adding = entry['add']
614
+
615
+ pen = pg.mkPen('g', width=4) if is_adding else pg.mkPen('r', width=4)
616
+ hover_pen = pg.mkPen('b', width=4)
617
+ handle_pen = pg.mkPen('b', width=4)
618
+
619
+ if roi_type == 'RectROI':
620
+ roi = pg.RectROI(entry['pos'], entry['size'], pen=pen,
621
+ hoverPen=hover_pen, handlePen=handle_pen,
622
+ handleHoverPen=hover_pen)
623
+
624
+ roi.addScaleHandle([1, 0], [0.0, 1.0])
625
+ roi.addScaleHandle([0, 1], [1.0, 0.0])
626
+ roi.addScaleHandle([0, 0], [1.0, 1.0])
627
+ roi.addTranslateHandle([0.5, 0.5])
628
+
629
+ elif roi_type == 'CircleROI':
630
+ roi = pg.CircleROI(entry['pos'], entry['size'], pen=pen,
631
+ hoverPen=hover_pen, handlePen=handle_pen,
632
+ handleHoverPen=hover_pen)
633
+ roi.addTranslateHandle([0.5, 0.5])
634
+
635
+ elif roi_type == 'PolyLineROI':
636
+ points = [QtCore.QPointF(p[0], p[1]) for p in entry['points']]
637
+ roi = pg.PolyLineROI(points, closed=True,pen=pen,
638
+ hoverPen=hover_pen, handlePen=handle_pen,
639
+ handleHoverPen=hover_pen)
640
+
641
+ else:
642
+ raise TypeError(f"Unsupported ROI type: {roi_type}")
643
+
644
+ #update handle sizes
645
+ for handle in roi.getHandles():
646
+ handle.radius = 10
647
+ handle.buildPath()
648
+ handle.update()
649
+
650
+ return roi
651
+
652
+ def _finish(self):
653
+ """Finish ROI selection and close the GUI, with a check for empty seed."""
654
+
655
+ self._finalize_selection()
656
+
657
+ if not self.seed:
658
+ reply = QtWidgets.QMessageBox.question(
659
+ self.main_window,
660
+ "Exit Confirmation",
661
+ "No Seed location has been selected for reliability guided DIC. Are you sure you want to continue?",
662
+ QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No,
663
+ QtWidgets.QMessageBox.StandardButton.No
664
+ )
665
+ if reply == QtWidgets.QMessageBox.StandardButton.No:
666
+ return
667
+
668
+ self.main_window.close()
669
+ pg.QtWidgets.QApplication.quit()
670
+
671
+ def _finalize_selection(self):
672
+ """Process the final mask and seed location."""
673
+ self.mask = np.flipud(self.temp_mask.T)
674
+
675
+ if hasattr(self, 'seed_roi'):
676
+ pos = self.seed_roi.pos()
677
+ x = int(np.floor(pos.x()))
678
+ y = int(np.floor(self.width - pos.y()))
679
+ self.seed = [x, y]
680
+
681
+ if not self.mask[y, x]:
682
+ raise ValueError(f"Seed location [{x}, {y}] is not within the mask")
683
+ print(f"Final seed location: [{x}, {y}]")
684
+
685
+ def _get_roi_data(self, roi_element, add: bool):
686
+ """Extract data from ROI element for serialization."""
687
+ if isinstance(roi_element, pg.RectROI):
688
+ return {
689
+ 'type': 'RectROI',
690
+ 'pos': [float(roi_element.pos().x()), float(roi_element.pos().y())],
691
+ 'size': [float(roi_element.size().x()), float(roi_element.size().y())],
692
+ 'add': bool(add)
693
+ }
694
+ elif isinstance(roi_element, pg.CircleROI):
695
+ return {
696
+ 'type': 'CircleROI',
697
+ 'pos': [float(roi_element.pos().x()), float(roi_element.pos().y())],
698
+ 'size': [float(roi_element.size().x()), float(roi_element.size().y())],
699
+ 'add': bool(add)
700
+ }
701
+ elif isinstance(roi_element, pg.PolyLineROI):
702
+ handle_pos = roi_element.getLocalHandlePositions()
703
+ points = [[float(p[1].x()), float(p[1].y())] for p in handle_pos]
704
+ return {
705
+ 'type': 'PolyLineROI',
706
+ 'points': points,
707
+ 'add': bool(add)
708
+ }
709
+ else:
710
+ raise TypeError(f"Unsupported ROI type: {type(roi_element)}")
711
+
712
+
713
+ def reset_mask(self):
714
+ """
715
+ Completely resets the roi mask to 0s.
716
+ """
717
+ self.mask[:] = False;
718
+
719
+
720
+
721
+ def rect_boundary(self, left: int, right: int, top: int, bottom: int) -> None:
722
+ """
723
+ Defines a central rectangular region of interest (ROI) excluding
724
+ surrounding pixels defined by input arguments"
725
+
726
+ Parameters
727
+ ----------
728
+ left (int): Number of px to exclude from left edge.
729
+ right (int): Number of px to exclude from the right edge.
730
+ top (int): Number of px to exclude from the top edge.
731
+ bottom (int): Number of px to exclude from the bottom edge.
732
+ """
733
+ self.reset_mask()
734
+ self.mask[bottom:(self.ref_image.shape[0]-top), left:(self.ref_image.shape[1])-right] = 255
735
+ self.__roi_selected = True
736
+
737
+ def rect_region(self, x: int, y: int, size_x: int, size_y: int ) -> None:
738
+
739
+ top = max(0, y)
740
+ bottom = min(self.ref_image.shape[0],y+size_y)
741
+ left = max(0, x)
742
+ right = min(self.ref_image.shape[1],x+size_x)
743
+
744
+ # Apply the mask in the subset region
745
+ self.mask[top:bottom, left:right] = 255
746
+ self.__roi_selected = True
747
+
748
+
749
+
750
+
751
+ def save_image(self, filename: str | Path) -> None:
752
+ """
753
+ Save the ROI overlayed over the reference image in .tiff image format.
754
+
755
+ Parameters
756
+ ----------
757
+ filename : str or pathlib.Path
758
+ Filename of image
759
+
760
+ Raises
761
+ ------
762
+ ValueError
763
+ If no ROI has been selected
764
+ """
765
+ if not self.__roi_selected:
766
+ raise ValueError("No ROI selected with \'interactive_selection\', \'rect_boundary\', \'read_array\' or \'rect_region\'. ")
767
+
768
+ overlay = self.ref_image.copy()
769
+ overlay[self.mask] = (0, 255, 0)
770
+ result = cv2.addWeighted(self.ref_image, 0.6, overlay, 0.4, 0)
771
+ cv2.imwrite(str(filename), result)
772
+
773
+
774
+
775
+
776
+ def save_array(self, filename: str | Path, binary: bool=False) -> None:
777
+ """
778
+ Save the ROI mask as a numpy binary or text file.
779
+
780
+ Parameters
781
+ ----------
782
+ filename : str or pathlib.Path
783
+ filename given to saved ROI mask
784
+ binary : bool
785
+ If True, saves from as a .npy binary file.
786
+ If False, saves to a space delimited text file.
787
+
788
+ Raises
789
+ ------
790
+ ValueError
791
+ If no ROI has been selected.
792
+ """
793
+ if not self.__roi_selected:
794
+ raise ValueError("No ROI selected with \'interactive_selection\', \'rect_boundary\', \'read_array\' or \'rect_region\'. ")
795
+
796
+ if binary:
797
+ np.save(filename, self.mask)
798
+ else:
799
+ np.savetxt(filename, self.mask, fmt='%d', delimiter=' ')
800
+
801
+
802
+ def read_array(self, filename: str | Path, binary: bool = False) -> None:
803
+ """
804
+ Load the ROI mask from a binary or text file and store it in `self.mask`.
805
+
806
+ Parameters
807
+ ----------
808
+ filename : str or pathlib.Path
809
+ Path to the file to load.
810
+ binary : bool
811
+ If True, loads from a .npy binary file. If False, loads from a text file.
812
+
813
+ Raises
814
+ ------
815
+ FileNotFoundError
816
+ If the specified file does not exist.
817
+ ValueError
818
+ If the loaded data is not a valid mask.
819
+ """
820
+
821
+ if not os.path.exists(filename):
822
+ raise FileNotFoundError(f"File '{filename}' does not exist.")
823
+
824
+ if binary:
825
+ self.mask = np.load(filename)
826
+ else:
827
+ self.mask = np.loadtxt(filename, dtype=bool, delimiter=' ')
828
+
829
+ # Optional: check if the loaded data is a proper binary mask (0s and 1s)
830
+ if not np.isin(self.mask, [0, 1]).all():
831
+ raise ValueError("Loaded ROI mask contains values other than 0 and 1.")
832
+
833
+ self.__roi_selected = True
834
+
835
+
836
+ def show_image(self) -> None:
837
+ """
838
+ Displays the current mask in grayscale.
839
+
840
+ Raises
841
+ ------
842
+ ValueError: If no ROI is selected.
843
+ """
844
+
845
+
846
+ if not self.__roi_selected:
847
+ raise ValueError("No ROI selected with 'interactive_selection' or 'rect_boundary'")
848
+
849
+ # Create a green mask image
850
+ green_mask = np.zeros_like(self.ref_image)
851
+
852
+ green_mask[self.mask,:] = [0, 255, 0]
853
+
854
+ # Blend the original image and the mask
855
+ blended = self.ref_image.astype(float) * 0.7 + green_mask.astype(float) * 0.3
856
+ blended = blended.astype(np.uint8)
857
+
858
+ # Display using Matplotlib
859
+ plt.figure()
860
+ plt.imshow(blended)
861
+ plt.axis('off')
862
+ plt.tight_layout()
863
+ plt.show()
864
+
865
+
866
+ class CustomMainWindow(QtWidgets.QWidget):
867
+ def __init__(self, dic_obj=None, *args, **kwargs):
868
+ super().__init__(*args, **kwargs)
869
+ self.dic_obj = dic_obj
870
+
871
+ def closeEvent(self, event):
872
+ if self.dic_obj:
873
+ # Force finalization before checking seed
874
+ self.dic_obj._finalize_selection()
875
+
876
+ if not self.dic_obj.seed:
877
+ reply = QtWidgets.QMessageBox.question(
878
+ self,
879
+ "Exit Confirmation",
880
+ "No Seed location has been selected for reliability guided DIC. Are you sure you want to continue?",
881
+ QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No,
882
+ QtWidgets.QMessageBox.StandardButton.No
883
+ )
884
+ if reply == QtWidgets.QMessageBox.StandardButton.No:
885
+ event.ignore()
886
+ return
887
+ event.accept()