spacr 0.1.63__py3-none-any.whl → 0.1.75__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/gui_elements.py CHANGED
@@ -1,6 +1,19 @@
1
+ import os, threading, time, sqlite3
1
2
  import tkinter as tk
2
3
  from tkinter import ttk
3
4
  import tkinter.font as tkFont
5
+ from queue import Queue
6
+ from tkinter import Label
7
+ import numpy as np
8
+ from PIL import Image, ImageOps, ImageTk
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from skimage.exposure import rescale_intensity
11
+ from IPython.display import display, HTML
12
+ import imageio.v2 as imageio
13
+ from collections import deque
14
+ from skimage.draw import polygon, line
15
+ from skimage.transform import resize
16
+ from scipy.ndimage import binary_fill_holes, label
4
17
 
5
18
  class spacrDropdownMenu(tk.OptionMenu):
6
19
  def __init__(self, parent, variable, options, command=None, **kwargs):
@@ -261,19 +274,1173 @@ class spacrToolTip:
261
274
  self.tooltip_window.destroy()
262
275
  self.tooltip_window = None
263
276
 
277
+ class modify_masks:
278
+
279
+ def __init__(self, root, folder_path, scale_factor):
280
+ self.root = root
281
+ self.folder_path = folder_path
282
+ self.scale_factor = scale_factor
283
+ self.image_filenames = sorted([f for f in os.listdir(folder_path) if f.endswith(('.png', '.jpg', '.jpeg', '.tif', '.tiff'))])
284
+ self.masks_folder = os.path.join(folder_path, 'masks')
285
+ self.current_image_index = 0
286
+ self.initialize_flags()
287
+ self.canvas_width = self.root.winfo_screenheight() -100
288
+ self.canvas_height = self.root.winfo_screenheight() -100
289
+ self.root.configure(bg='black')
290
+ self.setup_navigation_toolbar()
291
+ self.setup_mode_toolbar()
292
+ self.setup_function_toolbar()
293
+ self.setup_zoom_toolbar()
294
+ self.setup_canvas()
295
+ self.load_first_image()
296
+
297
+ ####################################################################################################
298
+ # Helper functions#
299
+ ####################################################################################################
300
+
301
+ def update_display(self):
302
+ if self.zoom_active:
303
+ self.display_zoomed_image()
304
+ else:
305
+ self.display_image()
306
+
307
+ def update_original_mask_from_zoom(self):
308
+ y0, y1, x0, x1 = self.zoom_y0, self.zoom_y1, self.zoom_x0, self.zoom_x1
309
+ zoomed_mask_resized = resize(self.zoom_mask, (y1 - y0, x1 - x0), order=0, preserve_range=True).astype(np.uint8)
310
+ self.mask[y0:y1, x0:x1] = zoomed_mask_resized
311
+
312
+ def update_original_mask(self, zoomed_mask, x0, x1, y0, y1):
313
+ actual_mask_region = self.mask[y0:y1, x0:x1]
314
+ target_shape = actual_mask_region.shape
315
+ resized_mask = resize(zoomed_mask, target_shape, order=0, preserve_range=True).astype(np.uint8)
316
+ if resized_mask.shape != actual_mask_region.shape:
317
+ raise ValueError(f"Shape mismatch: resized_mask {resized_mask.shape}, actual_mask_region {actual_mask_region.shape}")
318
+ self.mask[y0:y1, x0:x1] = np.maximum(actual_mask_region, resized_mask)
319
+ self.mask = self.mask.copy()
320
+ self.mask[y0:y1, x0:x1] = np.maximum(self.mask[y0:y1, x0:x1], resized_mask)
321
+ self.mask = self.mask.copy()
322
+
323
+ def get_scaling_factors(self, img_width, img_height, canvas_width, canvas_height):
324
+ x_scale = img_width / canvas_width
325
+ y_scale = img_height / canvas_height
326
+ return x_scale, y_scale
327
+
328
+ def canvas_to_image(self, x_canvas, y_canvas):
329
+ x_scale, y_scale = self.get_scaling_factors(
330
+ self.image.shape[1], self.image.shape[0],
331
+ self.canvas_width, self.canvas_height
332
+ )
333
+ x_image = int(x_canvas * x_scale)
334
+ y_image = int(y_canvas * y_scale)
335
+ return x_image, y_image
336
+
337
+ def apply_zoom_on_enter(self, event):
338
+ if self.zoom_active and self.zoom_rectangle_start is not None:
339
+ self.set_zoom_rectangle_end(event)
340
+
341
+ def normalize_image(self, image, lower_quantile, upper_quantile):
342
+ lower_bound = np.percentile(image, lower_quantile)
343
+ upper_bound = np.percentile(image, upper_quantile)
344
+ normalized = np.clip(image, lower_bound, upper_bound)
345
+ normalized = (normalized - lower_bound) / (upper_bound - lower_bound)
346
+ max_value = np.iinfo(image.dtype).max
347
+ normalized = (normalized * max_value).astype(image.dtype)
348
+ return normalized
349
+
350
+ def resize_arrays(self, img, mask):
351
+ original_dtype = img.dtype
352
+ scaled_height = int(img.shape[0] * self.scale_factor)
353
+ scaled_width = int(img.shape[1] * self.scale_factor)
354
+ scaled_img = resize(img, (scaled_height, scaled_width), anti_aliasing=True, preserve_range=True)
355
+ scaled_mask = resize(mask, (scaled_height, scaled_width), order=0, anti_aliasing=False, preserve_range=True)
356
+ stretched_img = resize(scaled_img, (self.canvas_height, self.canvas_width), anti_aliasing=True, preserve_range=True)
357
+ stretched_mask = resize(scaled_mask, (self.canvas_height, self.canvas_width), order=0, anti_aliasing=False, preserve_range=True)
358
+ return stretched_img.astype(original_dtype), stretched_mask.astype(original_dtype)
359
+
360
+ ####################################################################################################
361
+ #Initiate canvas elements#
362
+ ####################################################################################################
363
+
364
+ def load_first_image(self):
365
+ self.image, self.mask = self.load_image_and_mask(self.current_image_index)
366
+ self.original_size = self.image.shape
367
+ self.image, self.mask = self.resize_arrays(self.image, self.mask)
368
+ self.display_image()
369
+
370
+ def setup_canvas(self):
371
+ self.canvas = tk.Canvas(self.root, width=self.canvas_width, height=self.canvas_height, bg='black')
372
+ self.canvas.pack()
373
+ self.canvas.bind("<Motion>", self.update_mouse_info)
374
+
375
+ def initialize_flags(self):
376
+ self.zoom_rectangle_start = None
377
+ self.zoom_rectangle_end = None
378
+ self.zoom_rectangle_id = None
379
+ self.zoom_x0 = None
380
+ self.zoom_y0 = None
381
+ self.zoom_x1 = None
382
+ self.zoom_y1 = None
383
+ self.zoom_mask = None
384
+ self.zoom_image = None
385
+ self.zoom_image_orig = None
386
+ self.zoom_scale = 1
387
+ self.drawing = False
388
+ self.zoom_active = False
389
+ self.magic_wand_active = False
390
+ self.brush_active = False
391
+ self.dividing_line_active = False
392
+ self.dividing_line_coords = []
393
+ self.current_dividing_line = None
394
+ self.lower_quantile = tk.StringVar(value="1.0")
395
+ self.upper_quantile = tk.StringVar(value="99.9")
396
+ self.magic_wand_tolerance = tk.StringVar(value="1000")
397
+
398
+ def update_mouse_info(self, event):
399
+ x, y = event.x, event.y
400
+ intensity = "N/A"
401
+ mask_value = "N/A"
402
+ pixel_count = "N/A"
403
+ if self.zoom_active:
404
+ if 0 <= x < self.canvas_width and 0 <= y < self.canvas_height:
405
+ intensity = self.zoom_image_orig[y, x] if self.zoom_image_orig is not None else "N/A"
406
+ mask_value = self.zoom_mask[y, x] if self.zoom_mask is not None else "N/A"
407
+ else:
408
+ if 0 <= x < self.image.shape[1] and 0 <= y < self.image.shape[0]:
409
+ intensity = self.image[y, x]
410
+ mask_value = self.mask[y, x]
411
+ if mask_value != "N/A" and mask_value != 0:
412
+ pixel_count = np.sum(self.mask == mask_value)
413
+ self.intensity_label.config(text=f"Intensity: {intensity}")
414
+ self.mask_value_label.config(text=f"Mask: {mask_value}, Area: {pixel_count}")
415
+ self.mask_value_label.config(text=f"Mask: {mask_value}")
416
+ if mask_value != "N/A" and mask_value != 0:
417
+ self.pixel_count_label.config(text=f"Area: {pixel_count}")
418
+ else:
419
+ self.pixel_count_label.config(text="Area: N/A")
420
+
421
+ def setup_navigation_toolbar(self):
422
+ navigation_toolbar = tk.Frame(self.root, bg='black')
423
+ navigation_toolbar.pack(side='top', fill='x')
424
+ prev_btn = tk.Button(navigation_toolbar, text="Previous", command=self.previous_image, bg='black', fg='white')
425
+ prev_btn.pack(side='left')
426
+ next_btn = tk.Button(navigation_toolbar, text="Next", command=self.next_image, bg='black', fg='white')
427
+ next_btn.pack(side='left')
428
+ save_btn = tk.Button(navigation_toolbar, text="Save", command=self.save_mask, bg='black', fg='white')
429
+ save_btn.pack(side='left')
430
+ self.intensity_label = tk.Label(navigation_toolbar, text="Image: N/A", bg='black', fg='white')
431
+ self.intensity_label.pack(side='right')
432
+ self.mask_value_label = tk.Label(navigation_toolbar, text="Mask: N/A", bg='black', fg='white')
433
+ self.mask_value_label.pack(side='right')
434
+ self.pixel_count_label = tk.Label(navigation_toolbar, text="Area: N/A", bg='black', fg='white')
435
+ self.pixel_count_label.pack(side='right')
436
+
437
+ def setup_mode_toolbar(self):
438
+ self.mode_toolbar = tk.Frame(self.root, bg='black')
439
+ self.mode_toolbar.pack(side='top', fill='x')
440
+ self.draw_btn = tk.Button(self.mode_toolbar, text="Draw", command=self.toggle_draw_mode, bg='black', fg='white')
441
+ self.draw_btn.pack(side='left')
442
+ self.magic_wand_btn = tk.Button(self.mode_toolbar, text="Magic Wand", command=self.toggle_magic_wand_mode, bg='black', fg='white')
443
+ self.magic_wand_btn.pack(side='left')
444
+ tk.Label(self.mode_toolbar, text="Tolerance:", bg='black', fg='white').pack(side='left')
445
+ self.tolerance_entry = tk.Entry(self.mode_toolbar, textvariable=self.magic_wand_tolerance, bg='black', fg='white')
446
+ self.tolerance_entry.pack(side='left')
447
+ tk.Label(self.mode_toolbar, text="Max Pixels:", bg='black', fg='white').pack(side='left')
448
+ self.max_pixels_entry = tk.Entry(self.mode_toolbar, bg='black', fg='white')
449
+ self.max_pixels_entry.insert(0, "1000")
450
+ self.max_pixels_entry.pack(side='left')
451
+ self.erase_btn = tk.Button(self.mode_toolbar, text="Erase", command=self.toggle_erase_mode, bg='black', fg='white')
452
+ self.erase_btn.pack(side='left')
453
+ self.brush_btn = tk.Button(self.mode_toolbar, text="Brush", command=self.toggle_brush_mode, bg='black', fg='white')
454
+ self.brush_btn.pack(side='left')
455
+ self.brush_size_entry = tk.Entry(self.mode_toolbar, bg='black', fg='white')
456
+ self.brush_size_entry.insert(0, "10")
457
+ self.brush_size_entry.pack(side='left')
458
+ tk.Label(self.mode_toolbar, text="Brush Size:", bg='black', fg='white').pack(side='left')
459
+ self.dividing_line_btn = tk.Button(self.mode_toolbar, text="Dividing Line", command=self.toggle_dividing_line_mode, bg='black', fg='white')
460
+ self.dividing_line_btn.pack(side='left')
461
+
462
+ def setup_function_toolbar(self):
463
+ self.function_toolbar = tk.Frame(self.root, bg='black')
464
+ self.function_toolbar.pack(side='top', fill='x')
465
+ self.fill_btn = tk.Button(self.function_toolbar, text="Fill", command=self.fill_objects, bg='black', fg='white')
466
+ self.fill_btn.pack(side='left')
467
+ self.relabel_btn = tk.Button(self.function_toolbar, text="Relabel", command=self.relabel_objects, bg='black', fg='white')
468
+ self.relabel_btn.pack(side='left')
469
+ self.clear_btn = tk.Button(self.function_toolbar, text="Clear", command=self.clear_objects, bg='black', fg='white')
470
+ self.clear_btn.pack(side='left')
471
+ self.invert_btn = tk.Button(self.function_toolbar, text="Invert", command=self.invert_mask, bg='black', fg='white')
472
+ self.invert_btn.pack(side='left')
473
+ remove_small_btn = tk.Button(self.function_toolbar, text="Remove Small", command=self.remove_small_objects, bg='black', fg='white')
474
+ remove_small_btn.pack(side='left')
475
+ tk.Label(self.function_toolbar, text="Min Area:", bg='black', fg='white').pack(side='left')
476
+ self.min_area_entry = tk.Entry(self.function_toolbar, bg='black', fg='white')
477
+ self.min_area_entry.insert(0, "100") # Default minimum area
478
+ self.min_area_entry.pack(side='left')
479
+
480
+ def setup_zoom_toolbar(self):
481
+ self.zoom_toolbar = tk.Frame(self.root, bg='black')
482
+ self.zoom_toolbar.pack(side='top', fill='x')
483
+ self.zoom_btn = tk.Button(self.zoom_toolbar, text="Zoom", command=self.toggle_zoom_mode, bg='black', fg='white')
484
+ self.zoom_btn.pack(side='left')
485
+ self.normalize_btn = tk.Button(self.zoom_toolbar, text="Apply Normalization", command=self.apply_normalization, bg='black', fg='white')
486
+ self.normalize_btn.pack(side='left')
487
+ tk.Label(self.zoom_toolbar, text="Lower Percentile:", bg='black', fg='white').pack(side='left')
488
+ self.lower_entry = tk.Entry(self.zoom_toolbar, textvariable=self.lower_quantile, bg='black', fg='white')
489
+ self.lower_entry.pack(side='left')
490
+
491
+ tk.Label(self.zoom_toolbar, text="Upper Percentile:", bg='black', fg='white').pack(side='left')
492
+ self.upper_entry = tk.Entry(self.zoom_toolbar, textvariable=self.upper_quantile, bg='black', fg='white')
493
+ self.upper_entry.pack(side='left')
494
+
495
+
496
+ def load_image_and_mask(self, index):
497
+ image_path = os.path.join(self.folder_path, self.image_filenames[index])
498
+ image = imageio.imread(image_path)
499
+ mask_path = os.path.join(self.masks_folder, self.image_filenames[index])
500
+ if os.path.exists(mask_path):
501
+ print(f'loading mask:{mask_path} for image: {image_path}')
502
+ mask = imageio.imread(mask_path)
503
+ if mask.dtype != np.uint8:
504
+ mask = (mask / np.max(mask) * 255).astype(np.uint8)
505
+ else:
506
+ mask = np.zeros(image.shape[:2], dtype=np.uint8)
507
+ print(f'loaded new mask for image: {image_path}')
508
+ return image, mask
509
+
510
+ ####################################################################################################
511
+ # Image Display functions#
512
+ ####################################################################################################
513
+ def display_image(self):
514
+ if self.zoom_rectangle_id is not None:
515
+ self.canvas.delete(self.zoom_rectangle_id)
516
+ self.zoom_rectangle_id = None
517
+ lower_quantile = float(self.lower_quantile.get()) if self.lower_quantile.get() else 1.0
518
+ upper_quantile = float(self.upper_quantile.get()) if self.upper_quantile.get() else 99.9
519
+ normalized = self.normalize_image(self.image, lower_quantile, upper_quantile)
520
+ combined = self.overlay_mask_on_image(normalized, self.mask)
521
+ self.tk_image = ImageTk.PhotoImage(image=Image.fromarray(combined))
522
+ self.canvas.create_image(0, 0, anchor='nw', image=self.tk_image)
523
+
524
+ def display_zoomed_image(self):
525
+ if self.zoom_rectangle_start and self.zoom_rectangle_end:
526
+ # Convert canvas coordinates to image coordinates
527
+ x0, y0 = self.canvas_to_image(*self.zoom_rectangle_start)
528
+ x1, y1 = self.canvas_to_image(*self.zoom_rectangle_end)
529
+ x0, x1 = min(x0, x1), max(x0, x1)
530
+ y0, y1 = min(y0, y1), max(y0, y1)
531
+ self.zoom_x0 = x0
532
+ self.zoom_y0 = y0
533
+ self.zoom_x1 = x1
534
+ self.zoom_y1 = y1
535
+ # Normalize the entire image
536
+ lower_quantile = float(self.lower_quantile.get()) if self.lower_quantile.get() else 1.0
537
+ upper_quantile = float(self.upper_quantile.get()) if self.upper_quantile.get() else 99.9
538
+ normalized_image = self.normalize_image(self.image, lower_quantile, upper_quantile)
539
+ # Extract the zoomed portion of the normalized image and mask
540
+ self.zoom_image = normalized_image[y0:y1, x0:x1]
541
+ self.zoom_image_orig = self.image[y0:y1, x0:x1]
542
+ self.zoom_mask = self.mask[y0:y1, x0:x1]
543
+ original_mask_area = self.mask.shape[0] * self.mask.shape[1]
544
+ zoom_mask_area = self.zoom_mask.shape[0] * self.zoom_mask.shape[1]
545
+ if original_mask_area > 0:
546
+ self.zoom_scale = original_mask_area/zoom_mask_area
547
+ # Resize the zoomed image and mask to fit the canvas
548
+ canvas_height = self.canvas.winfo_height()
549
+ canvas_width = self.canvas.winfo_width()
550
+
551
+ if self.zoom_image.size > 0 and canvas_height > 0 and canvas_width > 0:
552
+ self.zoom_image = resize(self.zoom_image, (canvas_height, canvas_width), preserve_range=True).astype(self.zoom_image.dtype)
553
+ self.zoom_image_orig = resize(self.zoom_image_orig, (canvas_height, canvas_width), preserve_range=True).astype(self.zoom_image_orig.dtype)
554
+ #self.zoom_mask = resize(self.zoom_mask, (canvas_height, canvas_width), preserve_range=True).astype(np.uint8)
555
+ self.zoom_mask = resize(self.zoom_mask, (canvas_height, canvas_width), order=0, preserve_range=True).astype(np.uint8)
556
+ combined = self.overlay_mask_on_image(self.zoom_image, self.zoom_mask)
557
+ self.tk_image = ImageTk.PhotoImage(image=Image.fromarray(combined))
558
+ self.canvas.create_image(0, 0, anchor='nw', image=self.tk_image)
559
+
560
+ def overlay_mask_on_image(self, image, mask, alpha=0.5):
561
+ if len(image.shape) == 2:
562
+ image = np.stack((image,) * 3, axis=-1)
563
+ mask = mask.astype(np.int32)
564
+ max_label = np.max(mask)
565
+ np.random.seed(0)
566
+ colors = np.random.randint(0, 255, size=(max_label + 1, 3), dtype=np.uint8)
567
+ colors[0] = [0, 0, 0] # background color
568
+ colored_mask = colors[mask]
569
+ image_8bit = (image / 256).astype(np.uint8)
570
+ # Blend the mask and the image with transparency
571
+ combined_image = np.where(mask[..., None] > 0,
572
+ np.clip(image_8bit * (1 - alpha) + colored_mask * alpha, 0, 255),
573
+ image_8bit)
574
+ # Convert the final image back to uint8
575
+ combined_image = combined_image.astype(np.uint8)
576
+ return combined_image
577
+
578
+ ####################################################################################################
579
+ # Navigation functions#
580
+ ####################################################################################################
581
+
582
+ def previous_image(self):
583
+ if self.current_image_index > 0:
584
+ self.current_image_index -= 1
585
+ self.initialize_flags()
586
+ self.image, self.mask = self.load_image_and_mask(self.current_image_index)
587
+ self.original_size = self.image.shape
588
+ self.image, self.mask = self.resize_arrays(self.image, self.mask)
589
+ self.display_image()
590
+
591
+ def next_image(self):
592
+ if self.current_image_index < len(self.image_filenames) - 1:
593
+ self.current_image_index += 1
594
+ self.initialize_flags()
595
+ self.image, self.mask = self.load_image_and_mask(self.current_image_index)
596
+ self.original_size = self.image.shape
597
+ self.image, self.mask = self.resize_arrays(self.image, self.mask)
598
+ self.display_image()
599
+
600
+ def save_mask(self):
601
+ if self.current_image_index < len(self.image_filenames):
602
+ original_size = self.original_size
603
+ if self.mask.shape != original_size:
604
+ resized_mask = resize(self.mask, original_size, order=0, preserve_range=True).astype(np.uint16)
605
+ else:
606
+ resized_mask = self.mask
607
+ resized_mask, _ = label(resized_mask > 0)
608
+ save_folder = os.path.join(self.folder_path, 'masks')
609
+ if not os.path.exists(save_folder):
610
+ os.makedirs(save_folder)
611
+ image_filename = os.path.splitext(self.image_filenames[self.current_image_index])[0] + '.tif'
612
+ save_path = os.path.join(save_folder, image_filename)
613
+
614
+ print(f"Saving mask to: {save_path}") # Debug print
615
+ imageio.imwrite(save_path, resized_mask)
616
+
617
+ ####################################################################################################
618
+ # Zoom Functions #
619
+ ####################################################################################################
620
+ def set_zoom_rectangle_start(self, event):
621
+ if self.zoom_active:
622
+ self.zoom_rectangle_start = (event.x, event.y)
623
+
624
+ def set_zoom_rectangle_end(self, event):
625
+ if self.zoom_active:
626
+ self.zoom_rectangle_end = (event.x, event.y)
627
+ if self.zoom_rectangle_id is not None:
628
+ self.canvas.delete(self.zoom_rectangle_id)
629
+ self.zoom_rectangle_id = None
630
+ self.display_zoomed_image()
631
+ self.canvas.unbind("<Motion>")
632
+ self.canvas.unbind("<Button-1>")
633
+ self.canvas.unbind("<Button-3>")
634
+ self.canvas.bind("<Motion>", self.update_mouse_info)
635
+
636
+ def update_zoom_box(self, event):
637
+ if self.zoom_active and self.zoom_rectangle_start is not None:
638
+ if self.zoom_rectangle_id is not None:
639
+ self.canvas.delete(self.zoom_rectangle_id)
640
+ # Assuming event.x and event.y are already in image coordinates
641
+ self.zoom_rectangle_end = (event.x, event.y)
642
+ x0, y0 = self.zoom_rectangle_start
643
+ x1, y1 = self.zoom_rectangle_end
644
+ self.zoom_rectangle_id = self.canvas.create_rectangle(x0, y0, x1, y1, outline="red", width=2)
645
+
646
+ ####################################################################################################
647
+ # Mode activation#
648
+ ####################################################################################################
649
+
650
+ def toggle_zoom_mode(self):
651
+ if not self.zoom_active:
652
+ self.brush_btn.config(text="Brush")
653
+ self.canvas.unbind("<B1-Motion>")
654
+ self.canvas.unbind("<B3-Motion>")
655
+ self.canvas.unbind("<ButtonRelease-1>")
656
+ self.canvas.unbind("<ButtonRelease-3>")
657
+ self.zoom_active = True
658
+ self.drawing = False
659
+ self.magic_wand_active = False
660
+ self.erase_active = False
661
+ self.brush_active = False
662
+ self.dividing_line_active = False
663
+ self.draw_btn.config(text="Draw")
664
+ self.erase_btn.config(text="Erase")
665
+ self.magic_wand_btn.config(text="Magic Wand")
666
+ self.zoom_btn.config(text="Zoom ON")
667
+ self.dividing_line_btn.config(text="Dividing Line")
668
+ self.canvas.unbind("<Button-1>")
669
+ self.canvas.unbind("<Button-3>")
670
+ self.canvas.unbind("<Motion>")
671
+ self.canvas.bind("<Button-1>", self.set_zoom_rectangle_start)
672
+ self.canvas.bind("<Button-3>", self.set_zoom_rectangle_end)
673
+ self.canvas.bind("<Motion>", self.update_zoom_box)
674
+ else:
675
+ self.zoom_active = False
676
+ self.zoom_btn.config(text="Zoom")
677
+ self.canvas.unbind("<Button-1>")
678
+ self.canvas.unbind("<Button-3>")
679
+ self.canvas.unbind("<Motion>")
680
+ self.zoom_rectangle_start = self.zoom_rectangle_end = None
681
+ self.zoom_rectangle_id = None
682
+ self.display_image()
683
+ self.canvas.bind("<Motion>", self.update_mouse_info)
684
+ self.zoom_rectangle_start = None
685
+ self.zoom_rectangle_end = None
686
+ self.zoom_rectangle_id = None
687
+ self.zoom_x0 = None
688
+ self.zoom_y0 = None
689
+ self.zoom_x1 = None
690
+ self.zoom_y1 = None
691
+ self.zoom_mask = None
692
+ self.zoom_image = None
693
+ self.zoom_image_orig = None
694
+
695
+ def toggle_brush_mode(self):
696
+ self.brush_active = not self.brush_active
697
+ if self.brush_active:
698
+ self.drawing = False
699
+ self.magic_wand_active = False
700
+ self.erase_active = False
701
+ self.brush_btn.config(text="Brush ON")
702
+ self.draw_btn.config(text="Draw")
703
+ self.erase_btn.config(text="Erase")
704
+ self.magic_wand_btn.config(text="Magic Wand")
705
+ self.canvas.unbind("<Button-1>")
706
+ self.canvas.unbind("<Button-3>")
707
+ self.canvas.unbind("<Motion>")
708
+ self.canvas.bind("<B1-Motion>", self.apply_brush) # Left click and drag to apply brush
709
+ self.canvas.bind("<B3-Motion>", self.erase_brush) # Right click and drag to erase with brush
710
+ self.canvas.bind("<ButtonRelease-1>", self.apply_brush_release) # Left button release
711
+ self.canvas.bind("<ButtonRelease-3>", self.erase_brush_release) # Right button release
712
+ else:
713
+ self.brush_active = False
714
+ self.brush_btn.config(text="Brush")
715
+ self.canvas.unbind("<B1-Motion>")
716
+ self.canvas.unbind("<B3-Motion>")
717
+ self.canvas.unbind("<ButtonRelease-1>")
718
+ self.canvas.unbind("<ButtonRelease-3>")
719
+
720
+ def image_to_canvas(self, x_image, y_image):
721
+ x_scale, y_scale = self.get_scaling_factors(
722
+ self.image.shape[1], self.image.shape[0],
723
+ self.canvas_width, self.canvas_height
724
+ )
725
+ x_canvas = int(x_image / x_scale)
726
+ y_canvas = int(y_image / y_scale)
727
+ return x_canvas, y_canvas
728
+
729
+ def toggle_dividing_line_mode(self):
730
+ self.dividing_line_active = not self.dividing_line_active
731
+ if self.dividing_line_active:
732
+ self.drawing = False
733
+ self.magic_wand_active = False
734
+ self.erase_active = False
735
+ self.brush_active = False
736
+ self.draw_btn.config(text="Draw")
737
+ self.erase_btn.config(text="Erase")
738
+ self.magic_wand_btn.config(text="Magic Wand")
739
+ self.brush_btn.config(text="Brush")
740
+ self.dividing_line_btn.config(text="Dividing Line ON")
741
+ self.canvas.unbind("<Button-1>")
742
+ self.canvas.unbind("<ButtonRelease-1>")
743
+ self.canvas.unbind("<Motion>")
744
+ self.canvas.bind("<Button-1>", self.start_dividing_line)
745
+ self.canvas.bind("<ButtonRelease-1>", self.finish_dividing_line)
746
+ self.canvas.bind("<Motion>", self.update_dividing_line_preview)
747
+ else:
748
+ print("Dividing Line Mode: OFF")
749
+ self.dividing_line_active = False
750
+ self.dividing_line_btn.config(text="Dividing Line")
751
+ self.canvas.unbind("<Button-1>")
752
+ self.canvas.unbind("<ButtonRelease-1>")
753
+ self.canvas.unbind("<Motion>")
754
+ self.display_image()
755
+
756
+ def start_dividing_line(self, event):
757
+ if self.dividing_line_active:
758
+ self.dividing_line_coords = [(event.x, event.y)]
759
+ self.current_dividing_line = self.canvas.create_line(event.x, event.y, event.x, event.y, fill="red", width=2)
760
+
761
+ def finish_dividing_line(self, event):
762
+ if self.dividing_line_active:
763
+ self.dividing_line_coords.append((event.x, event.y))
764
+ if self.zoom_active:
765
+ self.dividing_line_coords = [self.canvas_to_image(x, y) for x, y in self.dividing_line_coords]
766
+ self.apply_dividing_line()
767
+ self.canvas.delete(self.current_dividing_line)
768
+ self.current_dividing_line = None
769
+
770
+ def update_dividing_line_preview(self, event):
771
+ if self.dividing_line_active and self.dividing_line_coords:
772
+ x, y = event.x, event.y
773
+ if self.zoom_active:
774
+ x, y = self.canvas_to_image(x, y)
775
+ self.dividing_line_coords.append((x, y))
776
+ canvas_coords = [(self.image_to_canvas(*pt) if self.zoom_active else pt) for pt in self.dividing_line_coords]
777
+ flat_canvas_coords = [coord for pt in canvas_coords for coord in pt]
778
+ self.canvas.coords(self.current_dividing_line, *flat_canvas_coords)
779
+
780
+ def apply_dividing_line(self):
781
+ if self.dividing_line_coords:
782
+ coords = self.dividing_line_coords
783
+ if self.zoom_active:
784
+ coords = [self.canvas_to_image(x, y) for x, y in coords]
785
+
786
+ rr, cc = [], []
787
+ for (x0, y0), (x1, y1) in zip(coords[:-1], coords[1:]):
788
+ line_rr, line_cc = line(y0, x0, y1, x1)
789
+ rr.extend(line_rr)
790
+ cc.extend(line_cc)
791
+ rr, cc = np.array(rr), np.array(cc)
792
+
793
+ mask_copy = self.mask.copy()
794
+
795
+ if self.zoom_active:
796
+ # Update the zoomed mask
797
+ self.zoom_mask[rr, cc] = 0
798
+ # Reflect changes to the original mask
799
+ y0, y1, x0, x1 = self.zoom_y0, self.zoom_y1, self.zoom_x0, self.zoom_x1
800
+ zoomed_mask_resized_back = resize(self.zoom_mask, (y1 - y0, x1 - x0), order=0, preserve_range=True).astype(np.uint8)
801
+ self.mask[y0:y1, x0:x1] = zoomed_mask_resized_back
802
+ else:
803
+ # Directly update the original mask
804
+ mask_copy[rr, cc] = 0
805
+ self.mask = mask_copy
806
+
807
+ labeled_mask, num_labels = label(self.mask > 0)
808
+ self.mask = labeled_mask
809
+ self.update_display()
810
+
811
+ self.dividing_line_coords = []
812
+ self.canvas.unbind("<Button-1>")
813
+ self.canvas.unbind("<ButtonRelease-1>")
814
+ self.canvas.unbind("<Motion>")
815
+ self.dividing_line_active = False
816
+ self.dividing_line_btn.config(text="Dividing Line")
817
+
818
+ def toggle_draw_mode(self):
819
+ self.drawing = not self.drawing
820
+ if self.drawing:
821
+ self.brush_btn.config(text="Brush")
822
+ self.canvas.unbind("<B1-Motion>")
823
+ self.canvas.unbind("<B3-Motion>")
824
+ self.canvas.unbind("<ButtonRelease-1>")
825
+ self.canvas.unbind("<ButtonRelease-3>")
826
+ self.magic_wand_active = False
827
+ self.erase_active = False
828
+ self.brush_active = False
829
+ self.draw_btn.config(text="Draw ON")
830
+ self.magic_wand_btn.config(text="Magic Wand")
831
+ self.erase_btn.config(text="Erase")
832
+ self.draw_coordinates = []
833
+ self.canvas.unbind("<Button-1>")
834
+ self.canvas.unbind("<Motion>")
835
+ self.canvas.bind("<B1-Motion>", self.draw)
836
+ self.canvas.bind("<ButtonRelease-1>", self.finish_drawing)
837
+ else:
838
+ self.drawing = False
839
+ self.draw_btn.config(text="Draw")
840
+ self.canvas.unbind("<B1-Motion>")
841
+ self.canvas.unbind("<ButtonRelease-1>")
842
+
843
+ def toggle_magic_wand_mode(self):
844
+ self.magic_wand_active = not self.magic_wand_active
845
+ if self.magic_wand_active:
846
+ self.brush_btn.config(text="Brush")
847
+ self.canvas.unbind("<B1-Motion>")
848
+ self.canvas.unbind("<B3-Motion>")
849
+ self.canvas.unbind("<ButtonRelease-1>")
850
+ self.canvas.unbind("<ButtonRelease-3>")
851
+ self.drawing = False
852
+ self.erase_active = False
853
+ self.brush_active = False
854
+ self.draw_btn.config(text="Draw")
855
+ self.erase_btn.config(text="Erase")
856
+ self.magic_wand_btn.config(text="Magic Wand ON")
857
+ self.canvas.bind("<Button-1>", self.use_magic_wand)
858
+ self.canvas.bind("<Button-3>", self.use_magic_wand)
859
+ else:
860
+ self.magic_wand_btn.config(text="Magic Wand")
861
+ self.canvas.unbind("<Button-1>")
862
+ self.canvas.unbind("<Button-3>")
863
+
864
+ def toggle_erase_mode(self):
865
+ self.erase_active = not self.erase_active
866
+ if self.erase_active:
867
+ self.brush_btn.config(text="Brush")
868
+ self.canvas.unbind("<B1-Motion>")
869
+ self.canvas.unbind("<B3-Motion>")
870
+ self.canvas.unbind("<ButtonRelease-1>")
871
+ self.canvas.unbind("<ButtonRelease-3>")
872
+ self.erase_btn.config(text="Erase ON")
873
+ self.canvas.bind("<Button-1>", self.erase_object)
874
+ self.drawing = False
875
+ self.magic_wand_active = False
876
+ self.brush_active = False
877
+ self.draw_btn.config(text="Draw")
878
+ self.magic_wand_btn.config(text="Magic Wand")
879
+ else:
880
+ self.erase_active = False
881
+ self.erase_btn.config(text="Erase")
882
+ self.canvas.unbind("<Button-1>")
883
+
884
+ ####################################################################################################
885
+ # Mode functions#
886
+ ####################################################################################################
887
+
888
+ def apply_brush_release(self, event):
889
+ if hasattr(self, 'brush_path'):
890
+ for x, y, brush_size in self.brush_path:
891
+ img_x, img_y = (x, y) if self.zoom_active else self.canvas_to_image(x, y)
892
+ x0 = max(img_x - brush_size // 2, 0)
893
+ y0 = max(img_y - brush_size // 2, 0)
894
+ x1 = min(img_x + brush_size // 2, self.zoom_mask.shape[1] if self.zoom_active else self.mask.shape[1])
895
+ y1 = min(img_y + brush_size // 2, self.zoom_mask.shape[0] if self.zoom_active else self.mask.shape[0])
896
+ if self.zoom_active:
897
+ self.zoom_mask[y0:y1, x0:x1] = 255
898
+ self.update_original_mask_from_zoom()
899
+ else:
900
+ self.mask[y0:y1, x0:x1] = 255
901
+ del self.brush_path
902
+ self.canvas.delete("temp_line")
903
+ self.update_display()
904
+
905
+ def erase_brush_release(self, event):
906
+ if hasattr(self, 'erase_path'):
907
+ for x, y, brush_size in self.erase_path:
908
+ img_x, img_y = (x, y) if self.zoom_active else self.canvas_to_image(x, y)
909
+ x0 = max(img_x - brush_size // 2, 0)
910
+ y0 = max(img_y - brush_size // 2, 0)
911
+ x1 = min(img_x + brush_size // 2, self.zoom_mask.shape[1] if self.zoom_active else self.mask.shape[1])
912
+ y1 = min(img_y + brush_size // 2, self.zoom_mask.shape[0] if self.zoom_active else self.mask.shape[0])
913
+ if self.zoom_active:
914
+ self.zoom_mask[y0:y1, x0:x1] = 0
915
+ self.update_original_mask_from_zoom()
916
+ else:
917
+ self.mask[y0:y1, x0:x1] = 0
918
+ del self.erase_path
919
+ self.canvas.delete("temp_line")
920
+ self.update_display()
921
+
922
+ def apply_brush(self, event):
923
+ brush_size = int(self.brush_size_entry.get())
924
+ x, y = event.x, event.y
925
+ if not hasattr(self, 'brush_path'):
926
+ self.brush_path = []
927
+ self.last_brush_coord = (x, y)
928
+ if self.last_brush_coord:
929
+ last_x, last_y = self.last_brush_coord
930
+ rr, cc = line(last_y, last_x, y, x)
931
+ for ry, rx in zip(rr, cc):
932
+ self.brush_path.append((rx, ry, brush_size))
933
+
934
+ self.canvas.create_line(self.last_brush_coord[0], self.last_brush_coord[1], x, y, width=brush_size, fill="blue", tag="temp_line")
935
+ self.last_brush_coord = (x, y)
936
+
937
+ def erase_brush(self, event):
938
+ brush_size = int(self.brush_size_entry.get())
939
+ x, y = event.x, event.y
940
+ if not hasattr(self, 'erase_path'):
941
+ self.erase_path = []
942
+ self.last_erase_coord = (x, y)
943
+ if self.last_erase_coord:
944
+ last_x, last_y = self.last_erase_coord
945
+ rr, cc = line(last_y, last_x, y, x)
946
+ for ry, rx in zip(rr, cc):
947
+ self.erase_path.append((rx, ry, brush_size))
948
+
949
+ self.canvas.create_line(self.last_erase_coord[0], self.last_erase_coord[1], x, y, width=brush_size, fill="white", tag="temp_line")
950
+ self.last_erase_coord = (x, y)
951
+
952
+ def erase_object(self, event):
953
+ x, y = event.x, event.y
954
+ if self.zoom_active:
955
+ canvas_x, canvas_y = x, y
956
+ zoomed_x = int(canvas_x * (self.zoom_image.shape[1] / self.canvas_width))
957
+ zoomed_y = int(canvas_y * (self.zoom_image.shape[0] / self.canvas_height))
958
+ orig_x = int(zoomed_x * ((self.zoom_x1 - self.zoom_x0) / self.canvas_width) + self.zoom_x0)
959
+ orig_y = int(zoomed_y * ((self.zoom_y1 - self.zoom_y0) / self.canvas_height) + self.zoom_y0)
960
+ if orig_x < 0 or orig_y < 0 or orig_x >= self.image.shape[1] or orig_y >= self.image.shape[0]:
961
+ print("Point is out of bounds in the original image.")
962
+ return
963
+ else:
964
+ orig_x, orig_y = x, y
965
+ label_to_remove = self.mask[orig_y, orig_x]
966
+ if label_to_remove > 0:
967
+ self.mask[self.mask == label_to_remove] = 0
968
+ self.update_display()
969
+
970
+ def use_magic_wand(self, event):
971
+ x, y = event.x, event.y
972
+ tolerance = int(self.magic_wand_tolerance.get())
973
+ maximum = int(self.max_pixels_entry.get())
974
+ action = 'add' if event.num == 1 else 'erase'
975
+ if self.zoom_active:
976
+ self.magic_wand_zoomed((x, y), tolerance, action)
977
+ else:
978
+ self.magic_wand_normal((x, y), tolerance, action)
979
+
980
+ def apply_magic_wand(self, image, mask, seed_point, tolerance, maximum, action='add'):
981
+ x, y = seed_point
982
+ initial_value = image[y, x].astype(np.float32)
983
+ visited = np.zeros_like(image, dtype=bool)
984
+ queue = deque([(x, y)])
985
+ added_pixels = 0
986
+
987
+ while queue and added_pixels < maximum:
988
+ cx, cy = queue.popleft()
989
+ if visited[cy, cx]:
990
+ continue
991
+ visited[cy, cx] = True
992
+ current_value = image[cy, cx].astype(np.float32)
993
+
994
+ if np.linalg.norm(abs(current_value - initial_value)) <= tolerance:
995
+ if mask[cy, cx] == 0:
996
+ added_pixels += 1
997
+ mask[cy, cx] = 255 if action == 'add' else 0
998
+
999
+ if added_pixels >= maximum:
1000
+ break
1001
+
1002
+ for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
1003
+ nx, ny = cx + dx, cy + dy
1004
+ if 0 <= nx < image.shape[1] and 0 <= ny < image.shape[0] and not visited[ny, nx]:
1005
+ queue.append((nx, ny))
1006
+ return mask
1007
+
1008
+ def magic_wand_normal(self, seed_point, tolerance, action):
1009
+ try:
1010
+ maximum = int(self.max_pixels_entry.get())
1011
+ except ValueError:
1012
+ print("Invalid maximum value; using default of 1000")
1013
+ maximum = 1000
1014
+ self.mask = self.apply_magic_wand(self.image, self.mask, seed_point, tolerance, maximum, action)
1015
+ self.display_image()
1016
+
1017
+ def magic_wand_zoomed(self, seed_point, tolerance, action):
1018
+ if self.zoom_image_orig is None or self.zoom_mask is None:
1019
+ print("Zoomed image or mask not initialized")
1020
+ return
1021
+ try:
1022
+ maximum = int(self.max_pixels_entry.get())
1023
+ maximum = maximum * self.zoom_scale
1024
+ except ValueError:
1025
+ print("Invalid maximum value; using default of 1000")
1026
+ maximum = 1000
1027
+
1028
+ canvas_x, canvas_y = seed_point
1029
+ 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]:
1030
+ print("Selected point is out of bounds in the zoomed image.")
1031
+ return
1032
+
1033
+ self.zoom_mask = self.apply_magic_wand(self.zoom_image_orig, self.zoom_mask, (canvas_x, canvas_y), tolerance, maximum, action)
1034
+ y0, y1, x0, x1 = self.zoom_y0, self.zoom_y1, self.zoom_x0, self.zoom_x1
1035
+ zoomed_mask_resized_back = resize(self.zoom_mask, (y1 - y0, x1 - x0), order=0, preserve_range=True).astype(np.uint8)
1036
+ if action == 'erase':
1037
+ self.mask[y0:y1, x0:x1] = np.where(zoomed_mask_resized_back == 0, 0, self.mask[y0:y1, x0:x1])
1038
+ else:
1039
+ self.mask[y0:y1, x0:x1] = np.where(zoomed_mask_resized_back > 0, zoomed_mask_resized_back, self.mask[y0:y1, x0:x1])
1040
+ self.update_display()
1041
+
1042
+ def draw(self, event):
1043
+ if self.drawing:
1044
+ x, y = event.x, event.y
1045
+ if self.draw_coordinates:
1046
+ last_x, last_y = self.draw_coordinates[-1]
1047
+ self.current_line = self.canvas.create_line(last_x, last_y, x, y, fill="yellow", width=3)
1048
+ self.draw_coordinates.append((x, y))
1049
+
1050
+ def draw_on_zoomed_mask(self, draw_coordinates):
1051
+ canvas_height = self.canvas.winfo_height()
1052
+ canvas_width = self.canvas.winfo_width()
1053
+ zoomed_mask = np.zeros((canvas_height, canvas_width), dtype=np.uint8)
1054
+ rr, cc = polygon(np.array(draw_coordinates)[:, 1], np.array(draw_coordinates)[:, 0], shape=zoomed_mask.shape)
1055
+ zoomed_mask[rr, cc] = 255
1056
+ return zoomed_mask
1057
+
1058
+ def finish_drawing(self, event):
1059
+ if len(self.draw_coordinates) > 2:
1060
+ self.draw_coordinates.append(self.draw_coordinates[0])
1061
+ if self.zoom_active:
1062
+ x0, x1, y0, y1 = self.zoom_x0, self.zoom_x1, self.zoom_y0, self.zoom_y1
1063
+ zoomed_mask = self.draw_on_zoomed_mask(self.draw_coordinates)
1064
+ self.update_original_mask(zoomed_mask, x0, x1, y0, y1)
1065
+ else:
1066
+ rr, cc = polygon(np.array(self.draw_coordinates)[:, 1], np.array(self.draw_coordinates)[:, 0], shape=self.mask.shape)
1067
+ self.mask[rr, cc] = np.maximum(self.mask[rr, cc], 255)
1068
+ self.mask = self.mask.copy()
1069
+ self.canvas.delete(self.current_line)
1070
+ self.draw_coordinates.clear()
1071
+ self.update_display()
1072
+
1073
+ def finish_drawing_if_active(self, event):
1074
+ if self.drawing and len(self.draw_coordinates) > 2:
1075
+ self.finish_drawing(event)
1076
+
1077
+ ####################################################################################################
1078
+ # Single function butons#
1079
+ ####################################################################################################
1080
+
1081
+ def apply_normalization(self):
1082
+ self.lower_quantile.set(self.lower_entry.get())
1083
+ self.upper_quantile.set(self.upper_entry.get())
1084
+ self.update_display()
1085
+
1086
+ def fill_objects(self):
1087
+ binary_mask = self.mask > 0
1088
+ filled_mask = binary_fill_holes(binary_mask)
1089
+ self.mask = filled_mask.astype(np.uint8) * 255
1090
+ labeled_mask, _ = label(filled_mask)
1091
+ self.mask = labeled_mask
1092
+ self.update_display()
1093
+
1094
+ def relabel_objects(self):
1095
+ mask = self.mask
1096
+ labeled_mask, num_labels = label(mask > 0)
1097
+ self.mask = labeled_mask
1098
+ self.update_display()
1099
+
1100
+ def clear_objects(self):
1101
+ self.mask = np.zeros_like(self.mask)
1102
+ self.update_display()
1103
+
1104
+ def invert_mask(self):
1105
+ self.mask = np.where(self.mask > 0, 0, 1)
1106
+ self.relabel_objects()
1107
+ self.update_display()
1108
+
1109
+ def remove_small_objects(self):
1110
+ try:
1111
+ min_area = int(self.min_area_entry.get())
1112
+ except ValueError:
1113
+ print("Invalid minimum area value; using default of 100")
1114
+ min_area = 100
1115
+
1116
+ labeled_mask, num_labels = label(self.mask > 0)
1117
+ for i in range(1, num_labels + 1): # Skip background
1118
+ if np.sum(labeled_mask == i) < min_area:
1119
+ self.mask[labeled_mask == i] = 0 # Remove small objects
1120
+ self.update_display()
1121
+
1122
+ class ImageApp:
1123
+ def __init__(self, root, db_path, src, image_type=None, channels=None, grid_rows=None, grid_cols=None, image_size=(200, 200), annotation_column='annotate', normalize=False, percentiles=(1,99), measurement=None, threshold=None):
1124
+ self.root = root
1125
+ self.db_path = db_path
1126
+ self.src = src
1127
+ self.index = 0
1128
+ self.grid_rows = grid_rows
1129
+ self.grid_cols = grid_cols
1130
+ self.image_size = image_size
1131
+ self.annotation_column = annotation_column
1132
+ self.image_type = image_type
1133
+ self.channels = channels
1134
+ self.normalize = normalize
1135
+ self.percentiles = percentiles
1136
+ self.images = {}
1137
+ self.pending_updates = {}
1138
+ self.labels = []
1139
+ self.adjusted_to_original_paths = {}
1140
+ self.terminate = False
1141
+ self.update_queue = Queue()
1142
+ self.status_label = Label(self.root, text="", font=("Arial", 12))
1143
+ self.status_label.grid(row=self.grid_rows + 1, column=0, columnspan=self.grid_cols)
1144
+ self.measurement = measurement
1145
+ self.threshold = threshold
1146
+
1147
+ self.filtered_paths_annotations = []
1148
+ self.prefilter_paths_annotations()
1149
+
1150
+ self.db_update_thread = threading.Thread(target=self.update_database_worker)
1151
+ self.db_update_thread.start()
1152
+
1153
+ for i in range(grid_rows * grid_cols):
1154
+ label = Label(root)
1155
+ label.grid(row=i // grid_cols, column=i % grid_cols)
1156
+ self.labels.append(label)
1157
+
1158
+ def prefilter_paths_annotations(self):
1159
+ from .io import _read_and_join_tables
1160
+ from .utils import is_list_of_lists
1161
+
1162
+ if self.measurement and self.threshold is not None:
1163
+ df = _read_and_join_tables(self.db_path)
1164
+ df[self.annotation_column] = None
1165
+ before = len(df)
1166
+
1167
+ if is_list_of_lists(self.measurement):
1168
+ if isinstance(self.threshold, list) or is_list_of_lists(self.threshold):
1169
+ if len(self.measurement) == len(self.threshold):
1170
+ for idx, var in enumerate(self.measurement):
1171
+ df = df[df[var[idx]] > self.threshold[idx]]
1172
+ after = len(df)
1173
+ elif len(self.measurement) == len(self.threshold)*2:
1174
+ th_idx = 0
1175
+ for idx, var in enumerate(self.measurement):
1176
+ if idx % 2 != 0:
1177
+ th_idx += 1
1178
+ thd = self.threshold
1179
+ if isinstance(thd, list):
1180
+ thd = thd[0]
1181
+ df[f'threshold_measurement_{idx}'] = df[self.measurement[idx]]/df[self.measurement[idx+1]]
1182
+ print(f"mean threshold_measurement_{idx}: {np.mean(df['threshold_measurement'])}")
1183
+ print(f"median threshold measurement: {np.median(df[self.measurement])}")
1184
+ df = df[df[f'threshold_measurement_{idx}'] > thd]
1185
+ after = len(df)
1186
+ elif isinstance(self.measurement, list):
1187
+ df['threshold_measurement'] = df[self.measurement[0]]/df[self.measurement[1]]
1188
+ print(f"mean threshold measurement: {np.mean(df['threshold_measurement'])}")
1189
+ print(f"median threshold measurement: {np.median(df[self.measurement])}")
1190
+ df = df[df['threshold_measurement'] > self.threshold]
1191
+ after = len(df)
1192
+ self.measurement = 'threshold_measurement'
1193
+ print(f'Removed: {before-after} rows, retained {after}')
1194
+ else:
1195
+ print(f"mean threshold measurement: {np.mean(df[self.measurement])}")
1196
+ print(f"median threshold measurement: {np.median(df[self.measurement])}")
1197
+ before = len(df)
1198
+ if isinstance(self.threshold, str):
1199
+ if self.threshold == 'q1':
1200
+ self.threshold = df[self.measurement].quantile(0.1)
1201
+ if self.threshold == 'q2':
1202
+ self.threshold = df[self.measurement].quantile(0.2)
1203
+ if self.threshold == 'q3':
1204
+ self.threshold = df[self.measurement].quantile(0.3)
1205
+ if self.threshold == 'q4':
1206
+ self.threshold = df[self.measurement].quantile(0.4)
1207
+ if self.threshold == 'q5':
1208
+ self.threshold = df[self.measurement].quantile(0.5)
1209
+ if self.threshold == 'q6':
1210
+ self.threshold = df[self.measurement].quantile(0.6)
1211
+ if self.threshold == 'q7':
1212
+ self.threshold = df[self.measurement].quantile(0.7)
1213
+ if self.threshold == 'q8':
1214
+ self.threshold = df[self.measurement].quantile(0.8)
1215
+ if self.threshold == 'q9':
1216
+ self.threshold = df[self.measurement].quantile(0.9)
1217
+ print(f"threshold: {self.threshold}")
1218
+
1219
+ df = df[df[self.measurement] > self.threshold]
1220
+ after = len(df)
1221
+ print(f'Removed: {before-after} rows, retained {after}')
1222
+
1223
+ df = df.dropna(subset=['png_path'])
1224
+ if self.image_type:
1225
+ before = len(df)
1226
+ if isinstance(self.image_type, list):
1227
+ for tpe in self.image_type:
1228
+ df = df[df['png_path'].str.contains(tpe)]
1229
+ else:
1230
+ df = df[df['png_path'].str.contains(self.image_type)]
1231
+ after = len(df)
1232
+ print(f'image_type: Removed: {before-after} rows, retained {after}')
1233
+
1234
+ self.filtered_paths_annotations = df[['png_path', self.annotation_column]].values.tolist()
1235
+ else:
1236
+ conn = sqlite3.connect(self.db_path)
1237
+ c = conn.cursor()
1238
+ if self.image_type:
1239
+ c.execute(f"SELECT png_path, {self.annotation_column} FROM png_list WHERE png_path LIKE ?", (f"%{self.image_type}%",))
1240
+ else:
1241
+ c.execute(f"SELECT png_path, {self.annotation_column} FROM png_list")
1242
+ self.filtered_paths_annotations = c.fetchall()
1243
+ conn.close()
1244
+
1245
+ def load_images(self):
1246
+ for label in self.labels:
1247
+ label.config(image='')
1248
+
1249
+ self.images = {}
1250
+ paths_annotations = self.filtered_paths_annotations[self.index:self.index + self.grid_rows * self.grid_cols]
1251
+
1252
+ adjusted_paths = []
1253
+ for path, annotation in paths_annotations:
1254
+ if not path.startswith(self.src):
1255
+ parts = path.split('/data/')
1256
+ if len(parts) > 1:
1257
+ new_path = os.path.join(self.src, 'data', parts[1])
1258
+ self.adjusted_to_original_paths[new_path] = path
1259
+ adjusted_paths.append((new_path, annotation))
1260
+ else:
1261
+ adjusted_paths.append((path, annotation))
1262
+ else:
1263
+ adjusted_paths.append((path, annotation))
1264
+
1265
+ with ThreadPoolExecutor() as executor:
1266
+ loaded_images = list(executor.map(self.load_single_image, adjusted_paths))
1267
+
1268
+ for i, (img, annotation) in enumerate(loaded_images):
1269
+ if annotation:
1270
+ border_color = 'teal' if annotation == 1 else 'red'
1271
+ img = self.add_colored_border(img, border_width=5, border_color=border_color)
1272
+
1273
+ photo = ImageTk.PhotoImage(img)
1274
+ label = self.labels[i]
1275
+ self.images[label] = photo
1276
+ label.config(image=photo)
1277
+
1278
+ path = adjusted_paths[i][0]
1279
+ label.bind('<Button-1>', self.get_on_image_click(path, label, img))
1280
+ label.bind('<Button-3>', self.get_on_image_click(path, label, img))
1281
+
1282
+ self.root.update()
1283
+
1284
+ def load_single_image(self, path_annotation_tuple):
1285
+ path, annotation = path_annotation_tuple
1286
+ img = Image.open(path)
1287
+ img = self.normalize_image(img, self.normalize, self.percentiles)
1288
+ img = img.convert('RGB')
1289
+ img = self.filter_channels(img)
1290
+ img = img.resize(self.image_size)
1291
+ return img, annotation
1292
+
1293
+ @staticmethod
1294
+ def normalize_image(img, normalize=False, percentiles=(1, 99)):
1295
+ img_array = np.array(img)
1296
+
1297
+ if normalize:
1298
+ if img_array.ndim == 2: # Grayscale image
1299
+ p2, p98 = np.percentile(img_array, percentiles)
1300
+ img_array = rescale_intensity(img_array, in_range=(p2, p98), out_range=(0, 255))
1301
+ else: # Color image or multi-channel image
1302
+ for channel in range(img_array.shape[2]):
1303
+ p2, p98 = np.percentile(img_array[:, :, channel], percentiles)
1304
+ img_array[:, :, channel] = rescale_intensity(img_array[:, :, channel], in_range=(p2, p98), out_range=(0, 255))
1305
+
1306
+ img_array = np.clip(img_array, 0, 255).astype('uint8')
1307
+
1308
+ return Image.fromarray(img_array)
1309
+
1310
+ def add_colored_border(self, img, border_width, border_color):
1311
+ top_border = Image.new('RGB', (img.width, border_width), color=border_color)
1312
+ bottom_border = Image.new('RGB', (img.width, border_width), color=border_color)
1313
+ left_border = Image.new('RGB', (border_width, img.height), color=border_color)
1314
+ right_border = Image.new('RGB', (border_width, img.height), color=border_color)
1315
+
1316
+ bordered_img = Image.new('RGB', (img.width + 2 * border_width, img.height + 2 * border_width), color='white')
1317
+ bordered_img.paste(top_border, (border_width, 0))
1318
+ bordered_img.paste(bottom_border, (border_width, img.height + border_width))
1319
+ bordered_img.paste(left_border, (0, border_width))
1320
+ bordered_img.paste(right_border, (img.width + border_width, border_width))
1321
+ bordered_img.paste(img, (border_width, border_width))
1322
+
1323
+ return bordered_img
1324
+
1325
+ def filter_channels(self, img):
1326
+ r, g, b = img.split()
1327
+ if self.channels:
1328
+ if 'r' not in self.channels:
1329
+ r = r.point(lambda _: 0)
1330
+ if 'g' not in self.channels:
1331
+ g = g.point(lambda _: 0)
1332
+ if 'b' not in self.channels:
1333
+ b = b.point(lambda _: 0)
1334
+
1335
+ if len(self.channels) == 1:
1336
+ channel_img = r if 'r' in self.channels else (g if 'g' in self.channels else b)
1337
+ return ImageOps.grayscale(channel_img)
1338
+
1339
+ return Image.merge("RGB", (r, g, b))
1340
+
1341
+ def get_on_image_click(self, path, label, img):
1342
+ def on_image_click(event):
1343
+ new_annotation = 1 if event.num == 1 else (2 if event.num == 3 else None)
1344
+
1345
+ original_path = self.adjusted_to_original_paths.get(path, path)
1346
+
1347
+ if original_path in self.pending_updates and self.pending_updates[original_path] == new_annotation:
1348
+ self.pending_updates[original_path] = None
1349
+ new_annotation = None
1350
+ else:
1351
+ self.pending_updates[original_path] = new_annotation
1352
+
1353
+ print(f"Image {os.path.split(path)[1]} annotated: {new_annotation}")
1354
+
1355
+ img_ = img.crop((5, 5, img.width-5, img.height-5))
1356
+ border_fill = 'teal' if new_annotation == 1 else ('red' if new_annotation == 2 else None)
1357
+ img_ = ImageOps.expand(img_, border=5, fill=border_fill) if border_fill else img_
1358
+
1359
+ photo = ImageTk.PhotoImage(img_)
1360
+ self.images[label] = photo
1361
+ label.config(image=photo)
1362
+ self.root.update()
1363
+
1364
+ return on_image_click
1365
+
1366
+ @staticmethod
1367
+ def update_html(text):
1368
+ display(HTML(f"""
1369
+ <script>
1370
+ document.getElementById('unique_id').innerHTML = '{text}';
1371
+ </script>
1372
+ """))
1373
+
1374
+ def update_database_worker(self):
1375
+ conn = sqlite3.connect(self.db_path)
1376
+ c = conn.cursor()
1377
+
1378
+ display(HTML("<div id='unique_id'>Initial Text</div>"))
1379
+
1380
+ while True:
1381
+ if self.terminate:
1382
+ conn.close()
1383
+ break
1384
+
1385
+ if not self.update_queue.empty():
1386
+ ImageApp.update_html("Do not exit, Updating database...")
1387
+ self.status_label.config(text='Do not exit, Updating database...')
1388
+
1389
+ pending_updates = self.update_queue.get()
1390
+ for path, new_annotation in pending_updates.items():
1391
+ if new_annotation is None:
1392
+ c.execute(f'UPDATE png_list SET {self.annotation_column} = NULL WHERE png_path = ?', (path,))
1393
+ else:
1394
+ c.execute(f'UPDATE png_list SET {self.annotation_column} = ? WHERE png_path = ?', (new_annotation, path))
1395
+ conn.commit()
1396
+
1397
+ ImageApp.update_html('')
1398
+ self.status_label.config(text='')
1399
+ self.root.update()
1400
+ time.sleep(0.1)
1401
+
1402
+ def update_gui_text(self, text):
1403
+ self.status_label.config(text=text)
1404
+ self.root.update()
1405
+
1406
+ def next_page(self):
1407
+ if self.pending_updates:
1408
+ self.update_queue.put(self.pending_updates.copy())
1409
+ self.pending_updates.clear()
1410
+ self.index += self.grid_rows * self.grid_cols
1411
+ self.load_images()
1412
+
1413
+ def previous_page(self):
1414
+ if self.pending_updates:
1415
+ self.update_queue.put(self.pending_updates.copy())
1416
+ self.pending_updates.clear()
1417
+ self.index -= self.grid_rows * self.grid_cols
1418
+ if self.index < 0:
1419
+ self.index = 0
1420
+ self.load_images()
1421
+
1422
+ def shutdown(self):
1423
+ self.terminate = True
1424
+ self.update_queue.put(self.pending_updates.copy())
1425
+ self.pending_updates.clear()
1426
+ self.db_update_thread.join()
1427
+ self.root.quit()
1428
+ self.root.destroy()
1429
+ print(f'Quit application')
1430
+
264
1431
  def create_menu_bar(root):
265
- from .app_annotate import initiate_annotation_app_root
266
- from .app_make_masks import initiate_mask_app_root
267
1432
  from .gui_utils import load_app
1433
+ from .gui_core import initiate_root
268
1434
 
269
1435
  gui_apps = {
270
- "Mask": 'mask',
271
- "Measure": 'measure',
272
- "Annotate": initiate_annotation_app_root,
273
- "Make Masks": initiate_mask_app_root,
274
- "Classify": 'classify',
275
- "Sequencing": 'sequencing',
276
- "Umap": 'umap'
1436
+ "Main": (lambda frame: initiate_root(frame, None), "Main GUI window."),
1437
+ "Mask": (lambda frame: initiate_root(frame, 'mask'), "Generate cellpose masks for cells, nuclei and pathogen images."),
1438
+ "Measure": (lambda frame: initiate_root(frame, 'measure'), "Measure single object intensity and morphological feature. Crop and save single object image"),
1439
+ "Annotate": (lambda frame: initiate_root(frame, 'annotate'), "Annotation single object images on a grid. Annotations are saved to database."),
1440
+ "Make Masks": (lambda frame: initiate_root(frame, 'make_masks'), "Adjust pre-existing Cellpose models to your specific dataset for improved performance"),
1441
+ "Classify": (lambda frame: initiate_root(frame, 'classify'), "Train Torch Convolutional Neural Networks (CNNs) or Transformers to classify single object images."),
1442
+ "Sequencing": (lambda frame: initiate_root(frame, 'sequencing'), "Analyze sequencing data."),
1443
+ "Umap": (lambda frame: initiate_root(frame, 'umap'), "Generate UMAP embeddings with datapoints represented as images.")
277
1444
  }
278
1445
 
279
1446
  def load_app_wrapper(app_name, app_func):
@@ -285,7 +1452,8 @@ def create_menu_bar(root):
285
1452
  app_menu = tk.Menu(menu_bar, tearoff=0, bg="#008080", fg="white")
286
1453
  menu_bar.add_cascade(label="SpaCr Applications", menu=app_menu)
287
1454
  # Add options to the "SpaCr Applications" menu
288
- for app_name, app_func in gui_apps.items():
1455
+ for app_name, app_data in gui_apps.items():
1456
+ app_func, app_desc = app_data
289
1457
  app_menu.add_command(label=app_name, command=lambda app_name=app_name, app_func=app_func: load_app_wrapper(app_name, app_func))
290
1458
  # Add a separator and an exit option
291
1459
  app_menu.add_separator()
@@ -296,8 +1464,8 @@ def create_menu_bar(root):
296
1464
  def set_dark_style(style):
297
1465
  font_style = tkFont.Font(family="Helvetica", size=24)
298
1466
  style.configure('TEntry', padding='5 5 5 5', borderwidth=1, relief='solid', fieldbackground='black', foreground='#ffffff', font=font_style)
299
- style.configure('TCombobox', fieldbackground='black', background='black', foreground='#ffffff', font=font_style)
300
- style.map('TCombobox', fieldbackground=[('readonly', 'black')], foreground=[('readonly', '#ffffff')])
1467
+ style.configure('TCombobox', fieldbackground='black', background='black', foreground='#ffffff', selectbackground='black', selectforeground='#ffffff', font=font_style)
1468
+ style.map('TCombobox', fieldbackground=[('readonly', 'black')], foreground=[('readonly', '#ffffff')], selectbackground=[('readonly', 'black')], selectforeground=[('readonly', '#ffffff')])
301
1469
  style.configure('Custom.TButton', background='black', foreground='white', bordercolor='white', focusthickness=3, focuscolor='white', font=('Helvetica', 12))
302
1470
  style.map('Custom.TButton', background=[('active', 'teal'), ('!active', 'black')], foreground=[('active', 'white'), ('!active', 'white')], bordercolor=[('active', 'white'), ('!active', 'white')])
303
1471
  style.configure('Custom.TLabel', padding='5 5 5 5', borderwidth=1, relief='flat', background='black', foreground='#ffffff', font=font_style)