shinestacker 0.2.0.post1.dev1__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.

Potentially problematic release.


This version of shinestacker might be problematic. Click here for more details.

Files changed (67) hide show
  1. shinestacker/__init__.py +3 -0
  2. shinestacker/_version.py +1 -0
  3. shinestacker/algorithms/__init__.py +14 -0
  4. shinestacker/algorithms/align.py +307 -0
  5. shinestacker/algorithms/balance.py +367 -0
  6. shinestacker/algorithms/core_utils.py +22 -0
  7. shinestacker/algorithms/depth_map.py +164 -0
  8. shinestacker/algorithms/exif.py +238 -0
  9. shinestacker/algorithms/multilayer.py +187 -0
  10. shinestacker/algorithms/noise_detection.py +182 -0
  11. shinestacker/algorithms/pyramid.py +176 -0
  12. shinestacker/algorithms/stack.py +112 -0
  13. shinestacker/algorithms/stack_framework.py +248 -0
  14. shinestacker/algorithms/utils.py +71 -0
  15. shinestacker/algorithms/vignetting.py +137 -0
  16. shinestacker/app/__init__.py +0 -0
  17. shinestacker/app/about_dialog.py +24 -0
  18. shinestacker/app/app_config.py +39 -0
  19. shinestacker/app/gui_utils.py +35 -0
  20. shinestacker/app/help_menu.py +16 -0
  21. shinestacker/app/main.py +176 -0
  22. shinestacker/app/open_frames.py +39 -0
  23. shinestacker/app/project.py +91 -0
  24. shinestacker/app/retouch.py +82 -0
  25. shinestacker/config/__init__.py +4 -0
  26. shinestacker/config/config.py +53 -0
  27. shinestacker/config/constants.py +174 -0
  28. shinestacker/config/gui_constants.py +85 -0
  29. shinestacker/core/__init__.py +5 -0
  30. shinestacker/core/colors.py +60 -0
  31. shinestacker/core/core_utils.py +52 -0
  32. shinestacker/core/exceptions.py +50 -0
  33. shinestacker/core/framework.py +210 -0
  34. shinestacker/core/logging.py +89 -0
  35. shinestacker/gui/__init__.py +0 -0
  36. shinestacker/gui/action_config.py +879 -0
  37. shinestacker/gui/actions_window.py +283 -0
  38. shinestacker/gui/colors.py +57 -0
  39. shinestacker/gui/gui_images.py +152 -0
  40. shinestacker/gui/gui_logging.py +213 -0
  41. shinestacker/gui/gui_run.py +393 -0
  42. shinestacker/gui/img/close-round-line-icon.png +0 -0
  43. shinestacker/gui/img/forward-button-icon.png +0 -0
  44. shinestacker/gui/img/play-button-round-icon.png +0 -0
  45. shinestacker/gui/img/plus-round-line-icon.png +0 -0
  46. shinestacker/gui/main_window.py +599 -0
  47. shinestacker/gui/new_project.py +170 -0
  48. shinestacker/gui/project_converter.py +148 -0
  49. shinestacker/gui/project_editor.py +539 -0
  50. shinestacker/gui/project_model.py +138 -0
  51. shinestacker/retouch/__init__.py +0 -0
  52. shinestacker/retouch/brush.py +9 -0
  53. shinestacker/retouch/brush_controller.py +57 -0
  54. shinestacker/retouch/brush_preview.py +126 -0
  55. shinestacker/retouch/exif_data.py +65 -0
  56. shinestacker/retouch/file_loader.py +104 -0
  57. shinestacker/retouch/image_editor.py +651 -0
  58. shinestacker/retouch/image_editor_ui.py +380 -0
  59. shinestacker/retouch/image_viewer.py +356 -0
  60. shinestacker/retouch/shortcuts_help.py +98 -0
  61. shinestacker/retouch/undo_manager.py +38 -0
  62. shinestacker-0.2.0.post1.dev1.dist-info/METADATA +55 -0
  63. shinestacker-0.2.0.post1.dev1.dist-info/RECORD +67 -0
  64. shinestacker-0.2.0.post1.dev1.dist-info/WHEEL +5 -0
  65. shinestacker-0.2.0.post1.dev1.dist-info/entry_points.txt +4 -0
  66. shinestacker-0.2.0.post1.dev1.dist-info/licenses/LICENSE +1 -0
  67. shinestacker-0.2.0.post1.dev1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,651 @@
1
+ import traceback
2
+ import numpy as np
3
+ import cv2
4
+ from PySide6.QtWidgets import (QMainWindow, QFileDialog, QMessageBox, QAbstractItemView,
5
+ QVBoxLayout, QLabel, QDialog, QApplication)
6
+ from PySide6.QtGui import QPixmap, QPainter, QColor, QImage, QPen, QBrush, QRadialGradient, QGuiApplication, QCursor
7
+ from PySide6.QtCore import Qt, QTimer, QEvent, QPoint
8
+ from .. config.constants import constants
9
+ from .. config.gui_constants import gui_constants
10
+ from .. core.exceptions import ShapeError, BitDepthError
11
+ from .. algorithms.exif import get_exif, write_image_with_exif_data
12
+ from .. algorithms.multilayer import write_multilayer_tiff_from_images
13
+ from .. algorithms.utils import read_img, validate_image, get_img_metadata
14
+ from .brush import Brush
15
+ from .brush_controller import BrushController
16
+ from .undo_manager import UndoManager
17
+ from .file_loader import FileLoader
18
+ from .exif_data import ExifData
19
+
20
+
21
+ def slider_to_brush_size(slider_val):
22
+ normalized = slider_val / gui_constants.BRUSH_SIZE_SLIDER_MAX
23
+ size = gui_constants.BRUSH_SIZES['min'] + gui_constants.BRUSH_SIZES['max'] * (normalized ** gui_constants.BRUSH_GAMMA)
24
+ return max(gui_constants.BRUSH_SIZES['min'], min(gui_constants.BRUSH_SIZES['max'], size))
25
+
26
+
27
+ def create_brush_gradient(center_x, center_y, radius, hardness, inner_color=None, outer_color=None, opacity=100):
28
+ gradient = QRadialGradient(center_x, center_y, float(radius))
29
+ inner = inner_color if inner_color is not None else QColor(*gui_constants.BRUSH_COLORS['inner'])
30
+ outer = outer_color if outer_color is not None else QColor(*gui_constants.BRUSH_COLORS['gradient_end'])
31
+ inner_with_opacity = QColor(inner)
32
+ inner_with_opacity.setAlpha(int(float(inner.alpha()) * float(opacity) / 100.0))
33
+ if hardness < 100:
34
+ hardness_normalized = float(hardness) / 100.0
35
+ gradient.setColorAt(0.0, inner_with_opacity)
36
+ gradient.setColorAt(hardness_normalized, inner_with_opacity)
37
+ gradient.setColorAt(1.0, outer)
38
+ else:
39
+ gradient.setColorAt(0.0, inner_with_opacity)
40
+ gradient.setColorAt(1.0, inner_with_opacity)
41
+ return gradient
42
+
43
+
44
+ class ImageEditor(QMainWindow):
45
+ def __init__(self):
46
+ super().__init__()
47
+ self.current_stack = None
48
+ self.master_layer = None
49
+ self.current_labels = None
50
+ self.current_layer = 0
51
+ self.shape = None
52
+ self.dtype = None
53
+ self._brush_mask_cache = {}
54
+ self.view_mode = 'master'
55
+ self.temp_view_individual = False
56
+ self.current_file_path = ''
57
+ self.exif_path = ''
58
+ self.exif_data = None
59
+ self.modified = False
60
+ self.installEventFilter(self)
61
+ self.update_timer = QTimer(self)
62
+ self.update_timer.setInterval(gui_constants.PAINT_REFRESH_TIMER)
63
+ self.update_timer.timeout.connect(self.process_pending_updates)
64
+ self.needs_update = False
65
+ self.brush = Brush()
66
+ self.brush_controller = BrushController(self.brush)
67
+ self.undo_manager = UndoManager()
68
+ self.loader_thread = None
69
+
70
+ def keyPressEvent(self, event):
71
+ if self.image_viewer.empty:
72
+ return
73
+ elif event.text() == '[':
74
+ self.decrease_brush_size()
75
+ return
76
+ elif event.text() == ']':
77
+ self.increase_brush_size()
78
+ return
79
+ elif event.text() == '{':
80
+ self.decrease_brush_hardness()
81
+ return
82
+ elif event.text() == '}':
83
+ self.increase_brush_hardness()
84
+ return
85
+ super().keyPressEvent(event)
86
+
87
+ def process_pending_updates(self):
88
+ if self.needs_update:
89
+ self.display_master_layer()
90
+ self.needs_update = False
91
+
92
+ def eventFilter(self, obj, event):
93
+ if event.type() == QEvent.KeyPress and event.key() == Qt.Key_X:
94
+ self.start_temp_view()
95
+ return True
96
+ elif event.type() == QEvent.KeyRelease and event.key() == Qt.Key_X:
97
+ self.end_temp_view()
98
+ return True
99
+ return super().eventFilter(obj, event)
100
+
101
+ def _check_unsaved_changes(self) -> bool:
102
+ if self.modified:
103
+ reply = QMessageBox.question(
104
+ self, "Unsaved Changes",
105
+ "The image stack has unsaved changes. Do you want to continue?",
106
+ QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel
107
+ )
108
+ if reply == QMessageBox.Save:
109
+ self.save_file()
110
+ return True
111
+ elif reply == QMessageBox.Discard:
112
+ return True
113
+ else:
114
+ return False
115
+ else:
116
+ return True
117
+
118
+ def sort_layers(self, order):
119
+ if not hasattr(self, 'current_stack') or not hasattr(self, 'current_labels'):
120
+ return
121
+ master_index = -1
122
+ master_label = None
123
+ master_layer = None
124
+ for i, label in enumerate(self.current_labels):
125
+ if label.lower() == "master":
126
+ master_index = i
127
+ master_label = self.current_labels.pop(i)
128
+ master_layer = self.current_stack[i]
129
+ self.current_stack = np.delete(self.current_stack, i, axis=0)
130
+ break
131
+ if order == 'asc':
132
+ self.sorted_indices = sorted(range(len(self.current_labels)),
133
+ key=lambda i: self.current_labels[i].lower())
134
+ elif order == 'desc':
135
+ self.sorted_indices = sorted(range(len(self.current_labels)),
136
+ key=lambda i: self.current_labels[i].lower(),
137
+ reverse=True)
138
+ else:
139
+ raise ValueError(f"Invalid sorting order: {order}")
140
+ self.current_labels = [self.current_labels[i] for i in self.sorted_indices]
141
+ self.current_stack = self.current_stack[self.sorted_indices]
142
+ if master_index != -1:
143
+ self.current_labels.insert(0, master_label)
144
+ self.current_stack = np.insert(self.current_stack, 0, master_layer, axis=0)
145
+ self.master_layer = master_layer.copy()
146
+ self.master_layer.setflags(write=True)
147
+ self.update_thumbnails()
148
+ if self.current_layer >= len(self.current_stack):
149
+ self.current_layer = len(self.current_stack) - 1
150
+ self.change_layer(self.current_layer)
151
+
152
+ def update_title(self):
153
+ title = constants.APP_TITLE
154
+ if self.current_file_path:
155
+ title += f" - {self.current_file_path.split('/')[-1]}"
156
+ if self.modified:
157
+ title += " *"
158
+ self.window().setWindowTitle(title)
159
+
160
+ def open_file(self, file_paths=None):
161
+ if file_paths is None:
162
+ file_paths, _ = QFileDialog.getOpenFileNames(
163
+ self, "Open Image", "", "Images (*.tif *.tiff *.jpg *.jpeg);;All Files (*)")
164
+ if not file_paths:
165
+ return
166
+ if self.loader_thread and self.loader_thread.isRunning():
167
+ if not self.loader_thread.wait(10000):
168
+ raise RuntimeError("Loading timeout error.")
169
+ if isinstance(file_paths, list) and len(file_paths) > 1:
170
+ self.import_frames_from_files(file_paths)
171
+ return
172
+ path = file_paths[0] if isinstance(file_paths, list) else file_paths
173
+ self.current_file_path = path
174
+ QGuiApplication.setOverrideCursor(QCursor(Qt.BusyCursor))
175
+ self.loading_dialog = QDialog(self)
176
+ self.loading_dialog.setWindowTitle("Loading")
177
+ self.loading_dialog.setWindowFlags(Qt.Window | Qt.FramelessWindowHint)
178
+ self.loading_dialog.setModal(True)
179
+ layout = QVBoxLayout()
180
+ layout.addWidget(QLabel("File loading..."))
181
+ self.loading_dialog.setLayout(layout)
182
+ self.loading_timer = QTimer()
183
+ self.loading_timer.setSingleShot(True)
184
+ self.loading_timer.timeout.connect(self.loading_dialog.show)
185
+ self.loading_timer.start(100)
186
+ self.loader_thread = FileLoader(path)
187
+ self.loader_thread.finished.connect(self.on_file_loaded)
188
+ self.loader_thread.error.connect(self.on_file_error)
189
+ self.loader_thread.start()
190
+
191
+ def on_file_loaded(self, stack, labels, master_layer):
192
+ QApplication.restoreOverrideCursor()
193
+ self.loading_timer.stop()
194
+ self.loading_dialog.hide()
195
+ self.current_stack = stack
196
+ if labels is None:
197
+ self.current_labels = [f'Layer {i:03d}' for i in range(len(stack))]
198
+ else:
199
+ self.current_labels = labels
200
+ self.master_layer = master_layer
201
+ self.shape = np.array(master_layer).shape
202
+ self.dtype = master_layer.dtype
203
+ self.modified = False
204
+ self.blank_layer = np.zeros(master_layer.shape[:2])
205
+ self.update_thumbnails()
206
+ self.image_viewer.setup_brush_cursor()
207
+ self.change_layer(0)
208
+ self.image_viewer.reset_zoom()
209
+ self.statusBar().showMessage(f"Loaded: {self.current_file_path}")
210
+ self.thumbnail_list.setFocus()
211
+ self.update_title()
212
+
213
+ def on_file_error(self, error_msg):
214
+ QApplication.restoreOverrideCursor()
215
+ self.loading_timer.stop()
216
+ self.loading_dialog.accept()
217
+ self.loading_dialog.deleteLater()
218
+ QMessageBox.critical(self, "Error", error_msg)
219
+ self.statusBar().showMessage(f"Error loading: {self.current_file_path}")
220
+
221
+ def mark_as_modified(self):
222
+ self.modified = True
223
+ self.update_title()
224
+
225
+ def import_frames(self):
226
+ file_paths, _ = QFileDialog.getOpenFileNames(self, "Select frames", "",
227
+ "Images Images (*.tif *.tiff *.jpg *.jpeg);;All Files (*)")
228
+ if file_paths:
229
+ self.import_frames_from_files(file_paths)
230
+
231
+ def import_frames_from_files(self, file_paths):
232
+ if file_paths is None or len(file_paths) == 0:
233
+ return
234
+ if self.current_stack is None and len(file_paths) > 0:
235
+ path = file_paths[0]
236
+ img = cv2.cvtColor(read_img(path), cv2.COLOR_BGR2RGB)
237
+ self.current_stack = np.array([img])
238
+ self.shape, self.dtype = get_img_metadata(img)
239
+ label = path.split("/")[-1].split(".")[0]
240
+ self.current_labels = [label]
241
+ if self.master_layer is None:
242
+ self.master_layer = img.copy()
243
+ self.blank_layer = np.zeros(self.master_layer.shape[:2])
244
+ next_paths = file_paths[1:]
245
+ else:
246
+ next_paths = file_paths
247
+ for path in next_paths:
248
+ try:
249
+ label = path.split("/")[-1].split(".")[0]
250
+ img = cv2.cvtColor(read_img(path), cv2.COLOR_BGR2RGB)
251
+ try:
252
+ validate_image(img, self.shape, self.dtype)
253
+ except ShapeError as e:
254
+ msg = QMessageBox()
255
+ msg.setIcon(QMessageBox.Critical)
256
+ msg.setWindowTitle("Import error")
257
+ msg.setText(f"All files must have the same shape.\n{str(e)}")
258
+ msg.exec()
259
+ return
260
+ except BitDepthError as e:
261
+ msg = QMessageBox()
262
+ msg.setIcon(QMessageBox.Critical)
263
+ msg.setWindowTitle("Import error")
264
+ msg.setText(f"All flies must have the same bit depth.\n{str(e)}")
265
+ msg.exec()
266
+ return
267
+ except Exception as e:
268
+ traceback.print_tb(e.__traceback__)
269
+ raise e
270
+ return
271
+ label_x = label
272
+ i = 0
273
+ while label_x in self.current_labels:
274
+ i += 1
275
+ label_x = f"{label} ({i})"
276
+ self.current_labels.append(label_x)
277
+ self.current_stack = np.append(self.current_stack, [img], axis=0)
278
+ except Exception as e:
279
+ traceback.print_tb(e.__traceback__)
280
+ msg = QMessageBox()
281
+ msg.setIcon(QMessageBox.Critical)
282
+ msg.setWindowTitle("Import error")
283
+ msg.setText(f"Error loading file: {path}.\n{str(e)}")
284
+ msg.exec()
285
+ self.statusBar().showMessage(f"Error loading file: {path}")
286
+ break
287
+ self.mark_as_modified()
288
+ self.change_layer(0)
289
+ self.image_viewer.reset_zoom()
290
+ self.thumbnail_list.setFocus()
291
+
292
+ self.update_thumbnails()
293
+
294
+ def save_file(self):
295
+ if self.save_master_only.isChecked():
296
+ self.save_master()
297
+ else:
298
+ self.save_multilayer()
299
+
300
+ def save_file_as(self):
301
+ if self.save_master_only.isChecked():
302
+ self.save_master_as()
303
+ else:
304
+ self.save_multilayer_as()
305
+
306
+ def save_multilayer(self):
307
+ if self.current_stack is None:
308
+ return
309
+ if self.current_file_path != '':
310
+ extension = self.current_file_path.split('.')[-1]
311
+ if extension in ['tif', 'tiff']:
312
+ self.save_multilayer_to_path(self.current_file_path)
313
+ return
314
+ self.save_multilayer_file_as()
315
+
316
+ def save_multilayer_as(self):
317
+ if self.current_stack is None:
318
+ return
319
+ path, _ = QFileDialog.getSaveFileName(self, "Save Image", "",
320
+ "TIFF Files (*.tif *.tiff);;All Files (*)")
321
+ if path:
322
+ if not path.lower().endswith(('.tif', '.tiff')):
323
+ path += '.tif'
324
+ self.save_multilayer_to_path(path)
325
+
326
+ def save_multilayer_to_path(self, path):
327
+ try:
328
+ master_layer = {'Master': self.master_layer}
329
+ individual_layers = {label: image for label, image in zip(self.current_labels, self.current_stack)}
330
+ write_multilayer_tiff_from_images({**master_layer, **individual_layers}, path, exif_path=self.exif_path)
331
+ self.current_file_path = path
332
+ self.modified = False
333
+ self.update_title()
334
+ self.statusBar().showMessage(f"Saved multilayer to: {path}")
335
+ except Exception as e:
336
+ traceback.print_tb(e.__traceback__)
337
+ QMessageBox.critical(self, "Save Error", f"Could not save file: {str(e)}")
338
+
339
+ def save_master(self):
340
+ if self.master_layer is None:
341
+ return
342
+ if self.current_file_path != '':
343
+ self.save_master_to_path(self.current_file_path)
344
+ return
345
+ self.save_master_as()
346
+
347
+ def save_master_as(self):
348
+ if self.current_stack is None:
349
+ return
350
+ path, _ = QFileDialog.getSaveFileName(self, "Save Image", "",
351
+ "TIFF Files (*.tif *.tiff);;JPEG Files (*.jpg *.jpeg);;All Files (*)")
352
+ if path:
353
+ self.save_master_to_path(path)
354
+
355
+ def save_master_to_path(self, path):
356
+ try:
357
+ write_image_with_exif_data(self.exif_data, cv2.cvtColor(self.master_layer, cv2.COLOR_RGB2BGR), path)
358
+ self.current_file_path = path
359
+ self.modified = False
360
+ self.update_title()
361
+ self.statusBar().showMessage(f"Saved master layer to: {path}")
362
+ except Exception as e:
363
+ traceback.print_tb(e.__traceback__)
364
+ QMessageBox.critical(self, "Save Error", f"Could not save file: {str(e)}")
365
+
366
+ def select_exif_path(self):
367
+ if self.current_stack is None:
368
+ return
369
+ path, _ = QFileDialog.getOpenFileName(None, "Select file with exif data")
370
+ if path:
371
+ self.exif_path = path
372
+ self.exif_data = get_exif(path)
373
+ self.statusBar().showMessage(f"EXIF data extracted from {path}.")
374
+ self._exif_dialog = ExifData(self.exif_data, self)
375
+ self._exif_dialog.exec()
376
+
377
+ def close_file(self):
378
+ if self._check_unsaved_changes():
379
+ self.master_layer = None
380
+ self.blank_layer = None
381
+ self.current_stack = None
382
+ self.master_layer = None
383
+ self.current_layer = 0
384
+ self.current_file_path = ''
385
+ self.modified = False
386
+ self.image_viewer.clear_image()
387
+ self.update_thumbnails()
388
+ self.update_title()
389
+
390
+ def set_view_master(self):
391
+ self.view_mode = 'master'
392
+ self.temp_view_individual = False
393
+ self.display_master_layer()
394
+ self.statusBar().showMessage("View mode: Master")
395
+
396
+ def set_view_individual(self):
397
+ self.view_mode = 'individual'
398
+ self.temp_view_individual = False
399
+ self.display_current_layer()
400
+ self.statusBar().showMessage("View mode: Individual layers")
401
+
402
+ def start_temp_view(self):
403
+ if not self.temp_view_individual and self.view_mode == 'master':
404
+ self.temp_view_individual = True
405
+ self.image_viewer.update_brush_cursor()
406
+ self.display_current_layer()
407
+ self.statusBar().showMessage("Temporary view: Individual layer (hold X)")
408
+
409
+ def end_temp_view(self):
410
+ if self.temp_view_individual:
411
+ self.temp_view_individual = False
412
+ self.image_viewer.update_brush_cursor()
413
+ self.display_master_layer()
414
+ self.statusBar().showMessage("View mode: Master")
415
+
416
+ def display_current_view(self):
417
+ if self.temp_view_individual or self.view_mode == 'individual':
418
+ self.display_current_layer()
419
+ else:
420
+ self.display_master_layer()
421
+
422
+ def display_master_layer(self):
423
+ if self.master_layer is None:
424
+ self.image_viewer.clear_image()
425
+ else:
426
+ qimage = self.numpy_to_qimage(self.master_layer)
427
+ self.image_viewer.set_image(qimage)
428
+
429
+ def create_thumbnail(self, layer, size):
430
+ if layer.dtype == np.uint16:
431
+ layer = (layer // 256).astype(np.uint8)
432
+ height, width = layer.shape[:2]
433
+ if layer.ndim == 3 and layer.shape[-1] == 3:
434
+ qimg = QImage(layer.data, width, height, 3 * width, QImage.Format_RGB888)
435
+ else:
436
+ qimg = QImage(layer.data, width, height, width, QImage.Format_Grayscale8)
437
+ return QPixmap.fromImage(qimg.scaled(*gui_constants.UI_SIZES['thumbnail'], Qt.KeepAspectRatio))
438
+
439
+ def update_master_thumbnail(self):
440
+ if self.master_layer is None:
441
+ self.master_thumbnail_label.clear()
442
+ else:
443
+ thumb_size = gui_constants.UI_SIZES['thumbnail']
444
+ master_thumb = self.create_thumbnail(self.master_layer, thumb_size)
445
+ self.master_thumbnail_label.setPixmap(master_thumb)
446
+
447
+ def update_thumbnails(self):
448
+ self.update_master_thumbnail()
449
+ self.thumbnail_list.clear()
450
+ thumb_size = gui_constants.UI_SIZES['thumbnail']
451
+ if self.current_stack is None:
452
+ return
453
+ for i, (layer, label) in enumerate(zip(self.current_stack, self.current_labels)):
454
+ thumbnail = self.create_thumbnail(layer, thumb_size)
455
+ self._add_thumbnail_item(thumbnail, label, i, i == self.current_layer)
456
+
457
+ def _add_thumbnail_item(self, thumbnail, label, i, is_current):
458
+ pass
459
+
460
+ def change_layer(self, layer_idx):
461
+ if 0 <= layer_idx < len(self.current_stack):
462
+ view_state = self.image_viewer.get_view_state()
463
+ self.current_layer = layer_idx
464
+ self.display_current_view()
465
+ self.image_viewer.set_view_state(view_state)
466
+ self.thumbnail_list.setCurrentRow(layer_idx)
467
+ self.thumbnail_list.setFocus()
468
+ self.image_viewer.update_brush_cursor()
469
+ self.image_viewer.setFocus()
470
+
471
+ def change_layer_item(self, item):
472
+ layer_idx = self.thumbnail_list.row(item)
473
+ self.change_layer(layer_idx)
474
+
475
+ def display_current_layer(self):
476
+ if self.current_stack is None:
477
+ return
478
+ layer = self.current_stack[self.current_layer]
479
+ qimage = self.numpy_to_qimage(layer)
480
+ self.image_viewer.set_image(qimage)
481
+
482
+ def numpy_to_qimage(self, array):
483
+ if array.dtype == np.uint16:
484
+ array = np.right_shift(array, 8).astype(np.uint8)
485
+
486
+ if array.ndim == 2:
487
+ height, width = array.shape
488
+ return QImage(memoryview(array), width, height, width, QImage.Format_Grayscale8)
489
+ elif array.ndim == 3:
490
+ height, width, _ = array.shape
491
+ if not array.flags['C_CONTIGUOUS']:
492
+ array = np.ascontiguousarray(array)
493
+ return QImage(memoryview(array), width, height, 3 * width, QImage.Format_RGB888)
494
+ return QImage()
495
+
496
+ def prev_layer(self):
497
+ if self.current_stack is not None:
498
+ new_idx = max(0, self.current_layer - 1)
499
+ if new_idx != self.current_layer:
500
+ self.change_layer(new_idx)
501
+ self.highlight_thumbnail(new_idx)
502
+
503
+ def next_layer(self):
504
+ if self.current_stack is not None:
505
+ new_idx = min(len(self.current_stack) - 1, self.current_layer + 1)
506
+ if new_idx != self.current_layer:
507
+ self.change_layer(new_idx)
508
+ self.highlight_thumbnail(new_idx)
509
+
510
+ def highlight_thumbnail(self, index):
511
+ self.thumbnail_list.setCurrentRow(index)
512
+ self.thumbnail_list.scrollToItem(self.thumbnail_list.item(index),
513
+ QAbstractItemView.PositionAtCenter)
514
+
515
+ def update_brush_size(self, slider_val):
516
+ self.brush.size = slider_to_brush_size(slider_val)
517
+ self.update_brush_thumb()
518
+ self.image_viewer.update_brush_cursor()
519
+ self.clear_brush_cache()
520
+
521
+ def increase_brush_size(self, amount=5):
522
+ val = self.brush_size_slider.value()
523
+ self.brush_size_slider.setValue(min(val + amount, self.brush_size_slider.maximum()))
524
+ self.update_brush_size(val)
525
+
526
+ def decrease_brush_size(self, amount=5):
527
+ val = self.brush_size_slider.value()
528
+ self.brush_size_slider.setValue(max(val - amount, self.brush_size_slider.minimum()))
529
+ self.update_brush_size(val)
530
+
531
+ def increase_brush_hardness(self, amount=2):
532
+ val = self.hardness_slider.value()
533
+ self.hardness_slider.setValue(min(val + amount, self.hardness_slider.maximum()))
534
+ self.update_brush_hardness(val)
535
+
536
+ def decrease_brush_hardness(self, amount=2):
537
+ val = self.hardness_slider.value()
538
+ self.hardness_slider.setValue(max(val - amount, self.hardness_slider.minimum()))
539
+ self.update_brush_hardness(val)
540
+
541
+ def update_brush_hardness(self, hardness):
542
+ self.brush.hardness = hardness
543
+ self.update_brush_thumb()
544
+ self.image_viewer.update_brush_cursor()
545
+ self.clear_brush_cache()
546
+
547
+ def update_brush_opacity(self, opacity):
548
+ self.brush.opacity = opacity
549
+ self.update_brush_thumb()
550
+ self.image_viewer.update_brush_cursor()
551
+
552
+ def update_brush_flow(self, flow):
553
+ self.brush.flow = flow
554
+ self.update_brush_thumb()
555
+ self.image_viewer.update_brush_cursor()
556
+
557
+ def update_brush_thumb(self):
558
+ width, height = gui_constants.UI_SIZES['brush_preview']
559
+ pixmap = QPixmap(width, height)
560
+ pixmap.fill(Qt.transparent)
561
+ painter = QPainter(pixmap)
562
+ painter.setRenderHint(QPainter.Antialiasing)
563
+ preview_size = min(self.brush.size, width + 30, height + 30)
564
+ center_x, center_y = width // 2, height // 2
565
+ radius = preview_size // 2
566
+ if self.image_viewer.cursor_style == 'preview':
567
+ gradient = create_brush_gradient(
568
+ center_x, center_y, radius,
569
+ self.brush.hardness,
570
+ inner_color=QColor(*gui_constants.BRUSH_COLORS['inner']),
571
+ outer_color=QColor(*gui_constants.BRUSH_COLORS['gradient_end']),
572
+ opacity=self.brush.opacity
573
+ )
574
+ painter.setBrush(QBrush(gradient))
575
+ painter.setPen(QPen(QColor(*gui_constants.BRUSH_COLORS['outer']), gui_constants.BRUSH_PREVIEW_LINE_WIDTH))
576
+ elif self.image_viewer.cursor_style == 'outline':
577
+ painter.setBrush(Qt.NoBrush)
578
+ painter.setPen(QPen(QColor(*gui_constants.BRUSH_COLORS['outer']), gui_constants.BRUSH_PREVIEW_LINE_WIDTH))
579
+ else:
580
+ painter.setBrush(QBrush(QColor(*gui_constants.BRUSH_COLORS['cursor_inner'])))
581
+ painter.setPen(QPen(QColor(*gui_constants.BRUSH_COLORS['pen']), gui_constants.BRUSH_PREVIEW_LINE_WIDTH))
582
+ painter.drawEllipse(QPoint(center_x, center_y), radius, radius)
583
+ if self.image_viewer.cursor_style == 'preview':
584
+ painter.setPen(QPen(QColor(0, 0, 160)))
585
+ painter.drawText(0, 10, f"Size: {int(self.brush.size)}px")
586
+ painter.drawText(0, 25, f"Hardness: {self.brush.hardness}%")
587
+ painter.drawText(0, 40, f"Opacity: {self.brush.opacity}%")
588
+ painter.drawText(0, 55, f"Flow: {self.brush.flow}%")
589
+ painter.end()
590
+ self.brush_preview.setPixmap(pixmap)
591
+
592
+ def clear_brush_cache(self):
593
+ self._brush_mask_cache.clear()
594
+
595
+ def allow_cursor_preview(self):
596
+ return self.view_mode == 'master' and not self.temp_view_individual
597
+
598
+ def copy_layer_to_master(self):
599
+ if self.current_stack is None or self.master_layer is None:
600
+ return
601
+ reply = QMessageBox.question(
602
+ self,
603
+ "Confirm Copy",
604
+ "Warning: the current master layer will be erased\n\nDo you want to continue?",
605
+ QMessageBox.Yes | QMessageBox.No,
606
+ QMessageBox.No
607
+ )
608
+ if reply == QMessageBox.Yes:
609
+ self.master_layer = self.current_stack[self.current_layer].copy()
610
+ self.master_layer.setflags(write=True)
611
+ self.display_current_view()
612
+ self.update_thumbnails()
613
+ self.mark_as_modified()
614
+ self.statusBar().showMessage(f"Copied layer {self.current_layer + 1} to master")
615
+
616
+ def copy_brush_area_to_master(self, view_pos):
617
+ if self.current_layer is None or self.current_stack is None or len(self.current_stack) == 0 \
618
+ or self.view_mode != 'master' or self.temp_view_individual:
619
+ return
620
+ area = self.brush_controller.apply_brush_operation(self.master_layer_copy,
621
+ self.current_stack[self.current_layer],
622
+ self.master_layer, self.mask_layer,
623
+ view_pos, self.image_viewer)
624
+ self.undo_manager.extend_undo_area(*area)
625
+
626
+ def begin_copy_brush_area(self, pos):
627
+ if self.view_mode == 'master' and not self.temp_view_individual:
628
+ self.mask_layer = self.blank_layer.copy()
629
+ self.master_layer_copy = self.master_layer.copy()
630
+ self.undo_manager.reset_undo_area()
631
+ self.copy_brush_area_to_master(pos)
632
+ self.needs_update = True
633
+ if not self.update_timer.isActive():
634
+ self.update_timer.start()
635
+ self.mark_as_modified()
636
+
637
+ def continue_copy_brush_area(self, pos):
638
+ if self.view_mode == 'master' and not self.temp_view_individual:
639
+ self.copy_brush_area_to_master(pos)
640
+ self.needs_update = True
641
+ if not self.update_timer.isActive():
642
+ self.update_timer.start()
643
+ self.mark_as_modified()
644
+
645
+ def end_copy_brush_area(self):
646
+ if self.update_timer.isActive():
647
+ self.display_master_layer()
648
+ self.update_master_thumbnail()
649
+ self.undo_manager.save_undo_state(self.master_layer_copy)
650
+ self.update_timer.stop()
651
+ self.mark_as_modified()