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.
- StructuralGT/__init__.py +31 -0
- StructuralGT/apps/__init__.py +0 -0
- StructuralGT/apps/cli_main.py +258 -0
- StructuralGT/apps/gui_main.py +69 -0
- StructuralGT/apps/gui_mcw/__init__.py +0 -0
- StructuralGT/apps/gui_mcw/checkbox_model.py +91 -0
- StructuralGT/apps/gui_mcw/controller.py +1073 -0
- StructuralGT/apps/gui_mcw/image_provider.py +74 -0
- StructuralGT/apps/gui_mcw/imagegrid_model.py +75 -0
- StructuralGT/apps/gui_mcw/qthread_worker.py +102 -0
- StructuralGT/apps/gui_mcw/table_model.py +79 -0
- StructuralGT/apps/gui_mcw/tree_model.py +154 -0
- StructuralGT/apps/sgt_qml/CenterMainContent.qml +19 -0
- StructuralGT/apps/sgt_qml/LeftContent.qml +48 -0
- StructuralGT/apps/sgt_qml/MainWindow.qml +762 -0
- StructuralGT/apps/sgt_qml/RightLoggingPanel.qml +125 -0
- StructuralGT/apps/sgt_qml/assets/icons/.DS_Store +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/back_icon.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/brightness_icon.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/cancel_icon.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/crop_icon.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/edit_icon.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/graph_icon.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/hide_panel.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/next_icon.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/notify_icon.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/rescale_icon.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/show_panel.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/square_icon.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/undo_icon.png +0 -0
- StructuralGT/apps/sgt_qml/components/ImageFilters.qml +82 -0
- StructuralGT/apps/sgt_qml/components/ImageProperties.qml +112 -0
- StructuralGT/apps/sgt_qml/components/ProjectNav.qml +127 -0
- StructuralGT/apps/sgt_qml/widgets/BinaryFilterWidget.qml +151 -0
- StructuralGT/apps/sgt_qml/widgets/BrightnessControlWidget.qml +103 -0
- StructuralGT/apps/sgt_qml/widgets/CreateProjectWidget.qml +112 -0
- StructuralGT/apps/sgt_qml/widgets/GTWidget.qml +94 -0
- StructuralGT/apps/sgt_qml/widgets/GraphComputeWidget.qml +77 -0
- StructuralGT/apps/sgt_qml/widgets/GraphExtractWidget.qml +175 -0
- StructuralGT/apps/sgt_qml/widgets/GraphPropertyWidget.qml +77 -0
- StructuralGT/apps/sgt_qml/widgets/ImageFilterWidget.qml +137 -0
- StructuralGT/apps/sgt_qml/widgets/ImagePropertyWidget.qml +78 -0
- StructuralGT/apps/sgt_qml/widgets/ImageViewWidget.qml +585 -0
- StructuralGT/apps/sgt_qml/widgets/MenuBarWidget.qml +137 -0
- StructuralGT/apps/sgt_qml/widgets/MicroscopyPropertyWidget.qml +80 -0
- StructuralGT/apps/sgt_qml/widgets/ProjectWidget.qml +141 -0
- StructuralGT/apps/sgt_qml/widgets/RescaleControlWidget.qml +83 -0
- StructuralGT/apps/sgt_qml/widgets/RibbonWidget.qml +406 -0
- StructuralGT/apps/sgt_qml/widgets/StatusBarWidget.qml +173 -0
- StructuralGT/compute/__init__.py +0 -0
- StructuralGT/compute/c_lang/include/sgt_base.h +21 -0
- StructuralGT/compute/graph_analyzer.py +1499 -0
- StructuralGT/entrypoints.py +49 -0
- StructuralGT/imaging/__init__.py +0 -0
- StructuralGT/imaging/base_image.py +403 -0
- StructuralGT/imaging/image_processor.py +780 -0
- StructuralGT/modules.py +29 -0
- StructuralGT/networks/__init__.py +0 -0
- StructuralGT/networks/fiber_network.py +490 -0
- StructuralGT/networks/graph_skeleton.py +425 -0
- StructuralGT/networks/sknw_mod.py +199 -0
- StructuralGT/utils/__init__.py +0 -0
- StructuralGT/utils/config_loader.py +244 -0
- StructuralGT/utils/configs.ini +97 -0
- StructuralGT/utils/progress_update.py +67 -0
- StructuralGT/utils/sgt_utils.py +291 -0
- sgtlib-3.3.9.dist-info/METADATA +789 -0
- sgtlib-3.3.9.dist-info/RECORD +72 -0
- sgtlib-3.3.9.dist-info/WHEEL +5 -0
- sgtlib-3.3.9.dist-info/entry_points.txt +3 -0
- sgtlib-3.3.9.dist-info/licenses/LICENSE +674 -0
- 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
|