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