sgtlib 3.3.9__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 (72) hide show
  1. StructuralGT/__init__.py +31 -0
  2. StructuralGT/apps/__init__.py +0 -0
  3. StructuralGT/apps/cli_main.py +258 -0
  4. StructuralGT/apps/gui_main.py +69 -0
  5. StructuralGT/apps/gui_mcw/__init__.py +0 -0
  6. StructuralGT/apps/gui_mcw/checkbox_model.py +91 -0
  7. StructuralGT/apps/gui_mcw/controller.py +1073 -0
  8. StructuralGT/apps/gui_mcw/image_provider.py +74 -0
  9. StructuralGT/apps/gui_mcw/imagegrid_model.py +75 -0
  10. StructuralGT/apps/gui_mcw/qthread_worker.py +102 -0
  11. StructuralGT/apps/gui_mcw/table_model.py +79 -0
  12. StructuralGT/apps/gui_mcw/tree_model.py +154 -0
  13. StructuralGT/apps/sgt_qml/CenterMainContent.qml +19 -0
  14. StructuralGT/apps/sgt_qml/LeftContent.qml +48 -0
  15. StructuralGT/apps/sgt_qml/MainWindow.qml +762 -0
  16. StructuralGT/apps/sgt_qml/RightLoggingPanel.qml +125 -0
  17. StructuralGT/apps/sgt_qml/assets/icons/.DS_Store +0 -0
  18. StructuralGT/apps/sgt_qml/assets/icons/back_icon.png +0 -0
  19. StructuralGT/apps/sgt_qml/assets/icons/brightness_icon.png +0 -0
  20. StructuralGT/apps/sgt_qml/assets/icons/cancel_icon.png +0 -0
  21. StructuralGT/apps/sgt_qml/assets/icons/crop_icon.png +0 -0
  22. StructuralGT/apps/sgt_qml/assets/icons/edit_icon.png +0 -0
  23. StructuralGT/apps/sgt_qml/assets/icons/graph_icon.png +0 -0
  24. StructuralGT/apps/sgt_qml/assets/icons/hide_panel.png +0 -0
  25. StructuralGT/apps/sgt_qml/assets/icons/next_icon.png +0 -0
  26. StructuralGT/apps/sgt_qml/assets/icons/notify_icon.png +0 -0
  27. StructuralGT/apps/sgt_qml/assets/icons/rescale_icon.png +0 -0
  28. StructuralGT/apps/sgt_qml/assets/icons/show_panel.png +0 -0
  29. StructuralGT/apps/sgt_qml/assets/icons/square_icon.png +0 -0
  30. StructuralGT/apps/sgt_qml/assets/icons/undo_icon.png +0 -0
  31. StructuralGT/apps/sgt_qml/components/ImageFilters.qml +82 -0
  32. StructuralGT/apps/sgt_qml/components/ImageProperties.qml +112 -0
  33. StructuralGT/apps/sgt_qml/components/ProjectNav.qml +127 -0
  34. StructuralGT/apps/sgt_qml/widgets/BinaryFilterWidget.qml +151 -0
  35. StructuralGT/apps/sgt_qml/widgets/BrightnessControlWidget.qml +103 -0
  36. StructuralGT/apps/sgt_qml/widgets/CreateProjectWidget.qml +112 -0
  37. StructuralGT/apps/sgt_qml/widgets/GTWidget.qml +94 -0
  38. StructuralGT/apps/sgt_qml/widgets/GraphComputeWidget.qml +77 -0
  39. StructuralGT/apps/sgt_qml/widgets/GraphExtractWidget.qml +175 -0
  40. StructuralGT/apps/sgt_qml/widgets/GraphPropertyWidget.qml +77 -0
  41. StructuralGT/apps/sgt_qml/widgets/ImageFilterWidget.qml +137 -0
  42. StructuralGT/apps/sgt_qml/widgets/ImagePropertyWidget.qml +78 -0
  43. StructuralGT/apps/sgt_qml/widgets/ImageViewWidget.qml +585 -0
  44. StructuralGT/apps/sgt_qml/widgets/MenuBarWidget.qml +137 -0
  45. StructuralGT/apps/sgt_qml/widgets/MicroscopyPropertyWidget.qml +80 -0
  46. StructuralGT/apps/sgt_qml/widgets/ProjectWidget.qml +141 -0
  47. StructuralGT/apps/sgt_qml/widgets/RescaleControlWidget.qml +83 -0
  48. StructuralGT/apps/sgt_qml/widgets/RibbonWidget.qml +406 -0
  49. StructuralGT/apps/sgt_qml/widgets/StatusBarWidget.qml +173 -0
  50. StructuralGT/compute/__init__.py +0 -0
  51. StructuralGT/compute/c_lang/include/sgt_base.h +21 -0
  52. StructuralGT/compute/graph_analyzer.py +1499 -0
  53. StructuralGT/entrypoints.py +49 -0
  54. StructuralGT/imaging/__init__.py +0 -0
  55. StructuralGT/imaging/base_image.py +403 -0
  56. StructuralGT/imaging/image_processor.py +780 -0
  57. StructuralGT/modules.py +29 -0
  58. StructuralGT/networks/__init__.py +0 -0
  59. StructuralGT/networks/fiber_network.py +490 -0
  60. StructuralGT/networks/graph_skeleton.py +425 -0
  61. StructuralGT/networks/sknw_mod.py +199 -0
  62. StructuralGT/utils/__init__.py +0 -0
  63. StructuralGT/utils/config_loader.py +244 -0
  64. StructuralGT/utils/configs.ini +97 -0
  65. StructuralGT/utils/progress_update.py +67 -0
  66. StructuralGT/utils/sgt_utils.py +291 -0
  67. sgtlib-3.3.9.dist-info/METADATA +789 -0
  68. sgtlib-3.3.9.dist-info/RECORD +72 -0
  69. sgtlib-3.3.9.dist-info/WHEEL +5 -0
  70. sgtlib-3.3.9.dist-info/entry_points.txt +3 -0
  71. sgtlib-3.3.9.dist-info/licenses/LICENSE +674 -0
  72. sgtlib-3.3.9.dist-info/top_level.txt +1 -0
@@ -0,0 +1,780 @@
1
+ # SPDX-License-Identifier: GNU GPL v3
2
+
3
+ """
4
+ Processes 2D or 3D images and generate a fiber graph network.
5
+ """
6
+
7
+ import re
8
+ import os
9
+ import cv2
10
+ # import pydicom
11
+ import logging
12
+ import numpy as np
13
+ # import nibabel as nib
14
+ from PIL import Image
15
+ from math import isqrt
16
+ from cv2.typing import MatLike
17
+ from dataclasses import dataclass
18
+ from collections import defaultdict
19
+
20
+ from ..utils.sgt_utils import plot_to_opencv
21
+ from ..utils.progress_update import ProgressUpdate
22
+ from ..imaging.base_image import BaseImage
23
+ from ..networks.fiber_network import FiberNetworkBuilder
24
+
25
+ logger = logging.getLogger("SGT App")
26
+
27
+ Image.MAX_IMAGE_PIXELS = None # Disable limit on maximum image size
28
+ ALLOWED_IMG_EXTENSIONS = ('*.jpg', '*.png', '*.jpeg', '*.tif', '*.tiff', '*.qptiff')
29
+
30
+
31
+ class ImageProcessor(ProgressUpdate):
32
+ """
33
+ A class for processing and preparing 2D or 3D microscopy images for building a fiber graph network.
34
+
35
+ Args:
36
+ img_path (str): input image path
37
+ out_dir (str): directory path for storing results.
38
+ """
39
+
40
+ @dataclass
41
+ class ImageBatch:
42
+ numpy_image: np.ndarray
43
+ images: list[BaseImage]
44
+ is_2d: bool
45
+ shape: tuple
46
+ props: list
47
+ scale_factor: float
48
+ scaling_options: list
49
+ selected_images: set
50
+ current_view: str
51
+ graph_obj: FiberNetworkBuilder
52
+
53
+ def __init__(self, img_path, out_dir, cfg_file="", auto_scale=True):
54
+ """
55
+ A class for processing and preparing microscopy images for building a fiber graph network.
56
+
57
+ Args:
58
+ img_path (str | list): input image path
59
+ out_dir (str): directory path for storing results
60
+ cfg_file (str): configuration file path
61
+ auto_scale (bool): whether to automatically scale the image
62
+
63
+ >>>
64
+ >>> i_path = "path/to/image"
65
+ >>> o_dir = ""
66
+ >>>
67
+ >>> imp_obj = ImageProcessor(i_path, o_dir)
68
+ >>> imp_obj.apply_img_filters()
69
+ """
70
+ super(ImageProcessor, self).__init__()
71
+ self.img_path: str = img_path if type(img_path) is str else img_path[0]
72
+ self.output_dir: str = out_dir
73
+ self.config_file: str = cfg_file
74
+ self.auto_scale: bool = auto_scale
75
+ self.image_batches: list[ImageProcessor.ImageBatch] = []
76
+ self.selected_batch: int = 0
77
+ self._initialize_image_batches(self._load_img_from_file(img_path))
78
+
79
+ def _load_img_from_file(self, file: list | str):
80
+ """
81
+ Read the image and save it as an OpenCV object.
82
+
83
+ Most 3D images are like layers of multiple image frames layered on-top of each other. The image frames may be
84
+ images of the same object/item through time or through space (i.e., from different angles). Our approach is to
85
+ separate these frames, extract GT graphs from them, and then the layer back from the extracted graphs in the same order.
86
+
87
+ Our software will display all the frames retrieved from the 3D image (automatically downsample large ones
88
+ depending on the user-selected re-scaling options), and allows the user to select which frames to run
89
+ GT computations on. (Some frames are just too noisy to be used.)
90
+
91
+ Again, our software provides a button that allows the user to select which frames are used to reconstruct the
92
+ layered GT graphs in the same order as their respective frames.
93
+
94
+ :param file: The file path.
95
+ :return: list[ImageProcessor.ImageBatch]
96
+ """
97
+
98
+ # First file if it's a list
99
+ ext = os.path.splitext(file[0])[1].lower() if (type(file) is list) else os.path.splitext(file)[1].lower()
100
+ try:
101
+ if ext in ['.png', '.jpg', '.jpeg']:
102
+ image_groups = defaultdict(list)
103
+ if type(file) is list:
104
+ for img in file:
105
+ # Create clusters/groups of similar size images
106
+ frame = cv2.imread(img, cv2.IMREAD_UNCHANGED)
107
+ h, w = frame.shape[:2]
108
+ image_groups[(h, w)].append(frame)
109
+ else:
110
+ # Load standard 2D images with OpenCV
111
+ image = cv2.imread(file, cv2.IMREAD_UNCHANGED)
112
+ if image is None:
113
+ raise ValueError(f"Failed to load {file}")
114
+ # Cluster the images into batches based on (h, w) size
115
+ h, w = image.shape[:2]
116
+ image_groups[(h, w)].append(image)
117
+ img_batch_groups = ImageProcessor.create_img_batch_groups(image_groups, self.config_file,
118
+ self.auto_scale)
119
+ return img_batch_groups
120
+ elif ext in ['.tif', '.tiff', '.qptiff']:
121
+ image_groups = defaultdict(list)
122
+ if type(file) is list:
123
+ for img in file:
124
+ # Create clusters/groups of similar size images
125
+ frame = cv2.imread(img, cv2.IMREAD_UNCHANGED)
126
+ h, w = frame.shape[:2]
127
+ image_groups[(h, w)].append(frame)
128
+ else:
129
+ # Try load multi-page TIFF using PIL
130
+ img = Image.open(file)
131
+ while True:
132
+ # Create clusters/groups of similar size images
133
+ frame = np.array(img) # Convert the current frame to the numpy array
134
+ # Cluster the images into batches based on (h, w) size
135
+ h, w = frame.shape[:2]
136
+ image_groups[(h, w)].append(frame)
137
+ try:
138
+ # Move to the next frame
139
+ img.seek(img.tell() + 1)
140
+ except EOFError:
141
+ # Stop when all frames are read
142
+ break
143
+ img_batch_groups = ImageProcessor.create_img_batch_groups(image_groups, self.config_file,
144
+ self.auto_scale)
145
+ return img_batch_groups
146
+ elif ext in ['.nii', '.nii.gz']:
147
+ """# Load NIfTI image using nibabel
148
+ img_nib = nib.load(file)
149
+ data = img_nib.get_fdata()
150
+ # Normalize and convert to uint8 for OpenCV compatibility
151
+ data = cv2.normalize(data, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
152
+ return data"""
153
+ return []
154
+ elif ext == '.dcm':
155
+ """# Load DICOM image using pydicom
156
+ dcm = pydicom.dcmread(file)
157
+ data = dcm.pixel_array
158
+ # Normalize and convert to uint8 if needed
159
+ data = cv2.normalize(data, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
160
+ return data"""
161
+ return []
162
+ else:
163
+ raise ValueError(f"Unsupported file format: {ext}")
164
+ except Exception as err:
165
+ logging.exception(f"Error loading {file}:", err, extra={'user': 'SGT Logs'})
166
+ # self.update_status([-1, f"Failed to load {file}: {err}"])
167
+ return None
168
+
169
+ def _initialize_image_batches(self, img_batches: list[ImageBatch]):
170
+ """
171
+ Retrieve all image slices of the selected image batch. If the image is 2D, only one slice exists
172
+ if it is 3D, then multiple slices exist.
173
+ """
174
+
175
+ # Check if image batches exist
176
+ if len(img_batches) == 0:
177
+ raise ValueError("No images available! Please add at least one image.")
178
+
179
+ for i, img_batch in enumerate(img_batches):
180
+ img_data = img_batch.numpy_image
181
+ scale_factor = img_batch.scale_factor
182
+
183
+ # Load images for processing
184
+ if img_data is None:
185
+ raise ValueError(f"Problem with images in batch {i}!")
186
+
187
+ has_alpha, _ = BaseImage.check_alpha_channel(img_data)
188
+ image_list = []
189
+ if (len(img_data.shape) >= 3) and (not has_alpha):
190
+ # If the image has shape (d, h, w) and does not an alpha channel which is less than 4 - (h, w, a)
191
+ image_list = [BaseImage(img, self.config_file, scale_factor) for img in img_data]
192
+ else:
193
+ img_obj = BaseImage(img_data, self.config_file, scale_factor)
194
+ image_list.append(img_obj)
195
+
196
+ is_2d = True
197
+ if len(image_list) == 1:
198
+ if len(img_data.shape) == 3 and image_list[0].has_alpha_channel:
199
+ logging.info("Image is 2D with Alpha Channel.", extra={'user': 'SGT Logs'})
200
+ # self.update_status([101, "Image is 2D with Alpha Channel"])
201
+ else:
202
+ logging.info("Image is 2D.", extra={'user': 'SGT Logs'})
203
+ # self.update_status([101, "Image is 2D"])
204
+ elif len(image_list) > 1:
205
+ is_2d = False
206
+ logging.info("Image is 3D.", extra={'user': 'SGT Logs'})
207
+ # self.update_status([101, "Image is 3D"])
208
+
209
+ img_batch.images = image_list
210
+ img_batch.is_2d = is_2d
211
+ self.update_image_props(img_batch)
212
+ self.image_batches = img_batches
213
+
214
+ def select_image_batch(self, sel_batch_idx: int, selected_images: set = None):
215
+ """
216
+ Update the selected image batch and the selected image slices.
217
+
218
+ Args:
219
+ sel_batch_idx: index of the selected image batch
220
+ selected_images: indices of the selected image slices.
221
+
222
+ Returns:
223
+
224
+ """
225
+
226
+ if sel_batch_idx >= len(self.image_batches):
227
+ raise ValueError(
228
+ f"Selected image batch {sel_batch_idx} out of range! Select in range 0-{len(self.image_batches)}")
229
+
230
+ self.selected_batch = sel_batch_idx
231
+ self.update_image_props(self.image_batches[sel_batch_idx])
232
+ self.reset_img_filters()
233
+
234
+ if selected_images is None:
235
+ return
236
+
237
+ if type(selected_images) is set:
238
+ self.image_batches[sel_batch_idx].selected_images = selected_images
239
+
240
+ def track_progress(self, value, msg):
241
+ self.update_status([value, msg])
242
+
243
+ def apply_img_filters(self, filter_type=2):
244
+ """
245
+ Executes function for processing image filters and converting the resulting image into a binary.
246
+
247
+ Filter Types:
248
+ 1 - Just Image Filters
249
+ 2 - Both Image and Binary (1 and 2) Filters
250
+
251
+ :return: None
252
+ """
253
+
254
+ self.update_status([10, "Processing image..."])
255
+ if filter_type == 2:
256
+ self.reset_img_filters()
257
+
258
+ sel_batch = self.get_selected_batch()
259
+ progress = 10
260
+ incr = 90 / len(sel_batch.images) - 1
261
+ for i in range(len(sel_batch.images)):
262
+ img_obj = sel_batch.images[i]
263
+ if i not in sel_batch.selected_images:
264
+ img_obj.img_mod, img_obj.img_bin = None, None
265
+ continue
266
+
267
+ if progress < 100:
268
+ progress += incr
269
+ self.update_status([progress, "Image processing in progress..."])
270
+
271
+ img_data = img_obj.img_2d.copy()
272
+ img_obj.img_mod = img_obj.process_img(image=img_data)
273
+
274
+ if filter_type == 2:
275
+ img_mod = img_obj.img_mod.copy()
276
+ img_obj.img_bin = img_obj.binarize_img(img_mod)
277
+ img_obj.get_pixel_width()
278
+
279
+ self.update_status([100, "Image processing complete..."])
280
+
281
+ def reset_img_filters(self):
282
+ """Delete existing filters that have been applied on the image."""
283
+ sel_batch = self.get_selected_batch()
284
+ for img_obj in sel_batch.images:
285
+ img_obj.img_mod, img_obj.img_bin = None, None
286
+ sel_batch.graph_obj.reset_graph()
287
+
288
+ def apply_img_scaling(self):
289
+ """Re-scale (downsample or up-sample) a 2D image or 3D images to a specified size"""
290
+
291
+ # scale_factor = 1
292
+ sel_batch = self.get_selected_batch()
293
+ if len(sel_batch.images) <= 0:
294
+ return
295
+
296
+ scale_size = 0
297
+ for scale_item in sel_batch.scaling_options:
298
+ try:
299
+ scale_size = scale_item["dataValue"] if scale_item["value"] == 1 else scale_size
300
+ except KeyError:
301
+ continue
302
+
303
+ if scale_size <= 0:
304
+ return
305
+
306
+ img_px_size = 1
307
+ for img_obj in sel_batch.images:
308
+ img = img_obj.img_raw
309
+ temp_px = max(img.shape[0], img.shape[1])
310
+ img_px_size = temp_px if temp_px > img_px_size else img_px_size
311
+ scale_factor = scale_size / img_px_size
312
+
313
+ # Resize (Downsample) all frames to the smaller pixel size while maintaining the aspect ratio
314
+ for img_obj in sel_batch.images:
315
+ img = img_obj.img_raw.copy()
316
+ scale_size = scale_factor * max(img.shape[0], img.shape[1])
317
+ img_small, _ = BaseImage.resize_img(scale_size, img)
318
+ if img_small is None:
319
+ # raise Exception("Unable to Rescale Image")
320
+ return
321
+ img_obj.img_2d = img_small
322
+ img_obj.scale_factor = scale_factor
323
+ self.update_image_props(sel_batch)
324
+
325
+ def crop_image(self, x: int, y: int, crop_w: int, crop_h: int, actual_w: int, actual_h: int):
326
+ """
327
+ A function that crops images into a new box dimension.
328
+
329
+ :param x: Left coordinate of cropping box.
330
+ :param y: Top coordinate of cropping box.
331
+ :param crop_w: Width of cropping box.
332
+ :param crop_h: Height of cropping box.
333
+ :param actual_w: Width of actual image.
334
+ :param actual_h: Height of actual image.
335
+ """
336
+ sel_batch = self.get_selected_batch()
337
+ if len(sel_batch.selected_images) > 0:
338
+ [sel_batch.images[i].apply_img_crop(x, y, crop_w, crop_h, actual_w, actual_h) for i in
339
+ sel_batch.selected_images]
340
+ self.update_image_props(sel_batch)
341
+ sel_batch.current_view = 'processed'
342
+
343
+ def undo_cropping(self):
344
+ """
345
+ A function that restores the image to its original size.
346
+ """
347
+ sel_batch = self.get_selected_batch()
348
+ if len(sel_batch.selected_images) > 0:
349
+ [sel_batch.images[i].init_image() for i in sel_batch.selected_images]
350
+ self.update_image_props(sel_batch)
351
+
352
+ def build_graph_network(self):
353
+ """Generates or extracts graphs of selected images."""
354
+
355
+ self.update_status([0, "Starting graph extraction..."])
356
+ try:
357
+ # Get the selected batch
358
+ sel_batch = self.get_selected_batch()
359
+ sel_batch.current_view = 'graph'
360
+
361
+ # Get binary image
362
+ sel_images = self.get_selected_images(sel_batch)
363
+ img_bin = [img.img_bin for img in sel_images]
364
+ img_bin = np.asarray(img_bin)
365
+
366
+ # Get the selected batch's graph object and generate the graph
367
+ px_size = float(sel_batch.images[0].configs["pixel_width"]["value"]) # First BaseImage in batch
368
+ rho_val = float(sel_batch.images[0].configs["resistivity"]["value"]) # First BaseImage in batch
369
+ f_name, out_dir = self.get_filenames()
370
+
371
+ sel_batch.graph_obj.abort = False
372
+ sel_batch.graph_obj.add_listener(self.track_progress)
373
+ sel_batch.graph_obj.fit_graph(out_dir, img_bin, sel_batch.is_2d, px_size, rho_val, image_file=f_name)
374
+
375
+ self.update_status([95, "Plotting graph network..."])
376
+ self.draw_graph_image(sel_batch)
377
+
378
+ sel_batch.graph_obj.remove_listener(self.track_progress)
379
+ self.abort = sel_batch.graph_obj.abort
380
+ if self.abort:
381
+ sel_batch.current_view = 'processed'
382
+ return
383
+ except Exception as err:
384
+ self.abort = True
385
+ logging.exception("Graph Extraction Error: %s", err, extra={'user': 'SGT Logs'})
386
+ self.update_status([-1, f"Graph Extraction Error: {err}"])
387
+ return
388
+
389
+ def build_graph_from_patches(self, num_square_filters: int, patch_count_per_filter: int, patch_padding: tuple = (0, 0)):
390
+ """
391
+ Extracts graphs from smaller square patches of selected images.
392
+
393
+ Given `num_square_filters` (k), the method generates k square filters/windows, each of sizes NxN—where N is
394
+ a distinct value computed or estimated for each filter.
395
+
396
+ For every NxN window, it randomly selects `patch_count_per_filter` (m) patches (aligned with the window)
397
+ from across the entire image.
398
+
399
+ :param num_square_filters: Number of square filters to generate.
400
+ :param patch_count_per_filter: Number of patches per filter.
401
+ :param patch_padding: Padding around each patch.
402
+
403
+ """
404
+ # Get the selected batch
405
+ sel_batch = self.get_selected_batch()
406
+ graph_configs = sel_batch.graph_obj.configs
407
+ img_obj = sel_batch.images[0] # ONLY works for 2D
408
+
409
+ def estimate_filter_width(parent_width, num):
410
+ """
411
+ Applies a non-linear function to compute the width-size of a filter based on its index location.
412
+ :param parent_width: Width of parent image.
413
+ :param num: Index of filter.
414
+ """
415
+ # return int(parent_width / ((2*num) + 4))
416
+ # est_w = int((parent_width * np.exp(-0.3 * num) / 4)) # Exponential decay
417
+ est_w = int((parent_width - 10) * (1 - (num/num_square_filters)))
418
+ return max(50, est_w) # Avoid too small sizes
419
+
420
+ def estimate_patches_count(total_patches_count):
421
+ """
422
+ The method computes the best approximate number of patches in a 2D layout that
423
+ will be equal to total_patches_count: total_n = num_rows * num_patches_per_row
424
+
425
+ :param total_patches_count: Total number of patches given by the user
426
+ :return: row_count, patches_count_per_row
427
+ """
428
+ for row_count in range(isqrt(total_patches_count), 0, -1):
429
+ if total_patches_count % row_count == 0:
430
+ num_patches_per_row = total_patches_count // row_count
431
+ return row_count, num_patches_per_row
432
+ return 1, total_patches_count
433
+
434
+ def extract_cnn_patches(img: MatLike, num_filters: int, num_patches: int, padding: tuple):
435
+ """
436
+ Perform a convolution operation that breaks down an image into smaller square mini-images.
437
+ Extract all patches from the image based on filter size, stride, and padding, similar to
438
+ CNN convolution but without applying the filter.
439
+
440
+ :param img: OpenCV image.
441
+ :param num_filters: Number of convolution filters.
442
+ :param num_patches: Number of patches to extract per filter window size.
443
+ :param padding: Padding value (pad_y, pad_x).
444
+ :return: List of convolved images.
445
+ """
446
+ if img is None:
447
+ return []
448
+
449
+ # Initialize Parameters
450
+ lst_img_seg = []
451
+
452
+ # Pad the image
453
+ pad_h, pad_w = padding
454
+ img_padded = np.pad(img, ((pad_h, pad_h), (pad_w, pad_w)), mode='constant')
455
+ h, w = img.shape[:2]
456
+ orig_img_width = h if h < w else w
457
+ num_rows, num_cols = estimate_patches_count(num_patches)
458
+
459
+ for k in range(num_filters):
460
+ temp_w = estimate_filter_width(orig_img_width, k)
461
+ k_h, k_w = (temp_w, temp_w)
462
+ stride_h = int((h + (2 * pad_h) - temp_w) / (num_rows - 1)) if num_rows > 1 else int(
463
+ (h + (2 * pad_h) - temp_w))
464
+ stride_w = int((w + (2 * pad_w) - temp_w) / (num_cols - 1)) if num_cols > 1 else int(
465
+ (w + (2 * pad_w) - temp_w))
466
+
467
+ img_scaling = BaseImage.ScalingFilter(
468
+ image_patches=[],
469
+ # graph_patches=[],
470
+ filter_size=(k_h, k_w),
471
+ stride=(stride_h, stride_w)
472
+ )
473
+
474
+ # Sliding-window to extract patches
475
+ for y in range(0, h - k_h + 1, stride_h):
476
+ for x in range(0, w - k_w + 1, stride_w):
477
+ patch = img_padded[y:y + k_h, x:x + k_w]
478
+ img_scaling.image_patches.append(patch)
479
+ lst_img_seg.append(img_scaling)
480
+
481
+ # Stop loop if filter size is too small
482
+ if temp_w <= 50:
483
+ break
484
+ return lst_img_seg
485
+
486
+ if len(img_obj.image_segments) <= 0:
487
+ img_obj.image_segments = extract_cnn_patches(img_obj.img_bin, num_square_filters, patch_count_per_filter, patch_padding)
488
+
489
+ seg_count = len(img_obj.image_segments)
490
+ graph_groups = defaultdict(list)
491
+ for i, scale_filter in enumerate(img_obj.image_segments):
492
+ self.update_status([101, f"Extracting graphs from image filter {i + 1}/{seg_count}..."])
493
+ for img_patch in scale_filter.image_patches:
494
+ graph_patch = FiberNetworkBuilder(cfg_file=self.config_file)
495
+ graph_patch.configs = graph_configs
496
+ success = graph_patch.extract_graph(img_patch, is_img_2d=True)
497
+ if success:
498
+ height, width = img_patch.shape
499
+ graph_groups[(height, width)].append(graph_patch.nx_giant_graph)
500
+ else:
501
+ self.update_status([101, f"Filter {img_patch.shape} graph extraction failed!"])
502
+
503
+ return graph_groups
504
+
505
+ def get_filenames(self, image_path: str = None):
506
+ """
507
+ Splits the image path into file name and image directory.
508
+
509
+ :param image_path: Image directory path.
510
+
511
+ Returns:
512
+ filename (str): image file name., output_dir (str): image directory path.
513
+ """
514
+
515
+ img_dir, filename = os.path.split(self.img_path) if image_path is None else os.path.split(image_path)
516
+ output_dir = img_dir if self.output_dir == '' else self.output_dir
517
+
518
+ for ext in ALLOWED_IMG_EXTENSIONS:
519
+ ext = ext.replace('*', '')
520
+ pattern = re.escape(ext) + r'$'
521
+ filename = re.sub(pattern, '', filename)
522
+ return filename, output_dir
523
+
524
+ def get_selected_batch(self):
525
+ """
526
+ Retrieved data of the current selected batch.
527
+ """
528
+ return self.image_batches[self.selected_batch]
529
+
530
+ def get_selected_images(self, selected_batch: ImageBatch):
531
+ """
532
+ Get indices of selected images.
533
+ :param selected_batch: The selected batch ImageBatch object.
534
+ """
535
+ if selected_batch is None:
536
+ selected_batch = self.get_selected_batch()
537
+
538
+ sel_images = [selected_batch.images[i] for i in selected_batch.selected_images]
539
+ return sel_images
540
+
541
+ def update_image_props(self, selected_batch: ImageBatch = None):
542
+ """
543
+ A method that retrieves image properties and stores them in a list-array.
544
+
545
+ :param selected_batch: ImageBatch data object.
546
+
547
+ Returns: list of image properties
548
+
549
+ """
550
+
551
+ if selected_batch is None:
552
+ return
553
+
554
+ f_name, _ = self.get_filenames()
555
+ if len(selected_batch.images) > 1:
556
+ # (Depth, Height, Width, Channels)
557
+ alpha_channel = selected_batch.images[0].has_alpha_channel # first image
558
+ fmt = "Multi + Alpha" if alpha_channel else "Multi"
559
+ num_dim = 3
560
+ else:
561
+ _, fmt = BaseImage.check_alpha_channel(selected_batch.images[0].img_raw) # first image
562
+ num_dim = 2
563
+
564
+ slices = 0
565
+ height, width = selected_batch.images[0].img_2d.shape[:2] # first image
566
+ if num_dim >= 3:
567
+ slices = len(selected_batch.images)
568
+
569
+ props = [
570
+ ["Name", f_name],
571
+ ["Height x Width", f"({height} x {width}) pixels"] if slices == 0
572
+ else ["Depth x H x W", f"({slices} x {height} x {width}) pixels"],
573
+ ["Dimensions", f"{num_dim}D"],
574
+ ["Format", f"{fmt}"],
575
+ # ["Pixel Size", "2nm x 2nm"]
576
+ ]
577
+ selected_batch.props = props
578
+
579
+ def save_images_to_file(self):
580
+ """
581
+ Write images to a file.
582
+ """
583
+
584
+ sel_batch = self.get_selected_batch()
585
+ sel_images = self.get_selected_images(sel_batch)
586
+ is_3d = True if len(sel_images) > 1 else False
587
+ img_file_name, out_dir = self.get_filenames()
588
+
589
+ for i, img in enumerate(sel_images):
590
+ if img.configs["save_images"]["value"] == 0:
591
+ continue
592
+
593
+ filename = f"{img_file_name}_Frame{i}" if is_3d else ''
594
+ pr_filename = filename + "_processed.jpg"
595
+ bin_filename = filename + "_binary.jpg"
596
+ img_file = os.path.join(out_dir, pr_filename)
597
+ bin_file = os.path.join(out_dir, bin_filename)
598
+
599
+ if img.img_mod is not None:
600
+ cv2.imwrite(str(img_file), img.img_mod)
601
+
602
+ if img.img_bin is not None:
603
+ cv2.imwrite(str(bin_file), img.img_bin)
604
+
605
+ """sel_batch = self.get_selected_batch()
606
+ gsd_filename = img_file_name + "_skel.gsd"
607
+ gsd_file = os.path.join(out_dir, gsd_filename)
608
+ if sel_batch.graph_obj.skel_obj.skeleton is not None:
609
+ write_gsd_file(gsd_file, sel_batch.graph_obj.skel_obj.skeleton)"""
610
+
611
+ def draw_graph_image(self, sel_batch: ImageBatch, show_giant_only: bool = False):
612
+ """
613
+ Use Matplotlib to draw the extracted graph which is superimposed on the processed image.
614
+
615
+ :param sel_batch: ImageBatch data object.
616
+ :param show_giant_only: If True, only draw the largest/giant graph on the processed image.
617
+ """
618
+ sel_images = self.get_selected_images(sel_batch)
619
+ img_3d = [img.img_2d for img in sel_images]
620
+ img_3d = np.asarray(img_3d)
621
+
622
+ if sel_batch.graph_obj is None:
623
+ return
624
+
625
+ plt_fig = sel_batch.graph_obj.plot_graph_network(image_arr=img_3d, giant_only=show_giant_only)
626
+ if plt_fig is not None:
627
+ sel_batch.graph_obj.img_ntwk = plot_to_opencv(plt_fig)
628
+
629
+ # MODIFIED TO EXCLUDE 3D IMAGES (TO BE REVISITED LATER)
630
+ # Problems:
631
+ # 1. Merge Nodes
632
+ # 2. Prune dangling edges
633
+ # 3. Matplotlib plot nodes and edges
634
+ @staticmethod
635
+ def create_img_batch_groups(img_groups: defaultdict, cfg_file: str, auto_scale: bool):
636
+ """"""
637
+
638
+ def get_scaling_options(orig_size: float):
639
+ """"""
640
+ orig_size = int(orig_size)
641
+ if orig_size > 2048:
642
+ recommended_size = 1024
643
+ scaling_options = [1024, 2048, int(orig_size * 0.25), int(orig_size * 0.5), int(orig_size * 0.75),
644
+ orig_size]
645
+ elif orig_size > 1024:
646
+ recommended_size = 1024
647
+ scaling_options = [1024, int(orig_size * 0.25), int(orig_size * 0.5), int(orig_size * 0.75), orig_size]
648
+ else:
649
+ recommended_size = orig_size
650
+ scaling_options = [int(orig_size * 0.25), int(orig_size * 0.5), int(orig_size * 0.75), orig_size]
651
+
652
+ # Remove duplicates and arrange in ascending order
653
+ scaling_options = sorted(list(set(scaling_options)))
654
+ scaling_data = []
655
+ for val in scaling_options:
656
+ data = {"text": f"{val} px", "value": 0, "dataValue": val}
657
+ if val == orig_size:
658
+ data["text"] = f"{data['text']}*"
659
+
660
+ if val == recommended_size:
661
+ data["text"] = f"{data['text']} (recommended)"
662
+ data["value"] = 1 if auto_scale else 0
663
+ scaling_data.append(data)
664
+ return scaling_data
665
+
666
+ def rescale_img(image_data, scale_options):
667
+ """Downsample or up-sample image to a specified pixel size."""
668
+
669
+ scale_factor = 1
670
+ img_2d, img_3d = None, None
671
+
672
+ if image_data is None:
673
+ return None, scale_factor
674
+
675
+ scale_size = 0
676
+ for scale_item in scale_options:
677
+ try:
678
+ scale_size = scale_item["dataValue"] if scale_item["value"] == 1 else scale_size
679
+ except KeyError:
680
+ continue
681
+
682
+ if scale_size <= 0:
683
+ return None, scale_factor
684
+
685
+ # if type(image_data) is np.ndarray:
686
+ has_alpha, _ = BaseImage.check_alpha_channel(image_data)
687
+ if (len(image_data.shape) == 2) or has_alpha:
688
+ # If the image has shape (h, w) or shape (h, w, a), where 'a' - alpha channel which is less than 4
689
+ img_2d, scale_factor = BaseImage.resize_img(scale_size, image_data)
690
+ return img_2d, scale_factor
691
+
692
+ # if type(image_data) is list:
693
+ if (len(image_data.shape) >= 3) and (not has_alpha):
694
+ # If the image has shape (d, h, w) and third is not alpha channel
695
+ img_3d = []
696
+ for img in image_data:
697
+ img_small, scale_factor = BaseImage.resize_img(scale_size, img)
698
+ img_3d.append(img_small)
699
+ return np.array(img_3d), scale_factor
700
+
701
+ img_info_list = []
702
+ for (h, w), images in img_groups.items():
703
+ images_small = []
704
+ scaling_factor = 1
705
+ scaling_opts = []
706
+ images = np.array(images)
707
+ max_size = max(h, w)
708
+ if max_size > 0 and auto_scale:
709
+ scaling_opts = get_scaling_options(max_size)
710
+ images_small, scaling_factor = rescale_img(images, scaling_opts)
711
+
712
+ # Convert back to numpy arrays
713
+ images = images_small if len(images_small) > 0 else images
714
+ images = np.array([images[0]]) # REMOVE TO ALLOW 3D
715
+ img_batch = ImageProcessor.ImageBatch(
716
+ numpy_image=images,
717
+ images=[],
718
+ is_2d=True,
719
+ shape=(h, w),
720
+ props=[],
721
+ scale_factor=scaling_factor,
722
+ scaling_options=scaling_opts,
723
+ selected_images=set(range(len(images))),
724
+ current_view='original', # 'original', 'binary', 'processed', 'graph'
725
+ graph_obj=FiberNetworkBuilder(cfg_file=cfg_file)
726
+ )
727
+ img_info_list.append(img_batch)
728
+ break # REMOVE TO ALLOW 3D
729
+ return img_info_list
730
+
731
+ @classmethod
732
+ def create_imp_object(cls, img_path: str, out_path: str = "", config_file: str = "", allow_auto_scale: bool = True):
733
+ """
734
+ Creates an ImageProcessor object. Make sure the image path exists, is verified, and points to an image.
735
+ :param img_path: Path to the image to be processed
736
+ :param out_path: Path to the output directory
737
+ :param config_file: Path to the config file
738
+ :param allow_auto_scale: Allows automatic scaling of the image
739
+ :return: ImageProcessor object.
740
+ """
741
+
742
+ # Get the image path and folder
743
+ img_files = []
744
+ img_dir, img_file = os.path.split(str(img_path))
745
+ img_file_ext = os.path.splitext(img_file)[1].lower()
746
+
747
+ is_prefix = True
748
+ # Regex pattern to extract the prefix (non-digit characters at the beginning of the file name)
749
+ img_name_pattern = re.match(r'^([a-zA-Z_]+)(\d+)(?=\.[a-zA-Z]+$)', img_file)
750
+ if img_name_pattern is None:
751
+ # Regex pattern to extract the suffix (non-digit characters at the end of the file name)
752
+ is_prefix = False
753
+ img_name_pattern = re.match(r'^\d+([a-zA-Z_]+)(?=\.[a-zA-Z]+$)', img_file)
754
+
755
+ if img_name_pattern:
756
+ img_files.append(img_path)
757
+ f_name = img_name_pattern.group(1)
758
+ name_pattern = re.compile(rf'^{f_name}\d+{re.escape(img_file_ext)}$', re.IGNORECASE) \
759
+ if is_prefix else re.compile(rf'^\d+{f_name}{re.escape(img_file_ext)}$', re.IGNORECASE)
760
+
761
+ # Check if 3D image slices exist in the image folder. Same file name but different number
762
+ files = sorted(os.listdir(img_dir))
763
+ for a_file in files:
764
+ if a_file.endswith(img_file_ext):
765
+ if name_pattern.match(a_file):
766
+ img_files.append(os.path.join(img_dir, a_file))
767
+
768
+ # Create the Output folder if it does not exist
769
+ default_out_dir = img_dir
770
+ if out_path != "":
771
+ default_out_dir = out_path
772
+
773
+ out_dir_name = "sgt_files"
774
+ out_dir = os.path.join(default_out_dir, out_dir_name)
775
+ out_dir = os.path.normpath(out_dir)
776
+ os.makedirs(out_dir, exist_ok=True)
777
+
778
+ # Create the StructuralGT object
779
+ input_file = img_files if len(img_files) > 1 else str(img_path)
780
+ return cls(input_file, out_dir, config_file, allow_auto_scale), img_file