spacr 0.0.1__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.
spacr/mask_app.py ADDED
@@ -0,0 +1,818 @@
1
+ import os
2
+ import numpy as np
3
+ import tkinter as tk
4
+ import imageio.v2 as imageio
5
+ from collections import deque
6
+ from PIL import Image, ImageTk
7
+ from skimage.draw import polygon, line
8
+ from skimage.transform import resize
9
+ from scipy.ndimage import binary_fill_holes, label
10
+ import tkinter as tk
11
+ from tkinter import ttk
12
+ from ttkthemes import ThemedTk
13
+
14
+ from .logger import log_function_call
15
+
16
+ from .gui_utils import ScrollableFrame, set_dark_style, set_default_font, create_dark_mode
17
+
18
+ class modify_masks:
19
+
20
+ def __init__(self, root, folder_path, scale_factor, width, height):
21
+ self.root = root
22
+ self.folder_path = folder_path
23
+ self.scale_factor = scale_factor
24
+ self.image_filenames = sorted([f for f in os.listdir(folder_path) if f.endswith(('.png', '.jpg', '.jpeg', '.tif', '.tiff'))])
25
+ self.masks_folder = os.path.join(folder_path, 'masks')
26
+ self.current_image_index = 0
27
+ self.initialize_flags()
28
+ self.canvas_width = width
29
+ self.canvas_height = height
30
+ self.setup_navigation_toolbar()
31
+ self.setup_mode_toolbar()
32
+ self.setup_function_toolbar()
33
+ self.setup_zoom_toolbar()
34
+ self.setup_canvas()
35
+ self.load_first_image()
36
+
37
+ ####################################################################################################
38
+ # Helper functions#
39
+ ####################################################################################################
40
+
41
+ def update_display(self):
42
+ if self.zoom_active:
43
+ self.display_zoomed_image()
44
+ else:
45
+ self.display_image()
46
+
47
+ def update_original_mask_from_zoom(self):
48
+ y0, y1, x0, x1 = self.zoom_y0, self.zoom_y1, self.zoom_x0, self.zoom_x1
49
+ zoomed_mask_resized = resize(self.zoom_mask, (y1 - y0, x1 - x0), order=0, preserve_range=True).astype(np.uint8)
50
+ self.mask[y0:y1, x0:x1] = zoomed_mask_resized
51
+
52
+ def update_original_mask(self, zoomed_mask, x0, x1, y0, y1):
53
+ actual_mask_region = self.mask[y0:y1, x0:x1]
54
+ target_shape = actual_mask_region.shape
55
+ resized_mask = resize(zoomed_mask, target_shape, order=0, preserve_range=True).astype(np.uint8)
56
+ if resized_mask.shape != actual_mask_region.shape:
57
+ raise ValueError(f"Shape mismatch: resized_mask {resized_mask.shape}, actual_mask_region {actual_mask_region.shape}")
58
+ self.mask[y0:y1, x0:x1] = np.maximum(actual_mask_region, resized_mask)
59
+ self.mask = self.mask.copy()
60
+ self.mask[y0:y1, x0:x1] = np.maximum(self.mask[y0:y1, x0:x1], resized_mask)
61
+ self.mask = self.mask.copy()
62
+
63
+ def get_scaling_factors(self, img_width, img_height, canvas_width, canvas_height):
64
+ x_scale = img_width / canvas_width
65
+ y_scale = img_height / canvas_height
66
+ return x_scale, y_scale
67
+
68
+ def canvas_to_image(self, x_canvas, y_canvas):
69
+ x_scale, y_scale = self.get_scaling_factors(
70
+ self.image.shape[1], self.image.shape[0],
71
+ self.canvas_width, self.canvas_height
72
+ )
73
+ x_image = int(x_canvas * x_scale)
74
+ y_image = int(y_canvas * y_scale)
75
+ return x_image, y_image
76
+
77
+ def apply_zoom_on_enter(self, event):
78
+ if self.zoom_active and self.zoom_rectangle_start is not None:
79
+ self.set_zoom_rectangle_end(event)
80
+
81
+ def normalize_image(self, image, lower_quantile, upper_quantile):
82
+ lower_bound = np.percentile(image, lower_quantile)
83
+ upper_bound = np.percentile(image, upper_quantile)
84
+ normalized = np.clip(image, lower_bound, upper_bound)
85
+ normalized = (normalized - lower_bound) / (upper_bound - lower_bound)
86
+ max_value = np.iinfo(image.dtype).max
87
+ normalized = (normalized * max_value).astype(image.dtype)
88
+ return normalized
89
+
90
+ def resize_arrays(self, img, mask):
91
+ original_dtype = img.dtype
92
+ scaled_height = int(img.shape[0] * self.scale_factor)
93
+ scaled_width = int(img.shape[1] * self.scale_factor)
94
+ scaled_img = resize(img, (scaled_height, scaled_width), anti_aliasing=True, preserve_range=True)
95
+ scaled_mask = resize(mask, (scaled_height, scaled_width), order=0, anti_aliasing=False, preserve_range=True)
96
+ stretched_img = resize(scaled_img, (self.canvas_height, self.canvas_width), anti_aliasing=True, preserve_range=True)
97
+ stretched_mask = resize(scaled_mask, (self.canvas_height, self.canvas_width), order=0, anti_aliasing=False, preserve_range=True)
98
+ return stretched_img.astype(original_dtype), stretched_mask.astype(original_dtype)
99
+
100
+ ####################################################################################################
101
+ #Initiate canvas elements#
102
+ ####################################################################################################
103
+
104
+ def load_first_image(self):
105
+ self.image, self.mask = self.load_image_and_mask(self.current_image_index)
106
+ self.original_size = self.image.shape
107
+ self.image, self.mask = self.resize_arrays(self.image, self.mask)
108
+ self.display_image()
109
+
110
+ def setup_canvas(self):
111
+ self.canvas = tk.Canvas(self.root, width=self.canvas_width, height=self.canvas_height)
112
+ self.canvas.pack()
113
+ self.canvas.bind("<Motion>", self.update_mouse_info)
114
+
115
+ def initialize_flags(self):
116
+ self.zoom_rectangle_start = None
117
+ self.zoom_rectangle_end = None
118
+ self.zoom_rectangle_id = None
119
+ self.zoom_x0 = None
120
+ self.zoom_y0 = None
121
+ self.zoom_x1 = None
122
+ self.zoom_y1 = None
123
+ self.zoom_mask = None
124
+ self.zoom_image = None
125
+ self.zoom_image_orig = None
126
+ self.zoom_scale = 1
127
+ self.drawing = False
128
+ self.zoom_active = False
129
+ self.magic_wand_active = False
130
+ self.brush_active = False
131
+ self.lower_quantile = tk.StringVar(value="1.0")
132
+ self.upper_quantile = tk.StringVar(value="99.9")
133
+ self.magic_wand_tolerance = tk.StringVar(value="1000")
134
+
135
+ def update_mouse_info(self, event):
136
+ x, y = event.x, event.y
137
+ intensity = "N/A"
138
+ mask_value = "N/A"
139
+ pixel_count = "N/A"
140
+ if self.zoom_active:
141
+ if 0 <= x < self.canvas_width and 0 <= y < self.canvas_height:
142
+ intensity = self.zoom_image_orig[y, x] if self.zoom_image_orig is not None else "N/A"
143
+ mask_value = self.zoom_mask[y, x] if self.zoom_mask is not None else "N/A"
144
+ else:
145
+ if 0 <= x < self.image.shape[1] and 0 <= y < self.image.shape[0]:
146
+ intensity = self.image[y, x]
147
+ mask_value = self.mask[y, x]
148
+ if mask_value != "N/A" and mask_value != 0:
149
+ pixel_count = np.sum(self.mask == mask_value)
150
+ self.intensity_label.config(text=f"Intensity: {intensity}")
151
+ self.mask_value_label.config(text=f"Mask: {mask_value}, Area: {pixel_count}")
152
+ self.mask_value_label.config(text=f"Mask: {mask_value}")
153
+ if mask_value != "N/A" and mask_value != 0:
154
+ self.pixel_count_label.config(text=f"Area: {pixel_count}")
155
+ else:
156
+ self.pixel_count_label.config(text="Area: N/A")
157
+
158
+ def setup_navigation_toolbar(self):
159
+ navigation_toolbar = tk.Frame(self.root)
160
+ navigation_toolbar.pack(side='top', fill='x')
161
+ prev_btn = tk.Button(navigation_toolbar, text="Previous", command=self.previous_image)
162
+ prev_btn.pack(side='left')
163
+ next_btn = tk.Button(navigation_toolbar, text="Next", command=self.next_image)
164
+ next_btn.pack(side='left')
165
+ save_btn = tk.Button(navigation_toolbar, text="Save", command=self.save_mask)
166
+ save_btn.pack(side='left')
167
+ self.intensity_label = tk.Label(navigation_toolbar, text="Image: N/A")
168
+ self.intensity_label.pack(side='right')
169
+ self.mask_value_label = tk.Label(navigation_toolbar, text="Mask: N/A")
170
+ self.mask_value_label.pack(side='right')
171
+ self.pixel_count_label = tk.Label(navigation_toolbar, text="Area: N/A")
172
+ self.pixel_count_label.pack(side='right')
173
+
174
+ def setup_mode_toolbar(self):
175
+ self.mode_toolbar = tk.Frame(self.root)
176
+ self.mode_toolbar.pack(side='top', fill='x')
177
+ self.draw_btn = tk.Button(self.mode_toolbar, text="Draw", command=self.toggle_draw_mode)
178
+ self.draw_btn.pack(side='left')
179
+ self.magic_wand_btn = tk.Button(self.mode_toolbar, text="Magic Wand", command=self.toggle_magic_wand_mode)
180
+ self.magic_wand_btn.pack(side='left')
181
+ tk.Label(self.mode_toolbar, text="Tolerance:").pack(side='left')
182
+ self.tolerance_entry = tk.Entry(self.mode_toolbar, textvariable=self.magic_wand_tolerance)
183
+ self.tolerance_entry.pack(side='left')
184
+ tk.Label(self.mode_toolbar, text="Max Pixels:").pack(side='left')
185
+ self.max_pixels_entry = tk.Entry(self.mode_toolbar)
186
+ self.max_pixels_entry.insert(0, "1000")
187
+ self.max_pixels_entry.pack(side='left')
188
+ self.erase_btn = tk.Button(self.mode_toolbar, text="Erase", command=self.toggle_erase_mode)
189
+ self.erase_btn.pack(side='left')
190
+
191
+ self.brush_btn = tk.Button(self.mode_toolbar, text="Brush", command=self.toggle_brush_mode)
192
+ self.brush_btn.pack(side='left')
193
+ self.brush_size_entry = tk.Entry(self.mode_toolbar)
194
+ self.brush_size_entry.insert(0, "10")
195
+ self.brush_size_entry.pack(side='left')
196
+ tk.Label(self.mode_toolbar, text="Brush Size:").pack(side='left')
197
+
198
+ def setup_function_toolbar(self):
199
+ self.function_toolbar = tk.Frame(self.root)
200
+ self.function_toolbar.pack(side='top', fill='x')
201
+ self.fill_btn = tk.Button(self.function_toolbar, text="Fill", command=self.fill_objects)
202
+ self.fill_btn.pack(side='left')
203
+ self.relabel_btn = tk.Button(self.function_toolbar, text="Relabel", command=self.relabel_objects)
204
+ self.relabel_btn.pack(side='left')
205
+ self.clear_btn = tk.Button(self.function_toolbar, text="Clear", command=self.clear_objects)
206
+ self.clear_btn.pack(side='left')
207
+ self.invert_btn = tk.Button(self.function_toolbar, text="Invert", command=self.invert_mask)
208
+ self.invert_btn.pack(side='left')
209
+ remove_small_btn = tk.Button(self.function_toolbar, text="Remove Small", command=self.remove_small_objects)
210
+ remove_small_btn.pack(side='left')
211
+ tk.Label(self.function_toolbar, text="Min Area:").pack(side='left')
212
+ self.min_area_entry = tk.Entry(self.function_toolbar)
213
+ self.min_area_entry.insert(0, "100") # Default minimum area
214
+ self.min_area_entry.pack(side='left')
215
+
216
+ def setup_zoom_toolbar(self):
217
+ self.zoom_toolbar = tk.Frame(self.root)
218
+ self.zoom_toolbar.pack(side='top', fill='x')
219
+ self.zoom_btn = tk.Button(self.zoom_toolbar, text="Zoom", command=self.toggle_zoom_mode)
220
+ self.zoom_btn.pack(side='left')
221
+ self.normalize_btn = tk.Button(self.zoom_toolbar, text="Apply Normalization", command=self.apply_normalization)
222
+ self.normalize_btn.pack(side='left')
223
+ self.lower_entry = tk.Entry(self.zoom_toolbar, textvariable=self.lower_quantile)
224
+ self.lower_entry.pack(side='left')
225
+ tk.Label(self.zoom_toolbar, text="Lower Percentile:").pack(side='left')
226
+ self.upper_entry = tk.Entry(self.zoom_toolbar, textvariable=self.upper_quantile)
227
+ self.upper_entry.pack(side='left')
228
+ tk.Label(self.zoom_toolbar, text="Upper Percentile:").pack(side='left')
229
+
230
+ def load_image_and_mask(self, index):
231
+ image_path = os.path.join(self.folder_path, self.image_filenames[index])
232
+ image = imageio.imread(image_path)
233
+ mask_path = os.path.join(self.masks_folder, self.image_filenames[index])
234
+ if os.path.exists(mask_path):
235
+ print(f'loading mask:{mask_path} for image: {image_path}')
236
+ mask = imageio.imread(mask_path)
237
+ if mask.dtype != np.uint8:
238
+ mask = (mask / np.max(mask) * 255).astype(np.uint8)
239
+ else:
240
+ mask = np.zeros(image.shape[:2], dtype=np.uint8)
241
+ print(f'loaded new mask for image: {image_path}')
242
+ return image, mask
243
+
244
+ ####################################################################################################
245
+ # Image Display functions#
246
+ ####################################################################################################
247
+ def display_image(self):
248
+ if self.zoom_rectangle_id is not None:
249
+ self.canvas.delete(self.zoom_rectangle_id)
250
+ self.zoom_rectangle_id = None
251
+ lower_quantile = float(self.lower_quantile.get()) if self.lower_quantile.get() else 1.0
252
+ upper_quantile = float(self.upper_quantile.get()) if self.upper_quantile.get() else 99.9
253
+ normalized = self.normalize_image(self.image, lower_quantile, upper_quantile)
254
+ combined = self.overlay_mask_on_image(normalized, self.mask)
255
+ self.tk_image = ImageTk.PhotoImage(image=Image.fromarray(combined))
256
+ self.canvas.create_image(0, 0, anchor='nw', image=self.tk_image)
257
+
258
+ def display_zoomed_image(self):
259
+ if self.zoom_rectangle_start and self.zoom_rectangle_end:
260
+ # Convert canvas coordinates to image coordinates
261
+ x0, y0 = self.canvas_to_image(*self.zoom_rectangle_start)
262
+ x1, y1 = self.canvas_to_image(*self.zoom_rectangle_end)
263
+ x0, x1 = min(x0, x1), max(x0, x1)
264
+ y0, y1 = min(y0, y1), max(y0, y1)
265
+ self.zoom_x0 = x0
266
+ self.zoom_y0 = y0
267
+ self.zoom_x1 = x1
268
+ self.zoom_y1 = y1
269
+ # Normalize the entire image
270
+ lower_quantile = float(self.lower_quantile.get()) if self.lower_quantile.get() else 1.0
271
+ upper_quantile = float(self.upper_quantile.get()) if self.upper_quantile.get() else 99.9
272
+ normalized_image = self.normalize_image(self.image, lower_quantile, upper_quantile)
273
+ # Extract the zoomed portion of the normalized image and mask
274
+ self.zoom_image = normalized_image[y0:y1, x0:x1]
275
+ self.zoom_image_orig = self.image[y0:y1, x0:x1]
276
+ self.zoom_mask = self.mask[y0:y1, x0:x1]
277
+ original_mask_area = self.mask.shape[0] * self.mask.shape[1]
278
+ zoom_mask_area = self.zoom_mask.shape[0] * self.zoom_mask.shape[1]
279
+ if original_mask_area > 0:
280
+ self.zoom_scale = original_mask_area/zoom_mask_area
281
+ # Resize the zoomed image and mask to fit the canvas
282
+ canvas_height = self.canvas.winfo_height()
283
+ canvas_width = self.canvas.winfo_width()
284
+
285
+ if self.zoom_image.size > 0 and canvas_height > 0 and canvas_width > 0:
286
+ self.zoom_image = resize(self.zoom_image, (canvas_height, canvas_width), preserve_range=True).astype(self.zoom_image.dtype)
287
+ self.zoom_image_orig = resize(self.zoom_image_orig, (canvas_height, canvas_width), preserve_range=True).astype(self.zoom_image_orig.dtype)
288
+ #self.zoom_mask = resize(self.zoom_mask, (canvas_height, canvas_width), preserve_range=True).astype(np.uint8)
289
+ self.zoom_mask = resize(self.zoom_mask, (canvas_height, canvas_width), order=0, preserve_range=True).astype(np.uint8)
290
+ combined = self.overlay_mask_on_image(self.zoom_image, self.zoom_mask)
291
+ self.tk_image = ImageTk.PhotoImage(image=Image.fromarray(combined))
292
+ self.canvas.create_image(0, 0, anchor='nw', image=self.tk_image)
293
+
294
+ def overlay_mask_on_image(self, image, mask, alpha=0.5):
295
+ if len(image.shape) == 2:
296
+ image = np.stack((image,) * 3, axis=-1)
297
+ mask = mask.astype(np.int32)
298
+ max_label = np.max(mask)
299
+ np.random.seed(0)
300
+ colors = np.random.randint(0, 255, size=(max_label + 1, 3), dtype=np.uint8)
301
+ colors[0] = [0, 0, 0] # background color
302
+ colored_mask = colors[mask]
303
+ image_8bit = (image / 256).astype(np.uint8)
304
+ # Blend the mask and the image with transparency
305
+ combined_image = np.where(mask[..., None] > 0,
306
+ np.clip(image_8bit * (1 - alpha) + colored_mask * alpha, 0, 255),
307
+ image_8bit)
308
+ # Convert the final image back to uint8
309
+ combined_image = combined_image.astype(np.uint8)
310
+ return combined_image
311
+
312
+ ####################################################################################################
313
+ # Navigation functions#
314
+ ####################################################################################################
315
+
316
+ def previous_image(self):
317
+ if self.current_image_index > 0:
318
+ self.current_image_index -= 1
319
+ self.initialize_flags()
320
+ self.image, self.mask = self.load_image_and_mask(self.current_image_index)
321
+ self.original_size = self.image.shape
322
+ self.image, self.mask = self.resize_arrays(self.image, self.mask)
323
+ self.display_image()
324
+
325
+ def next_image(self):
326
+ if self.current_image_index < len(self.image_filenames) - 1:
327
+ self.current_image_index += 1
328
+ self.initialize_flags()
329
+ self.image, self.mask = self.load_image_and_mask(self.current_image_index)
330
+ self.original_size = self.image.shape
331
+ self.image, self.mask = self.resize_arrays(self.image, self.mask)
332
+ self.display_image()
333
+
334
+ def save_mask(self):
335
+ if self.current_image_index < len(self.image_filenames):
336
+ original_size = self.original_size
337
+ if self.mask.shape != original_size:
338
+ resized_mask = resize(self.mask, original_size, order=0, preserve_range=True).astype(np.uint16)
339
+ else:
340
+ resized_mask = self.mask
341
+ resized_mask, _ = label(resized_mask > 0)
342
+ save_folder = os.path.join(self.folder_path, 'masks')
343
+ if not os.path.exists(save_folder):
344
+ os.makedirs(save_folder)
345
+ image_filename = os.path.splitext(self.image_filenames[self.current_image_index])[0] + '.tif'
346
+ save_path = os.path.join(save_folder, image_filename)
347
+
348
+ print(f"Saving mask to: {save_path}") # Debug print
349
+ imageio.imwrite(save_path, resized_mask)
350
+
351
+ ####################################################################################################
352
+ # Zoom Functions #
353
+ ####################################################################################################
354
+ def set_zoom_rectangle_start(self, event):
355
+ if self.zoom_active:
356
+ self.zoom_rectangle_start = (event.x, event.y)
357
+
358
+ def set_zoom_rectangle_end(self, event):
359
+ if self.zoom_active:
360
+ self.zoom_rectangle_end = (event.x, event.y)
361
+ if self.zoom_rectangle_id is not None:
362
+ self.canvas.delete(self.zoom_rectangle_id)
363
+ self.zoom_rectangle_id = None
364
+ self.display_zoomed_image()
365
+ self.canvas.unbind("<Motion>")
366
+ self.canvas.unbind("<Button-1>")
367
+ self.canvas.unbind("<Button-3>")
368
+ self.canvas.bind("<Motion>", self.update_mouse_info)
369
+
370
+ def update_zoom_box(self, event):
371
+ if self.zoom_active and self.zoom_rectangle_start is not None:
372
+ if self.zoom_rectangle_id is not None:
373
+ self.canvas.delete(self.zoom_rectangle_id)
374
+ # Assuming event.x and event.y are already in image coordinates
375
+ self.zoom_rectangle_end = (event.x, event.y)
376
+ x0, y0 = self.zoom_rectangle_start
377
+ x1, y1 = self.zoom_rectangle_end
378
+ self.zoom_rectangle_id = self.canvas.create_rectangle(x0, y0, x1, y1, outline="red", width=2)
379
+
380
+ ####################################################################################################
381
+ # Mode activation#
382
+ ####################################################################################################
383
+
384
+ def toggle_zoom_mode(self):
385
+ if not self.zoom_active:
386
+ self.brush_btn.config(text="Brush")
387
+ self.canvas.unbind("<B1-Motion>")
388
+ self.canvas.unbind("<B3-Motion>")
389
+ self.canvas.unbind("<ButtonRelease-1>")
390
+ self.canvas.unbind("<ButtonRelease-3>")
391
+ self.zoom_active = True
392
+ self.drawing = False
393
+ self.magic_wand_active = False
394
+ self.erase_active = False
395
+ self.brush_active = False
396
+ self.draw_btn.config(text="Draw")
397
+ self.erase_btn.config(text="Erase")
398
+ self.magic_wand_btn.config(text="Magic Wand")
399
+ self.zoom_btn.config(text="Zoom ON")
400
+ self.canvas.unbind("<Button-1>")
401
+ self.canvas.unbind("<Button-3>")
402
+ self.canvas.unbind("<Motion>")
403
+ self.canvas.bind("<Button-1>", self.set_zoom_rectangle_start)
404
+ self.canvas.bind("<Button-3>", self.set_zoom_rectangle_end)
405
+ self.canvas.bind("<Motion>", self.update_zoom_box)
406
+ else:
407
+ self.zoom_active = False
408
+ self.zoom_btn.config(text="Zoom")
409
+ self.canvas.unbind("<Button-1>")
410
+ self.canvas.unbind("<Button-3>")
411
+ self.canvas.unbind("<Motion>")
412
+ self.zoom_rectangle_start = self.zoom_rectangle_end = None
413
+ self.zoom_rectangle_id = None
414
+ self.display_image()
415
+ self.canvas.bind("<Motion>", self.update_mouse_info)
416
+ self.zoom_rectangle_start = None
417
+ self.zoom_rectangle_end = None
418
+ self.zoom_rectangle_id = None
419
+ self.zoom_x0 = None
420
+ self.zoom_y0 = None
421
+ self.zoom_x1 = None
422
+ self.zoom_y1 = None
423
+ self.zoom_mask = None
424
+ self.zoom_image = None
425
+ self.zoom_image_orig = None
426
+
427
+ def toggle_brush_mode(self):
428
+ self.brush_active = not self.brush_active
429
+ if self.brush_active:
430
+ self.drawing = False
431
+ self.magic_wand_active = False
432
+ self.erase_active = False
433
+ self.brush_btn.config(text="Brush ON")
434
+ self.draw_btn.config(text="Draw")
435
+ self.erase_btn.config(text="Erase")
436
+ self.magic_wand_btn.config(text="Magic Wand")
437
+ self.canvas.unbind("<Button-1>")
438
+ self.canvas.unbind("<Button-3>")
439
+ self.canvas.unbind("<Motion>")
440
+ self.canvas.bind("<B1-Motion>", self.apply_brush) # Left click and drag to apply brush
441
+ self.canvas.bind("<B3-Motion>", self.erase_brush) # Right click and drag to erase with brush
442
+ self.canvas.bind("<ButtonRelease-1>", self.apply_brush_release) # Left button release
443
+ self.canvas.bind("<ButtonRelease-3>", self.erase_brush_release) # Right button release
444
+ else:
445
+ self.brush_active = False
446
+ self.brush_btn.config(text="Brush")
447
+ self.canvas.unbind("<B1-Motion>")
448
+ self.canvas.unbind("<B3-Motion>")
449
+ self.canvas.unbind("<ButtonRelease-1>")
450
+ self.canvas.unbind("<ButtonRelease-3>")
451
+
452
+ def toggle_draw_mode(self):
453
+ self.drawing = not self.drawing
454
+ if self.drawing:
455
+ self.brush_btn.config(text="Brush")
456
+ self.canvas.unbind("<B1-Motion>")
457
+ self.canvas.unbind("<B3-Motion>")
458
+ self.canvas.unbind("<ButtonRelease-1>")
459
+ self.canvas.unbind("<ButtonRelease-3>")
460
+ self.magic_wand_active = False
461
+ self.erase_active = False
462
+ self.brush_active = False
463
+ self.draw_btn.config(text="Draw ON")
464
+ self.magic_wand_btn.config(text="Magic Wand")
465
+ self.erase_btn.config(text="Erase")
466
+ self.draw_coordinates = []
467
+ self.canvas.unbind("<Button-1>")
468
+ self.canvas.unbind("<Motion>")
469
+ self.canvas.bind("<B1-Motion>", self.draw)
470
+ self.canvas.bind("<ButtonRelease-1>", self.finish_drawing)
471
+ else:
472
+ self.drawing = False
473
+ self.draw_btn.config(text="Draw")
474
+ self.canvas.unbind("<B1-Motion>")
475
+ self.canvas.unbind("<ButtonRelease-1>")
476
+
477
+ def toggle_magic_wand_mode(self):
478
+ self.magic_wand_active = not self.magic_wand_active
479
+ if self.magic_wand_active:
480
+ self.brush_btn.config(text="Brush")
481
+ self.canvas.unbind("<B1-Motion>")
482
+ self.canvas.unbind("<B3-Motion>")
483
+ self.canvas.unbind("<ButtonRelease-1>")
484
+ self.canvas.unbind("<ButtonRelease-3>")
485
+ self.drawing = False
486
+ self.erase_active = False
487
+ self.brush_active = False
488
+ self.draw_btn.config(text="Draw")
489
+ self.erase_btn.config(text="Erase")
490
+ self.magic_wand_btn.config(text="Magic Wand ON")
491
+ self.canvas.bind("<Button-1>", self.use_magic_wand)
492
+ self.canvas.bind("<Button-3>", self.use_magic_wand)
493
+ else:
494
+ self.magic_wand_btn.config(text="Magic Wand")
495
+ self.canvas.unbind("<Button-1>")
496
+ self.canvas.unbind("<Button-3>")
497
+
498
+ def toggle_erase_mode(self):
499
+ self.erase_active = not self.erase_active
500
+ if self.erase_active:
501
+ self.brush_btn.config(text="Brush")
502
+ self.canvas.unbind("<B1-Motion>")
503
+ self.canvas.unbind("<B3-Motion>")
504
+ self.canvas.unbind("<ButtonRelease-1>")
505
+ self.canvas.unbind("<ButtonRelease-3>")
506
+ self.erase_btn.config(text="Erase ON")
507
+ self.canvas.bind("<Button-1>", self.erase_object)
508
+ self.drawing = False
509
+ self.magic_wand_active = False
510
+ self.brush_active = False
511
+ self.draw_btn.config(text="Draw")
512
+ self.magic_wand_btn.config(text="Magic Wand")
513
+ else:
514
+ self.erase_active = False
515
+ self.erase_btn.config(text="Erase")
516
+ self.canvas.unbind("<Button-1>")
517
+
518
+ ####################################################################################################
519
+ # Mode functions#
520
+ ####################################################################################################
521
+
522
+ def apply_brush_release(self, event):
523
+ if hasattr(self, 'brush_path'):
524
+ for x, y, brush_size in self.brush_path:
525
+ img_x, img_y = (x, y) if self.zoom_active else self.canvas_to_image(x, y)
526
+ x0 = max(img_x - brush_size // 2, 0)
527
+ y0 = max(img_y - brush_size // 2, 0)
528
+ x1 = min(img_x + brush_size // 2, self.zoom_mask.shape[1] if self.zoom_active else self.mask.shape[1])
529
+ y1 = min(img_y + brush_size // 2, self.zoom_mask.shape[0] if self.zoom_active else self.mask.shape[0])
530
+ if self.zoom_active:
531
+ self.zoom_mask[y0:y1, x0:x1] = 255
532
+ self.update_original_mask_from_zoom()
533
+ else:
534
+ self.mask[y0:y1, x0:x1] = 255
535
+ del self.brush_path
536
+ self.canvas.delete("temp_line")
537
+ self.update_display()
538
+
539
+ def erase_brush_release(self, event):
540
+ if hasattr(self, 'erase_path'):
541
+ for x, y, brush_size in self.erase_path:
542
+ img_x, img_y = (x, y) if self.zoom_active else self.canvas_to_image(x, y)
543
+ x0 = max(img_x - brush_size // 2, 0)
544
+ y0 = max(img_y - brush_size // 2, 0)
545
+ x1 = min(img_x + brush_size // 2, self.zoom_mask.shape[1] if self.zoom_active else self.mask.shape[1])
546
+ y1 = min(img_y + brush_size // 2, self.zoom_mask.shape[0] if self.zoom_active else self.mask.shape[0])
547
+ if self.zoom_active:
548
+ self.zoom_mask[y0:y1, x0:x1] = 0
549
+ self.update_original_mask_from_zoom()
550
+ else:
551
+ self.mask[y0:y1, x0:x1] = 0
552
+ del self.erase_path
553
+ self.canvas.delete("temp_line")
554
+ self.update_display()
555
+
556
+ def apply_brush(self, event):
557
+ brush_size = int(self.brush_size_entry.get())
558
+ x, y = event.x, event.y
559
+ if not hasattr(self, 'brush_path'):
560
+ self.brush_path = []
561
+ self.last_brush_coord = (x, y)
562
+ if self.last_brush_coord:
563
+ last_x, last_y = self.last_brush_coord
564
+ rr, cc = line(last_y, last_x, y, x)
565
+ for ry, rx in zip(rr, cc):
566
+ self.brush_path.append((rx, ry, brush_size))
567
+
568
+ self.canvas.create_line(self.last_brush_coord[0], self.last_brush_coord[1], x, y, width=brush_size, fill="blue", tag="temp_line")
569
+ self.last_brush_coord = (x, y)
570
+
571
+ def erase_brush(self, event):
572
+ brush_size = int(self.brush_size_entry.get())
573
+ x, y = event.x, event.y
574
+ if not hasattr(self, 'erase_path'):
575
+ self.erase_path = []
576
+ self.last_erase_coord = (x, y)
577
+ if self.last_erase_coord:
578
+ last_x, last_y = self.last_erase_coord
579
+ rr, cc = line(last_y, last_x, y, x)
580
+ for ry, rx in zip(rr, cc):
581
+ self.erase_path.append((rx, ry, brush_size))
582
+
583
+ self.canvas.create_line(self.last_erase_coord[0], self.last_erase_coord[1], x, y, width=brush_size, fill="white", tag="temp_line")
584
+ self.last_erase_coord = (x, y)
585
+
586
+ def erase_object(self, event):
587
+ x, y = event.x, event.y
588
+ if self.zoom_active:
589
+ canvas_x, canvas_y = x, y
590
+ zoomed_x = int(canvas_x * (self.zoom_image.shape[1] / self.canvas_width))
591
+ zoomed_y = int(canvas_y * (self.zoom_image.shape[0] / self.canvas_height))
592
+ orig_x = int(zoomed_x * ((self.zoom_x1 - self.zoom_x0) / self.canvas_width) + self.zoom_x0)
593
+ orig_y = int(zoomed_y * ((self.zoom_y1 - self.zoom_y0) / self.canvas_height) + self.zoom_y0)
594
+ if orig_x < 0 or orig_y < 0 or orig_x >= self.image.shape[1] or orig_y >= self.image.shape[0]:
595
+ print("Point is out of bounds in the original image.")
596
+ return
597
+ else:
598
+ orig_x, orig_y = x, y
599
+ label_to_remove = self.mask[orig_y, orig_x]
600
+ if label_to_remove > 0:
601
+ self.mask[self.mask == label_to_remove] = 0
602
+ self.update_display()
603
+
604
+ def use_magic_wand(self, event):
605
+ x, y = event.x, event.y
606
+ tolerance = int(self.magic_wand_tolerance.get())
607
+ maximum = int(self.max_pixels_entry.get())
608
+ action = 'add' if event.num == 1 else 'erase'
609
+ if self.zoom_active:
610
+ self.magic_wand_zoomed((x, y), tolerance, action)
611
+ else:
612
+ self.magic_wand_normal((x, y), tolerance, action)
613
+
614
+ def apply_magic_wand(self, image, mask, seed_point, tolerance, maximum, action='add'):
615
+ x, y = seed_point
616
+ initial_value = image[y, x].astype(np.float32)
617
+ visited = np.zeros_like(image, dtype=bool)
618
+ queue = deque([(x, y)])
619
+ added_pixels = 0
620
+
621
+ while queue and added_pixels < maximum:
622
+ cx, cy = queue.popleft()
623
+ if visited[cy, cx]:
624
+ continue
625
+ visited[cy, cx] = True
626
+ current_value = image[cy, cx].astype(np.float32)
627
+
628
+ if np.linalg.norm(abs(current_value - initial_value)) <= tolerance:
629
+ if mask[cy, cx] == 0:
630
+ added_pixels += 1
631
+ mask[cy, cx] = 255 if action == 'add' else 0
632
+
633
+ if added_pixels >= maximum:
634
+ break
635
+
636
+ for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
637
+ nx, ny = cx + dx, cy + dy
638
+ if 0 <= nx < image.shape[1] and 0 <= ny < image.shape[0] and not visited[ny, nx]:
639
+ queue.append((nx, ny))
640
+ return mask
641
+
642
+ def magic_wand_normal(self, seed_point, tolerance, action):
643
+ try:
644
+ maximum = int(self.max_pixels_entry.get())
645
+ except ValueError:
646
+ print("Invalid maximum value; using default of 1000")
647
+ maximum = 1000
648
+ self.mask = self.apply_magic_wand(self.image, self.mask, seed_point, tolerance, maximum, action)
649
+ self.display_image()
650
+
651
+ def magic_wand_zoomed(self, seed_point, tolerance, action):
652
+ if self.zoom_image_orig is None or self.zoom_mask is None:
653
+ print("Zoomed image or mask not initialized")
654
+ return
655
+ try:
656
+ maximum = int(self.max_pixels_entry.get())
657
+ maximum = maximum * self.zoom_scale
658
+ except ValueError:
659
+ print("Invalid maximum value; using default of 1000")
660
+ maximum = 1000
661
+
662
+ canvas_x, canvas_y = seed_point
663
+ if canvas_x < 0 or canvas_y < 0 or canvas_x >= self.zoom_image_orig.shape[1] or canvas_y >= self.zoom_image_orig.shape[0]:
664
+ print("Selected point is out of bounds in the zoomed image.")
665
+ return
666
+
667
+ self.zoom_mask = self.apply_magic_wand(self.zoom_image_orig, self.zoom_mask, (canvas_x, canvas_y), tolerance, maximum, action)
668
+ y0, y1, x0, x1 = self.zoom_y0, self.zoom_y1, self.zoom_x0, self.zoom_x1
669
+ zoomed_mask_resized_back = resize(self.zoom_mask, (y1 - y0, x1 - x0), order=0, preserve_range=True).astype(np.uint8)
670
+ if action == 'erase':
671
+ self.mask[y0:y1, x0:x1] = np.where(zoomed_mask_resized_back == 0, 0, self.mask[y0:y1, x0:x1])
672
+ else:
673
+ self.mask[y0:y1, x0:x1] = np.where(zoomed_mask_resized_back > 0, zoomed_mask_resized_back, self.mask[y0:y1, x0:x1])
674
+ self.update_display()
675
+
676
+ def draw(self, event):
677
+ if self.drawing:
678
+ x, y = event.x, event.y
679
+ if self.draw_coordinates:
680
+ last_x, last_y = self.draw_coordinates[-1]
681
+ self.current_line = self.canvas.create_line(last_x, last_y, x, y, fill="yellow", width=3)
682
+ self.draw_coordinates.append((x, y))
683
+
684
+ def draw_on_zoomed_mask(self, draw_coordinates):
685
+ canvas_height = self.canvas.winfo_height()
686
+ canvas_width = self.canvas.winfo_width()
687
+ zoomed_mask = np.zeros((canvas_height, canvas_width), dtype=np.uint8)
688
+ rr, cc = polygon(np.array(draw_coordinates)[:, 1], np.array(draw_coordinates)[:, 0], shape=zoomed_mask.shape)
689
+ zoomed_mask[rr, cc] = 255
690
+ return zoomed_mask
691
+
692
+ def finish_drawing(self, event):
693
+ if len(self.draw_coordinates) > 2:
694
+ self.draw_coordinates.append(self.draw_coordinates[0])
695
+ if self.zoom_active:
696
+ x0, x1, y0, y1 = self.zoom_x0, self.zoom_x1, self.zoom_y0, self.zoom_y1
697
+ zoomed_mask = self.draw_on_zoomed_mask(self.draw_coordinates)
698
+ self.update_original_mask(zoomed_mask, x0, x1, y0, y1)
699
+ else:
700
+ rr, cc = polygon(np.array(self.draw_coordinates)[:, 1], np.array(self.draw_coordinates)[:, 0], shape=self.mask.shape)
701
+ self.mask[rr, cc] = np.maximum(self.mask[rr, cc], 255)
702
+ self.mask = self.mask.copy()
703
+ self.canvas.delete(self.current_line)
704
+ self.draw_coordinates.clear()
705
+ self.update_display()
706
+
707
+ def finish_drawing_if_active(self, event):
708
+ if self.drawing and len(self.draw_coordinates) > 2:
709
+ self.finish_drawing(event)
710
+
711
+ ####################################################################################################
712
+ # Single function butons#
713
+ ####################################################################################################
714
+
715
+ def apply_normalization(self):
716
+ self.lower_quantile.set(self.lower_entry.get())
717
+ self.upper_quantile.set(self.upper_entry.get())
718
+ self.update_display()
719
+
720
+ def fill_objects(self):
721
+ binary_mask = self.mask > 0
722
+ filled_mask = binary_fill_holes(binary_mask)
723
+ self.mask = filled_mask.astype(np.uint8) * 255
724
+ labeled_mask, _ = label(filled_mask)
725
+ self.mask = labeled_mask
726
+ self.update_display()
727
+
728
+ def relabel_objects(self):
729
+ mask = self.mask
730
+ labeled_mask, num_labels = label(mask > 0)
731
+ self.mask = labeled_mask
732
+ self.update_display()
733
+
734
+ def clear_objects(self):
735
+ self.mask = np.zeros_like(self.mask)
736
+ self.update_display()
737
+
738
+ def invert_mask(self):
739
+ self.mask = np.where(self.mask > 0, 0, 1)
740
+ self.relabel_objects()
741
+ self.update_display()
742
+
743
+ def remove_small_objects(self):
744
+ try:
745
+ min_area = int(self.min_area_entry.get())
746
+ except ValueError:
747
+ print("Invalid minimum area value; using default of 100")
748
+ min_area = 100
749
+
750
+ labeled_mask, num_labels = label(self.mask > 0)
751
+ for i in range(1, num_labels + 1): # Skip background
752
+ if np.sum(labeled_mask == i) < min_area:
753
+ self.mask[labeled_mask == i] = 0 # Remove small objects
754
+ self.update_display()
755
+
756
+ @log_function_call
757
+ def initiate_mask_app_root(width, height):
758
+ theme = 'breeze'
759
+ root = ThemedTk(theme=theme)
760
+ style = ttk.Style(root)
761
+ set_dark_style(style)
762
+ set_default_font(root, font_name="Arial", size=10)
763
+ root.geometry(f"{width}x{height}")
764
+ root.title("Mask App")
765
+
766
+ container = tk.PanedWindow(root, orient=tk.HORIZONTAL)
767
+ container.pack(fill=tk.BOTH, expand=True)
768
+
769
+ scrollable_frame = ScrollableFrame(container, bg='#333333')
770
+ container.add(scrollable_frame, stretch="always")
771
+
772
+ # Setup input fields
773
+ vars_dict = {
774
+ 'folder_path': ttk.Entry(scrollable_frame.scrollable_frame),
775
+ 'scale_factor': ttk.Entry(scrollable_frame.scrollable_frame),
776
+ 'width': ttk.Entry(scrollable_frame.scrollable_frame),
777
+ 'height': ttk.Entry(scrollable_frame.scrollable_frame)
778
+ }
779
+
780
+ # Arrange input fields and labels
781
+ row = 0
782
+ for name, entry in vars_dict.items():
783
+ ttk.Label(scrollable_frame.scrollable_frame, text=f"{name.replace('_', ' ').capitalize()}:").grid(row=row, column=0)
784
+ entry.grid(row=row, column=1)
785
+ row += 1
786
+
787
+ # Function to be called when "Run" button is clicked
788
+ def run_app():
789
+ folder_path = vars_dict['folder_path'].get()
790
+ scale_factor = float(vars_dict['scale_factor'].get())
791
+ width = int(vars_dict['width'].get())
792
+ height = int(vars_dict['height'].get())
793
+
794
+ # Destroy input fields and the "Run" button
795
+ root.destroy()
796
+
797
+ # Since the original root window is destroyed, create a new root window for the application
798
+ new_root = tk.Tk()
799
+ new_root.geometry(f"{width}x{height}")
800
+ new_root.title("Mask Application")
801
+
802
+ # Start the modify_masks application in the new root window
803
+ app_instance = modify_masks(new_root, folder_path, scale_factor, width, height)
804
+ new_root.mainloop()
805
+
806
+ create_dark_mode(root, style, console_output=None)
807
+
808
+ run_button = ttk.Button(scrollable_frame.scrollable_frame, text="Run", command=run_app)
809
+ run_button.grid(row=row, column=0, columnspan=2, pady=10)
810
+
811
+ return root
812
+
813
+ def gui_make_masks():
814
+ root = initiate_mask_app_root(400, 200)
815
+ root.mainloop()
816
+
817
+ if __name__ == "__main__":
818
+ gui_make_masks()