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/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ from spacr.version import version, version_str
2
+ import logging
3
+
4
+ from . import core
5
+ from . import io
6
+ from . import utils
7
+ from . import plot
8
+ from . import measure
9
+ from . import sim
10
+ from . import timelapse
11
+ from . import train
12
+ from . import mask_app
13
+ from . import annotate_app
14
+ from . import gui_utils
15
+ from . import gui_mask_app
16
+ from . import gui_measure_app
17
+ from . import logger
18
+
19
+ __all__ = [
20
+ "core",
21
+ "io",
22
+ "utils",
23
+ "plot",
24
+ "measure",
25
+ "sim",
26
+ "timelapse",
27
+ "train",
28
+ "annotate_app",
29
+ "gui_utils",
30
+ "mask_app",
31
+ "gui_mask_app",
32
+ "gui_measure_app",
33
+ "logger"
34
+ ]
35
+
36
+ logging.basicConfig(filename='spacr.log', level=logging.INFO,
37
+ format='%(asctime)s:%(levelname)s:%(message)s')
spacr/__main__.py ADDED
@@ -0,0 +1,15 @@
1
+ """
2
+ Copyright © 2024 Something
3
+ """
4
+
5
+ import sys, os, glob, pathlib, time
6
+ import numpy as np
7
+ from natsort import natsorted
8
+ from tqdm import tqdm
9
+ #from spacr import utils, io, version, timelapse, plot, core, mask_app, annotate_app
10
+ import logging
11
+
12
+
13
+
14
+ if __name__ == "__main__":
15
+ main()
spacr/annotate_app.py ADDED
@@ -0,0 +1,495 @@
1
+ from queue import Queue
2
+ from tkinter import Label
3
+ import tkinter as tk
4
+ import os, threading, time, sqlite3
5
+ import numpy as np
6
+ from PIL import Image, ImageOps
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from PIL import ImageTk
9
+ from IPython.display import display, HTML
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_default_font, set_dark_style, create_dark_mode
17
+
18
+ class ImageApp:
19
+ """
20
+ A class representing an image application.
21
+
22
+ Attributes:
23
+ - root (tkinter.Tk): The root window of the application.
24
+ - db_path (str): The path to the SQLite database.
25
+ - index (int): The index of the current page of images.
26
+ - grid_rows (int): The number of rows in the image grid.
27
+ - grid_cols (int): The number of columns in the image grid.
28
+ - image_size (tuple): The size of the displayed images.
29
+ - annotation_column (str): The column name for image annotations in the database.
30
+ - image_type (str): The type of images to display.
31
+ - channels (list): The channels to filter in the images.
32
+ - images (dict): A dictionary mapping labels to loaded images.
33
+ - pending_updates (dict): A dictionary of pending image annotation updates.
34
+ - labels (list): A list of label widgets for displaying images.
35
+ - terminate (bool): A flag indicating whether the application should terminate.
36
+ - update_queue (Queue): A queue for storing image annotation updates.
37
+ - status_label (tkinter.Label): A label widget for displaying status messages.
38
+ - db_update_thread (threading.Thread): A thread for updating the database.
39
+ """
40
+
41
+ def _init_(self, root, db_path, image_type=None, channels=None, grid_rows=None, grid_cols=None, image_size=(200, 200), annotation_column='annotate'):
42
+ """
43
+ Initializes an instance of the ImageApp class.
44
+
45
+ Parameters:
46
+ - root (tkinter.Tk): The root window of the application.
47
+ - db_path (str): The path to the SQLite database.
48
+ - image_type (str): The type of images to display.
49
+ - channels (list): The channels to filter in the images.
50
+ - grid_rows (int): The number of rows in the image grid.
51
+ - grid_cols (int): The number of columns in the image grid.
52
+ - image_size (tuple): The size of the displayed images.
53
+ - annotation_column (str): The column name for image annotations in the database.
54
+ """
55
+
56
+ self.root = root
57
+ self.db_path = db_path
58
+ self.index = 0
59
+ self.grid_rows = grid_rows
60
+ self.grid_cols = grid_cols
61
+ self.image_size = image_size
62
+ self.annotation_column = annotation_column
63
+ self.image_type = image_type
64
+ self.channels = channels
65
+ self.images = {}
66
+ self.pending_updates = {}
67
+ self.labels = []
68
+ #self.updating_db = True
69
+ self.terminate = False
70
+ self.update_queue = Queue()
71
+ self.status_label = Label(self.root, text="", font=("Arial", 12))
72
+ self.status_label.grid(row=self.grid_rows + 1, column=0, columnspan=self.grid_cols)
73
+
74
+ self.db_update_thread = threading.Thread(target=self.update_database_worker)
75
+ self.db_update_thread.start()
76
+
77
+ for i in range(grid_rows * grid_cols):
78
+ label = Label(root)
79
+ label.grid(row=i // grid_cols, column=i % grid_cols)
80
+ self.labels.append(label)
81
+
82
+ @staticmethod
83
+ def normalize_image(img):
84
+ """
85
+ Normalize the pixel values of an image to the range [0, 255].
86
+
87
+ Parameters:
88
+ - img: PIL.Image.Image
89
+ The input image to be normalized.
90
+
91
+ Returns:
92
+ - PIL.Image.Image
93
+ The normalized image.
94
+ """
95
+ img_array = np.array(img)
96
+ img_array = ((img_array - img_array.min()) * (1/(img_array.max() - img_array.min()) * 255)).astype('uint8')
97
+ return Image.fromarray(img_array)
98
+
99
+ def add_colored_border(self, img, border_width, border_color):
100
+ """
101
+ Adds a colored border to an image.
102
+
103
+ Args:
104
+ img (PIL.Image.Image): The input image.
105
+ border_width (int): The width of the border in pixels.
106
+ border_color (str): The color of the border in RGB format.
107
+
108
+ Returns:
109
+ PIL.Image.Image: The image with the colored border.
110
+ """
111
+ top_border = Image.new('RGB', (img.width, border_width), color=border_color)
112
+ bottom_border = Image.new('RGB', (img.width, border_width), color=border_color)
113
+ left_border = Image.new('RGB', (border_width, img.height), color=border_color)
114
+ right_border = Image.new('RGB', (border_width, img.height), color=border_color)
115
+
116
+ bordered_img = Image.new('RGB', (img.width + 2 * border_width, img.height + 2 * border_width), color='white')
117
+ bordered_img.paste(top_border, (border_width, 0))
118
+ bordered_img.paste(bottom_border, (border_width, img.height + border_width))
119
+ bordered_img.paste(left_border, (0, border_width))
120
+ bordered_img.paste(right_border, (img.width + border_width, border_width))
121
+ bordered_img.paste(img, (border_width, border_width))
122
+
123
+ return bordered_img
124
+
125
+ def filter_channels(self, img):
126
+ """
127
+ Filters the channels of an image based on the specified channels.
128
+
129
+ Args:
130
+ img (PIL.Image.Image): The input image.
131
+
132
+ Returns:
133
+ PIL.Image.Image: The filtered image.
134
+
135
+ """
136
+ r, g, b = img.split()
137
+ if self.channels:
138
+ if 'r' not in self.channels:
139
+ r = r.point(lambda _: 0)
140
+ if 'g' not in self.channels:
141
+ g = g.point(lambda _: 0)
142
+ if 'b' not in self.channels:
143
+ b = b.point(lambda _: 0)
144
+
145
+ if len(self.channels) == 1:
146
+ channel_img = r if 'r' in self.channels else (g if 'g' in self.channels else b)
147
+ return ImageOps.grayscale(channel_img)
148
+
149
+ return Image.merge("RGB", (r, g, b))
150
+
151
+ def load_images(self):
152
+ """
153
+ Loads and displays images with annotations.
154
+
155
+ This method retrieves image paths and annotations from a SQLite database,
156
+ loads the images using a ThreadPoolExecutor for parallel processing,
157
+ adds colored borders to images based on their annotations,
158
+ and displays the images in the corresponding labels.
159
+
160
+ Args:
161
+ None
162
+
163
+ Returns:
164
+ None
165
+ """
166
+ for label in self.labels:
167
+ label.config(image='')
168
+
169
+ self.images = {}
170
+
171
+ conn = sqlite3.connect(self.db_path)
172
+ c = conn.cursor()
173
+ if self.image_type:
174
+ c.execute(f"SELECT png_path, {self.annotation_column} FROM png_list WHERE png_path LIKE ? LIMIT ?, ?", (f"%{self.image_type}%", self.index, self.grid_rows * self.grid_cols))
175
+ else:
176
+ c.execute(f"SELECT png_path, {self.annotation_column} FROM png_list LIMIT ?, ?", (self.index, self.grid_rows * self.grid_cols))
177
+
178
+ paths = c.fetchall()
179
+ conn.close()
180
+
181
+ with ThreadPoolExecutor() as executor:
182
+ loaded_images = list(executor.map(self.load_single_image, paths))
183
+
184
+ for i, (img, annotation) in enumerate(loaded_images):
185
+ if annotation:
186
+ border_color = 'teal' if annotation == 1 else 'red'
187
+ img = self.add_colored_border(img, border_width=5, border_color=border_color)
188
+
189
+ photo = ImageTk.PhotoImage(img)
190
+ label = self.labels[i]
191
+ self.images[label] = photo
192
+ label.config(image=photo)
193
+
194
+ path = paths[i][0]
195
+ label.bind('<Button-1>', self.get_on_image_click(path, label, img))
196
+ label.bind('<Button-3>', self.get_on_image_click(path, label, img))
197
+
198
+ self.root.update()
199
+
200
+ def load_single_image(self, path_annotation_tuple):
201
+ """
202
+ Loads a single image from the given path and annotation tuple.
203
+
204
+ Args:
205
+ path_annotation_tuple (tuple): A tuple containing the image path and its annotation.
206
+
207
+ Returns:
208
+ img (PIL.Image.Image): The loaded image.
209
+ annotation: The annotation associated with the image.
210
+ """
211
+ path, annotation = path_annotation_tuple
212
+ img = Image.open(path)
213
+ if img.mode == "I":
214
+ img = self.normalize_image(img)
215
+ img = img.convert('RGB')
216
+ img = self.filter_channels(img)
217
+ img = img.resize(self.image_size)
218
+ return img, annotation
219
+
220
+ def get_on_image_click(self, path, label, img):
221
+ """
222
+ Returns a callback function that handles the click event on an image.
223
+
224
+ Parameters:
225
+ path (str): The path of the image file.
226
+ label (tkinter.Label): The label widget to update with the annotated image.
227
+ img (PIL.Image.Image): The image object.
228
+
229
+ Returns:
230
+ function: The callback function for the image click event.
231
+ """
232
+ def on_image_click(event):
233
+
234
+ new_annotation = 1 if event.num == 1 else (2 if event.num == 3 else None)
235
+
236
+ if path in self.pending_updates and self.pending_updates[path] == new_annotation:
237
+ self.pending_updates[path] = None
238
+ new_annotation = None
239
+ else:
240
+ self.pending_updates[path] = new_annotation
241
+
242
+ print(f"Image {os.path.split(path)[1]} annotated: {new_annotation}")
243
+
244
+ img_ = img.crop((5, 5, img.width-5, img.height-5))
245
+ border_fill = 'teal' if new_annotation == 1 else ('red' if new_annotation == 2 else None)
246
+ img_ = ImageOps.expand(img_, border=5, fill=border_fill) if border_fill else img_
247
+
248
+ photo = ImageTk.PhotoImage(img_)
249
+ self.images[label] = photo
250
+ label.config(image=photo)
251
+ self.root.update()
252
+
253
+ return on_image_click
254
+
255
+ @staticmethod
256
+ def update_html(text):
257
+ display(HTML(f"""
258
+ <script>
259
+ document.getElementById('unique_id').innerHTML = '{text}';
260
+ </script>
261
+ """))
262
+
263
+ def update_database_worker(self):
264
+ """
265
+ Worker function that continuously updates the database with pending updates from the update queue.
266
+ It retrieves the pending updates from the queue, updates the corresponding records in the database,
267
+ and resets the text in the HTML and status label.
268
+ """
269
+ conn = sqlite3.connect(self.db_path)
270
+ c = conn.cursor()
271
+
272
+ display(HTML("<div id='unique_id'>Initial Text</div>"))
273
+
274
+ while True:
275
+ if self.terminate:
276
+ conn.close()
277
+ break
278
+
279
+ if not self.update_queue.empty():
280
+ ImageApp.update_html("Do not exit, Updating database...")
281
+ self.status_label.config(text='Do not exit, Updating database...')
282
+
283
+ pending_updates = self.update_queue.get()
284
+ for path, new_annotation in pending_updates.items():
285
+ if new_annotation is None:
286
+ c.execute(f'UPDATE png_list SET {self.annotation_column} = NULL WHERE png_path = ?', (path,))
287
+ else:
288
+ c.execute(f'UPDATE png_list SET {self.annotation_column} = ? WHERE png_path = ?', (new_annotation, path))
289
+ conn.commit()
290
+
291
+ # Reset the text
292
+ ImageApp.update_html('')
293
+ self.status_label.config(text='')
294
+ self.root.update()
295
+ time.sleep(0.1)
296
+
297
+ def update_gui_text(self, text):
298
+ """
299
+ Update the text of the status label in the GUI.
300
+
301
+ Args:
302
+ text (str): The new text to be displayed in the status label.
303
+
304
+ Returns:
305
+ None
306
+ """
307
+ self.status_label.config(text=text)
308
+ self.root.update()
309
+
310
+ def next_page(self):
311
+ """
312
+ Moves to the next page of images in the grid.
313
+
314
+ If there are pending updates in the dictionary, they are added to the update queue.
315
+ The pending updates dictionary is then cleared.
316
+ The index is incremented by the number of rows multiplied by the number of columns in the grid.
317
+ Finally, the images are loaded for the new page.
318
+ """
319
+ if self.pending_updates: # Check if the dictionary is not empty
320
+ self.update_queue.put(self.pending_updates.copy())
321
+ self.pending_updates.clear()
322
+ self.index += self.grid_rows * self.grid_cols
323
+ self.load_images()
324
+
325
+ def previous_page(self):
326
+ """
327
+ Move to the previous page in the grid.
328
+
329
+ If there are pending updates in the dictionary, they are added to the update queue.
330
+ The dictionary of pending updates is then cleared.
331
+ The index is decremented by the number of rows multiplied by the number of columns in the grid.
332
+ If the index becomes negative, it is set to 0.
333
+ Finally, the images are loaded for the new page.
334
+ """
335
+ if self.pending_updates: # Check if the dictionary is not empty
336
+ self.update_queue.put(self.pending_updates.copy())
337
+ self.pending_updates.clear()
338
+ self.index -= self.grid_rows * self.grid_cols
339
+ if self.index < 0:
340
+ self.index = 0
341
+ self.load_images()
342
+
343
+ def shutdown(self):
344
+ """
345
+ Shuts down the application.
346
+
347
+ This method sets the `terminate` flag to True, clears the pending updates,
348
+ updates the database, and quits the application.
349
+
350
+ """
351
+ self.terminate = True # Set terminate first
352
+ self.update_queue.put(self.pending_updates.copy())
353
+ self.pending_updates.clear()
354
+ self.db_update_thread.join() # Join the thread to make sure database is updated
355
+ self.root.quit()
356
+ self.root.destroy()
357
+ print(f'Quit application')
358
+
359
+ def annotate(db, image_type=None, channels=None, geom="1000x1100", img_size=(200, 200), rows=5, columns=5, annotation_column='annotate'):
360
+ """
361
+ Annotates images in a database using a graphical user interface.
362
+
363
+ Args:
364
+ db (str): The path to the SQLite database.
365
+ image_type (str, optional): The type of images to load from the database. Defaults to None.
366
+ channels (str, optional): The channels of the images to load from the database. Defaults to None.
367
+ geom (str, optional): The geometry of the GUI window. Defaults to "1000x1100".
368
+ img_size (tuple, optional): The size of the images to display in the GUI. Defaults to (200, 200).
369
+ rows (int, optional): The number of rows in the image grid. Defaults to 5.
370
+ columns (int, optional): The number of columns in the image grid. Defaults to 5.
371
+ annotation_column (str, optional): The name of the annotation column in the database table. Defaults to 'annotate'.
372
+ """
373
+ #display(HTML("<div id='unique_id'>Initial Text</div>"))
374
+ conn = sqlite3.connect(db)
375
+ c = conn.cursor()
376
+ c.execute('PRAGMA table_info(png_list)')
377
+ cols = c.fetchall()
378
+ if annotation_column not in [col[1] for col in cols]:
379
+ c.execute(f'ALTER TABLE png_list ADD COLUMN {annotation_column} integer')
380
+ conn.commit()
381
+ conn.close()
382
+
383
+ root = tk.Tk()
384
+ root.geometry(geom)
385
+ app = ImageApp(root, db, image_type=image_type, channels=channels, image_size=img_size, grid_rows=rows, grid_cols=columns, annotation_column=annotation_column)
386
+
387
+ next_button = tk.Button(root, text="Next", command=app.next_page)
388
+ next_button.grid(row=app.grid_rows, column=app.grid_cols - 1)
389
+ back_button = tk.Button(root, text="Back", command=app.previous_page)
390
+ back_button.grid(row=app.grid_rows, column=app.grid_cols - 2)
391
+ exit_button = tk.Button(root, text="Exit", command=app.shutdown)
392
+ exit_button.grid(row=app.grid_rows, column=app.grid_cols - 3)
393
+
394
+ app.load_images()
395
+ root.mainloop()
396
+
397
+ def check_for_duplicates(db):
398
+ """
399
+ Check for duplicates in the given SQLite database.
400
+
401
+ Args:
402
+ db (str): The path to the SQLite database.
403
+
404
+ Returns:
405
+ None
406
+ """
407
+ db_path = db
408
+ conn = sqlite3.connect(db_path)
409
+ c = conn.cursor()
410
+ c.execute('SELECT file_name, COUNT(file_name) FROM png_list GROUP BY file_name HAVING COUNT(file_name) > 1')
411
+ duplicates = c.fetchall()
412
+ for duplicate in duplicates:
413
+ file_name = duplicate[0]
414
+ count = duplicate[1]
415
+ c.execute('SELECT rowid FROM png_list WHERE file_name = ?', (file_name,))
416
+ rowids = c.fetchall()
417
+ for rowid in rowids[:-1]:
418
+ c.execute('DELETE FROM png_list WHERE rowid = ?', (rowid[0],))
419
+ conn.commit()
420
+ conn.close()
421
+
422
+ @log_function_call
423
+ def initiate_annotation_app_root(width, height):
424
+ theme = 'breeze'
425
+ root = ThemedTk(theme=theme)
426
+ style = ttk.Style(root)
427
+ set_dark_style(style)
428
+ set_default_font(root, font_name="Arial", size=10)
429
+ root.geometry(f"{width}x{height}")
430
+ root.title("Annotation App")
431
+
432
+ container = tk.PanedWindow(root, orient=tk.HORIZONTAL)
433
+ container.pack(fill=tk.BOTH, expand=True)
434
+
435
+ scrollable_frame = ScrollableFrame(container, bg='#333333')
436
+ container.add(scrollable_frame, stretch="always")
437
+
438
+ # Setup input fields
439
+ vars_dict = {
440
+ 'db': ttk.Entry(scrollable_frame.scrollable_frame),
441
+ 'image_type': ttk.Entry(scrollable_frame.scrollable_frame),
442
+ 'channels': ttk.Entry(scrollable_frame.scrollable_frame),
443
+ 'annotation_column': ttk.Entry(scrollable_frame.scrollable_frame),
444
+ 'geom': ttk.Entry(scrollable_frame.scrollable_frame),
445
+ 'img_size': ttk.Entry(scrollable_frame.scrollable_frame),
446
+ 'rows': ttk.Entry(scrollable_frame.scrollable_frame),
447
+ 'columns': ttk.Entry(scrollable_frame.scrollable_frame)
448
+ }
449
+
450
+ # Arrange input fields and labels
451
+ row = 0
452
+ for name, entry in vars_dict.items():
453
+ ttk.Label(scrollable_frame.scrollable_frame, text=f"{name.replace('_', ' ').capitalize()}:").grid(row=row, column=0)
454
+ entry.grid(row=row, column=1)
455
+ row += 1
456
+
457
+ # Function to be called when "Run" button is clicked
458
+ def run_app():
459
+ db = vars_dict['db'].get()
460
+ image_type = vars_dict['image_type'].get()
461
+ channels = vars_dict['channels'].get()
462
+ annotation_column = vars_dict['annotation_column'].get()
463
+ geom = vars_dict['geom'].get()
464
+ img_size_str = vars_dict['img_size'].get().split(',') # Splitting the string by comma
465
+ img_size = (int(img_size_str[0]), int(img_size_str[1])) # Converting each part to an integer
466
+ rows = int(vars_dict['rows'].get())
467
+ columns = int(vars_dict['columns'].get())
468
+
469
+ # Destroy the initial settings window
470
+ root.destroy()
471
+
472
+ # Create a new root window for the application
473
+ new_root = tk.Tk()
474
+ new_root.geometry(f"{width}x{height}")
475
+ new_root.title("Mask Application")
476
+
477
+ # Start the annotation application in the new root window
478
+ app_instance = annotate(db, image_type, channels, annotation_column, geom, img_size, rows, columns)
479
+
480
+ new_root.mainloop()
481
+
482
+ create_dark_mode(root, style, console_output=None)
483
+
484
+ run_button = ttk.Button(scrollable_frame.scrollable_frame, text="Run", command=run_app)
485
+ run_button.grid(row=row, column=0, columnspan=2, pady=10)
486
+
487
+ return root
488
+
489
+ def gui_annotation():
490
+ root = initiate_annotation_app_root(500, 350)
491
+ root.mainloop()
492
+
493
+ if __name__ == "__main__":
494
+ gui_annotation()
495
+