shinestacker 0.2.2__py3-none-any.whl → 0.3.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.

Potentially problematic release.


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

@@ -3,7 +3,7 @@ from PySide6.QtGui import QShortcut, QKeySequence, QAction, QActionGroup
3
3
  from PySide6.QtCore import Qt, QSize, Signal
4
4
  from PySide6.QtGui import QGuiApplication
5
5
  from .. config.gui_constants import gui_constants
6
- from .image_editor import ImageEditor
6
+ from .image_filters import ImageFilters
7
7
  from .image_viewer import ImageViewer
8
8
  from .shortcuts_help import ShortcutsHelp
9
9
 
@@ -18,21 +18,19 @@ def brush_size_to_slider(size):
18
18
 
19
19
 
20
20
  class ClickableLabel(QLabel):
21
- """QLabel personalizzata che emette un segnale quando viene doppio-clickata"""
22
- doubleClicked = Signal() # PySide6 usa Signal invece di pyqtSignal
21
+ doubleClicked = Signal()
23
22
 
24
23
  def __init__(self, text, parent=None):
25
24
  super().__init__(text, parent)
26
25
  self.setMouseTracking(True)
27
26
 
28
27
  def mouseDoubleClickEvent(self, event):
29
- """Override del doppio click"""
30
28
  if event.button() == Qt.LeftButton:
31
29
  self.doubleClicked.emit()
32
30
  super().mouseDoubleClickEvent(event)
33
31
 
34
32
 
35
- class ImageEditorUI(ImageEditor):
33
+ class ImageEditorUI(ImageFilters):
36
34
  def __init__(self):
37
35
  super().__init__()
38
36
  self.setup_ui()
@@ -174,7 +172,12 @@ class ImageEditorUI(ImageEditor):
174
172
  self.thumbnail_list.setFixedWidth(gui_constants.THUMB_WIDTH)
175
173
  self.thumbnail_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
176
174
  self.thumbnail_list.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
177
- self.thumbnail_list.itemClicked.connect(self.change_layer_item)
175
+
176
+ def change_layer_item(item):
177
+ layer_idx = self.thumbnail_list.row(item)
178
+ self.change_layer(layer_idx)
179
+
180
+ self.thumbnail_list.itemClicked.connect(change_layer_item)
178
181
  self.thumbnail_list.setStyleSheet("""
179
182
  QListWidget {
180
183
  background-color: #f5f5f5;
@@ -225,10 +228,16 @@ class ImageEditorUI(ImageEditor):
225
228
  file_menu.addAction("Import &EXIF data", self.select_exif_path)
226
229
 
227
230
  edit_menu = menubar.addMenu("&Edit")
228
- undo_action = QAction("Undo Brush", self)
229
- undo_action.setShortcut("Ctrl+Z")
230
- undo_action.triggered.connect(self.undo_last_brush)
231
- edit_menu.addAction(undo_action)
231
+ self.undo_action = QAction("Undo", self)
232
+ self.undo_action.setEnabled(False)
233
+ self.undo_action.setShortcut("Ctrl+Z")
234
+ self.undo_action.triggered.connect(self.undo)
235
+ edit_menu.addAction(self.undo_action)
236
+ self.redo_action = QAction("Redo", self)
237
+ self.redo_action.setEnabled(False)
238
+ self.redo_action.setShortcut("Ctrl+Y")
239
+ self.redo_action.triggered.connect(self.redo)
240
+ edit_menu.addAction(self.redo_action)
232
241
  edit_menu.addSeparator()
233
242
 
234
243
  copy_action = QAction("Copy Layer to Master", self)
@@ -314,6 +323,18 @@ class ImageEditorUI(ImageEditor):
314
323
  cursor_group.addAction(brush_action)
315
324
  cursor_group.setExclusive(True)
316
325
 
326
+ filter_menu = menubar.addMenu("&Filter")
327
+ filter_menu.setObjectName("Filter")
328
+ denoise_action = QAction("Denoise", self)
329
+ denoise_action.triggered.connect(self.denoise)
330
+ filter_menu.addAction(denoise_action)
331
+ unsharp_mask_action = QAction("Unsharp Mask", self)
332
+ unsharp_mask_action.triggered.connect(self.unsharp_mask)
333
+ filter_menu.addAction(unsharp_mask_action)
334
+ white_balance_action = QAction("White Balance", self)
335
+ white_balance_action.triggered.connect(self.white_balance)
336
+ filter_menu.addAction(white_balance_action)
337
+
317
338
  help_menu = menubar.addMenu("&Help")
318
339
  help_menu.setObjectName("Help")
319
340
  shortcuts_help_action = QAction("Shortcuts and mouse", self)
@@ -365,14 +386,22 @@ class ImageEditorUI(ImageEditor):
365
386
  self._update_label_in_data(old_label, new_label, i)
366
387
 
367
388
  def _update_label_in_data(self, old_label, new_label, i):
368
- self.current_labels[i] = new_label
389
+ self.layer_collection.layer_labels[i] = new_label
369
390
 
370
- def undo_last_brush(self):
371
- if self.undo_manager.undo(self.master_layer):
391
+ def undo(self):
392
+ if self.undo_manager.undo(self.layer_collection.master_layer):
372
393
  self.display_current_view()
394
+ self.update_master_thumbnail()
373
395
  self.mark_as_modified()
374
396
  self.statusBar().showMessage("Undo applied", 2000)
375
397
 
398
+ def redo(self):
399
+ if self.undo_manager.redo(self.layer_collection.master_layer):
400
+ self.display_current_view()
401
+ self.update_master_thumbnail()
402
+ self.mark_as_modified()
403
+ self.statusBar().showMessage("Redo applied", 2000)
404
+
376
405
  def handle_temp_view(self, start):
377
406
  if start:
378
407
  self.start_temp_view()
@@ -0,0 +1,391 @@
1
+ import numpy as np
2
+ from PySide6.QtWidgets import (QHBoxLayout,
3
+ QPushButton, QFrame, QVBoxLayout, QLabel, QDialog, QApplication, QSlider,
4
+ QCheckBox, QDialogButtonBox)
5
+ from PySide6.QtGui import QCursor
6
+ from PySide6.QtCore import Qt, QTimer, QThread, Signal
7
+ from .. algorithms.denoise import denoise
8
+ from .. algorithms.sharpen import unsharp_mask
9
+ from .. algorithms.white_balance import white_balance_from_rgb
10
+ from .image_editor import ImageEditor
11
+
12
+
13
+ class ImageFilters(ImageEditor):
14
+ def __init__(self):
15
+ super().__init__()
16
+
17
+ class PreviewWorker(QThread):
18
+ finished = Signal(np.ndarray, int)
19
+
20
+ def __init__(self, func, args=(), kwargs=None, request_id=0):
21
+ super().__init__()
22
+ self.func = func
23
+ self.args = args
24
+ self.kwargs = kwargs or {}
25
+ self.request_id = request_id
26
+
27
+ def run(self):
28
+ try:
29
+ result = self.func(*self.args, **self.kwargs)
30
+ except Exception:
31
+ raise
32
+ self.finished.emit(result, self.request_id)
33
+
34
+ def connect_preview_toggle(self, preview_check, do_preview, restore_original):
35
+ def on_toggled(checked):
36
+ if checked:
37
+ do_preview()
38
+ else:
39
+ restore_original()
40
+ preview_check.toggled.connect(on_toggled)
41
+
42
+ def run_filter_with_preview(self, filter_func, get_params, setup_ui, undo_label):
43
+ if self.layer_collection.master_layer is None:
44
+ return
45
+ self.layer_collection.copy_master_layer()
46
+ dlg = QDialog(self)
47
+ layout = QVBoxLayout(dlg)
48
+ active_worker = None
49
+ last_request_id = 0
50
+
51
+ def set_preview(img, request_id, expected_id):
52
+ if request_id != expected_id:
53
+ return
54
+ self.layer_collection.master_layer = img
55
+ self.display_master_layer()
56
+ try:
57
+ dlg.activateWindow()
58
+ except Exception:
59
+ pass
60
+
61
+ def do_preview():
62
+ nonlocal active_worker, last_request_id
63
+ if active_worker and active_worker.isRunning():
64
+ try:
65
+ active_worker.quit()
66
+ active_worker.wait()
67
+ except Exception:
68
+ pass
69
+ last_request_id += 1
70
+ current_id = last_request_id
71
+ params = tuple(get_params() or ())
72
+ worker = self.PreviewWorker(filter_func, args=(self.layer_collection.master_layer_copy, *params), request_id=current_id)
73
+ active_worker = worker
74
+ active_worker.finished.connect(lambda img, rid: set_preview(img, rid, current_id))
75
+ active_worker.start()
76
+
77
+ def restore_original():
78
+ self.layer_collection.master_layer = self.layer_collection.master_layer_copy.copy()
79
+ self.display_master_layer()
80
+ try:
81
+ dlg.activateWindow()
82
+ except Exception:
83
+ pass
84
+
85
+ setup_ui(dlg, layout, do_preview, restore_original)
86
+ QTimer.singleShot(0, do_preview)
87
+ accepted = dlg.exec_() == QDialog.Accepted
88
+ if accepted:
89
+ params = tuple(get_params() or ())
90
+ try:
91
+ h, w = self.layer_collection.master_layer.shape[:2]
92
+ except Exception:
93
+ h, w = self.layer_collection.master_layer_copy.shape[:2]
94
+ if hasattr(self, "undo_manager"):
95
+ try:
96
+ self.undo_manager.extend_undo_area(0, 0, w, h)
97
+ self.undo_manager.save_undo_state(self.layer_collection.master_layer_copy, undo_label)
98
+ except Exception:
99
+ pass
100
+ final_img = filter_func(self.layer_collection.master_layer_copy, *params)
101
+ self.layer_collection.master_layer = final_img
102
+ self.layer_collection.copy_master_layer()
103
+ self.display_master_layer()
104
+ self.update_master_thumbnail()
105
+ self.mark_as_modified()
106
+ else:
107
+ restore_original()
108
+
109
+ def denoise(self):
110
+ max_range = 500.0
111
+ max_value = 10.00
112
+ initial_value = 2.5
113
+
114
+ def get_params():
115
+ return (max_value * slider.value() / max_range,)
116
+
117
+ def setup_ui(dlg, layout, do_preview, restore_original):
118
+ nonlocal slider
119
+ dlg.setWindowTitle("Denoise")
120
+ dlg.setMinimumWidth(600)
121
+ slider_layout = QHBoxLayout()
122
+ slider_local = QSlider(Qt.Horizontal)
123
+ slider_local.setRange(0, max_range)
124
+ slider_local.setValue(int(initial_value / max_value * max_range))
125
+ slider_layout.addWidget(slider_local)
126
+ value_label = QLabel(f"{max_value:.2f}")
127
+ slider_layout.addWidget(value_label)
128
+ layout.addLayout(slider_layout)
129
+ preview_check = QCheckBox("Preview")
130
+ preview_check.setChecked(True)
131
+ layout.addWidget(preview_check)
132
+ button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
133
+ layout.addWidget(button_box)
134
+ preview_timer = QTimer()
135
+ preview_timer.setSingleShot(True)
136
+ preview_timer.setInterval(200)
137
+
138
+ def do_preview_delayed():
139
+ preview_timer.start()
140
+
141
+ def slider_changed(val):
142
+ float_val = max_value * float(val) / max_range
143
+ value_label.setText(f"{float_val:.2f}")
144
+ if preview_check.isChecked():
145
+ do_preview_delayed()
146
+
147
+ preview_timer.timeout.connect(do_preview)
148
+ slider_local.valueChanged.connect(slider_changed)
149
+ self.connect_preview_toggle(preview_check, do_preview_delayed, restore_original)
150
+ button_box.accepted.connect(dlg.accept)
151
+ button_box.rejected.connect(dlg.reject)
152
+ slider = slider_local
153
+
154
+ slider = None
155
+ self.run_filter_with_preview(denoise, get_params, setup_ui, 'Denoise')
156
+
157
+ def unsharp_mask(self):
158
+ max_range = 500.0
159
+ max_radius = 4.0
160
+ max_amount = 3.0
161
+ max_threshold = 64.0
162
+ initial_radius = 1.0
163
+ initial_amount = 0.5
164
+ initial_threshold = 0.0
165
+
166
+ def get_params():
167
+ return (
168
+ max(0.01, max_radius * radius_slider.value() / max_range),
169
+ max_amount * amount_slider.value() / max_range,
170
+ max_threshold * threshold_slider.value() / max_range
171
+ )
172
+
173
+ def setup_ui(dlg, layout, do_preview, restore_original):
174
+ nonlocal radius_slider, amount_slider, threshold_slider
175
+ dlg.setWindowTitle("Unsharp Mask")
176
+ dlg.setMinimumWidth(600)
177
+ params = {
178
+ "Radius": (max_radius, initial_radius, "{:.2f}"),
179
+ "Amount": (max_amount, initial_amount, "{:.1%}"),
180
+ "Threshold": (max_threshold, initial_threshold, "{:.2f}")
181
+ }
182
+ value_labels = {}
183
+ for name, (max_val, init_val, fmt) in params.items():
184
+ param_layout = QHBoxLayout()
185
+ name_label = QLabel(f"{name}:")
186
+ param_layout.addWidget(name_label)
187
+ slider = QSlider(Qt.Horizontal)
188
+ slider.setRange(0, max_range)
189
+ slider.setValue(int(init_val / max_val * max_range))
190
+ param_layout.addWidget(slider)
191
+ value_label = QLabel(fmt.format(init_val))
192
+ param_layout.addWidget(value_label)
193
+ layout.addLayout(param_layout)
194
+ if name == "Radius":
195
+ radius_slider = slider
196
+ elif name == "Amount":
197
+ amount_slider = slider
198
+ elif name == "Threshold":
199
+ threshold_slider = slider
200
+ value_labels[name] = value_label
201
+ preview_check = QCheckBox("Preview")
202
+ preview_check.setChecked(True)
203
+ layout.addWidget(preview_check)
204
+ button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
205
+ layout.addWidget(button_box)
206
+ preview_timer = QTimer()
207
+ preview_timer.setSingleShot(True)
208
+ preview_timer.setInterval(200)
209
+
210
+ def update_value(name, value, max_val, fmt):
211
+ float_value = max_val * value / max_range
212
+ value_labels[name].setText(fmt.format(float_value))
213
+ if preview_check.isChecked():
214
+ preview_timer.start()
215
+
216
+ radius_slider.valueChanged.connect(
217
+ lambda v: update_value("Radius", v, max_radius, params["Radius"][2]))
218
+ amount_slider.valueChanged.connect(
219
+ lambda v: update_value("Amount", v, max_amount, params["Amount"][2]))
220
+ threshold_slider.valueChanged.connect(
221
+ lambda v: update_value("Threshold", v, max_threshold, params["Threshold"][2]))
222
+ preview_timer.timeout.connect(do_preview)
223
+ self.connect_preview_toggle(preview_check, do_preview, restore_original)
224
+ button_box.accepted.connect(dlg.accept)
225
+ button_box.rejected.connect(dlg.reject)
226
+ QTimer.singleShot(0, do_preview)
227
+
228
+ radius_slider = None
229
+ amount_slider = None
230
+ threshold_slider = None
231
+ self.run_filter_with_preview(unsharp_mask, get_params, setup_ui, 'Unsharp Mask')
232
+
233
+ def white_balance(self, init_val=False):
234
+ max_range = 255
235
+ if init_val is False:
236
+ init_val = (128, 128, 128)
237
+ initial_val = {k: v for k, v in zip(["R", "G", "B"], init_val)}
238
+ cursor_style = self.image_viewer.cursor_style
239
+ self.image_viewer.set_cursor_style('outline')
240
+ if self.image_viewer.brush_cursor:
241
+ self.image_viewer.brush_cursor.hide()
242
+ self.brush_preview.hide()
243
+
244
+ def get_params():
245
+ return tuple(sliders[n].value() for n in ("R", "G", "B"))
246
+
247
+ def setup_ui(dlg, layout, do_preview, restore_original):
248
+ nonlocal sliders, value_labels, color_preview, preview_timer
249
+ self.wb_dialog = dlg
250
+ dlg.setWindowModality(Qt.ApplicationModal)
251
+ dlg.setWindowFlags(dlg.windowFlags() | Qt.WindowStaysOnTopHint)
252
+ dlg.setFocusPolicy(Qt.StrongFocus)
253
+ dlg.setWindowTitle("White Balance")
254
+ dlg.setMinimumWidth(600)
255
+ row_layout = QHBoxLayout()
256
+ color_preview = QFrame()
257
+ color_preview.setFixedHeight(80)
258
+ color_preview.setFixedWidth(80)
259
+ color_preview.setStyleSheet("background-color: rgb(128,128,128);")
260
+ row_layout.addWidget(color_preview)
261
+ sliders_layout = QVBoxLayout()
262
+ sliders = {}
263
+ value_labels = {}
264
+ for name in ("R", "G", "B"):
265
+ row = QHBoxLayout()
266
+ label = QLabel(f"{name}:")
267
+ row.addWidget(label)
268
+ slider = QSlider(Qt.Horizontal)
269
+ slider.setRange(0, max_range)
270
+ init_val = initial_val[name]
271
+ slider.setValue(init_val)
272
+ row.addWidget(slider)
273
+ val_label = QLabel(str(init_val))
274
+ row.addWidget(val_label)
275
+ sliders_layout.addLayout(row)
276
+ sliders[name] = slider
277
+ value_labels[name] = val_label
278
+ row_layout.addLayout(sliders_layout)
279
+ layout.addLayout(row_layout)
280
+ pick_button = QPushButton("Pick Color")
281
+ layout.addWidget(pick_button)
282
+ preview_check = QCheckBox("Preview")
283
+ preview_check.setChecked(True)
284
+ layout.addWidget(preview_check)
285
+ button_box = QDialogButtonBox(
286
+ QDialogButtonBox.Ok | QDialogButtonBox.Reset | QDialogButtonBox.Cancel
287
+ )
288
+ layout.addWidget(button_box)
289
+ preview_timer = QTimer()
290
+ preview_timer.setSingleShot(True)
291
+ preview_timer.setInterval(200)
292
+
293
+ def update_preview_color():
294
+ rgb = tuple(sliders[n].value() for n in ("R", "G", "B"))
295
+ color_preview.setStyleSheet(f"background-color: rgb{rgb};")
296
+
297
+ def schedule_preview():
298
+ if preview_check.isChecked():
299
+ preview_timer.start()
300
+
301
+ def on_slider_change():
302
+ for name in ("R", "G", "B"):
303
+ value_labels[name].setText(str(sliders[name].value()))
304
+ update_preview_color()
305
+ schedule_preview()
306
+
307
+ for slider in sliders.values():
308
+ slider.valueChanged.connect(on_slider_change)
309
+
310
+ preview_timer.timeout.connect(do_preview)
311
+ self.connect_preview_toggle(preview_check, do_preview, restore_original)
312
+
313
+ def start_color_pick():
314
+ restore_original()
315
+ dlg.hide()
316
+ QApplication.setOverrideCursor(QCursor(Qt.CrossCursor))
317
+ self.image_viewer.setCursor(Qt.CrossCursor)
318
+ self._original_mouse_press = self.image_viewer.mousePressEvent
319
+ self.image_viewer.mousePressEvent = pick_color_from_click
320
+
321
+ def pick_color_from_click(event):
322
+ if event.button() == Qt.LeftButton:
323
+ pos = event.pos()
324
+ bgr = self.get_pixel_color_at(pos, radius=int(self.brush.size))
325
+ rgb = (bgr[2], bgr[1], bgr[0])
326
+ self.white_balance(rgb)
327
+
328
+ def reset_rgb():
329
+ for name, slider in sliders.items():
330
+ slider.setValue(initial_val[name])
331
+
332
+ pick_button.clicked.connect(start_color_pick)
333
+ button_box.accepted.connect(dlg.accept)
334
+ button_box.rejected.connect(dlg.reject)
335
+ button_box.button(QDialogButtonBox.Reset).clicked.connect(reset_rgb)
336
+
337
+ def on_finished():
338
+ self.image_viewer.set_cursor_style(cursor_style)
339
+ self.image_viewer.brush_cursor.show()
340
+ self.brush_preview.show()
341
+ if hasattr(self, "_original_mouse_press"):
342
+ QApplication.restoreOverrideCursor()
343
+ self.image_viewer.unsetCursor()
344
+ self.image_viewer.mousePressEvent = self._original_mouse_press
345
+ delattr(self, "_original_mouse_press")
346
+ self.wb_dialog = None
347
+
348
+ dlg.finished.connect(on_finished)
349
+ QTimer.singleShot(0, do_preview)
350
+
351
+ sliders = {}
352
+ value_labels = {}
353
+ color_preview = None
354
+ preview_timer = None
355
+ self.run_filter_with_preview(lambda img, r, g, b: white_balance_from_rgb(img, (r, g, b)),
356
+ get_params, setup_ui, 'White Balance')
357
+
358
+ def get_pixel_color_at(self, pos, radius=None):
359
+ scene_pos = self.image_viewer.mapToScene(pos)
360
+ item_pos = self.image_viewer.pixmap_item.mapFromScene(scene_pos)
361
+ x = int(item_pos.x())
362
+ y = int(item_pos.y())
363
+ if (0 <= x < self.layer_collection.master_layer.shape[1]) and (0 <= y < self.layer_collection.master_layer.shape[0]):
364
+ if radius is None:
365
+ radius = int(self.brush.size)
366
+ if radius > 0:
367
+ y_indices, x_indices = np.ogrid[-radius:radius + 1, -radius:radius + 1]
368
+ mask = x_indices**2 + y_indices**2 <= radius**2
369
+ x0 = max(0, x - radius)
370
+ x1 = min(self.layer_collection.master_layer.shape[1], x + radius + 1)
371
+ y0 = max(0, y - radius)
372
+ y1 = min(self.layer_collection.master_layer.shape[0], y + radius + 1)
373
+ mask = mask[radius - (y - y0): radius + (y1 - y), radius - (x - x0): radius + (x1 - x)]
374
+ region = self.layer_collection.master_layer[y0:y1, x0:x1]
375
+ if region.size == 0:
376
+ pixel = self.layer_collection.master_layer[y, x]
377
+ else:
378
+ if region.ndim == 3:
379
+ pixel = [region[:, :, c][mask].mean() for c in range(region.shape[2])]
380
+ else:
381
+ pixel = region[mask].mean()
382
+ else:
383
+ pixel = self.layer_collection.master_layer[y, x]
384
+ if np.isscalar(pixel):
385
+ pixel = [pixel, pixel, pixel]
386
+ pixel = [np.float32(x) for x in pixel]
387
+ if self.layer_collection.master_layer.dtype == np.uint16:
388
+ pixel = [x / 256.0 for x in pixel]
389
+ return tuple(int(v) for v in pixel)
390
+ else:
391
+ return (0, 0, 0)
@@ -1,26 +1,10 @@
1
1
  import math
2
2
  from PySide6.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsPixmapItem
3
- from PySide6.QtGui import QPixmap, QPainter, QColor, QPen, QBrush, QCursor, QShortcut, QKeySequence, QRadialGradient
4
- from PySide6.QtCore import Qt, QRectF, QTime, QPoint, Signal
3
+ from PySide6.QtGui import QPixmap, QPainter, QColor, QPen, QBrush, QCursor, QShortcut, QKeySequence
4
+ from PySide6.QtCore import Qt, QRectF, QTime, QPoint, QPointF, Signal
5
5
  from .. config.gui_constants import gui_constants
6
6
  from .brush_preview import BrushPreviewItem
7
-
8
-
9
- def create_brush_gradient(center_x, center_y, radius, hardness, inner_color=None, outer_color=None, opacity=100):
10
- gradient = QRadialGradient(center_x, center_y, float(radius))
11
- inner = inner_color if inner_color is not None else QColor(*gui_constants.BRUSH_COLORS['inner'])
12
- outer = outer_color if outer_color is not None else QColor(*gui_constants.BRUSH_COLORS['gradient_end'])
13
- inner_with_opacity = QColor(inner)
14
- inner_with_opacity.setAlpha(int(float(inner.alpha()) * float(opacity) / 100.0))
15
- if hardness < 100:
16
- hardness_normalized = float(hardness) / 100.0
17
- gradient.setColorAt(0.0, inner_with_opacity)
18
- gradient.setColorAt(hardness_normalized, inner_with_opacity)
19
- gradient.setColorAt(1.0, outer)
20
- else:
21
- gradient.setColorAt(0.0, inner_with_opacity)
22
- gradient.setColorAt(1.0, inner_with_opacity)
23
- return gradient
7
+ from .brush_gradient import create_brush_gradient
24
8
 
25
9
 
26
10
  class ImageViewer(QGraphicsView):
@@ -74,6 +58,8 @@ class ImageViewer(QGraphicsView):
74
58
  self.empty = False
75
59
  self.setFocus()
76
60
  self.activateWindow()
61
+ self.brush_preview.layer_collection = self.image_editor.layer_collection
62
+ self.brush_preview.brush = self.brush
77
63
 
78
64
  def clear_image(self):
79
65
  self.scene.clear()
@@ -123,7 +109,7 @@ class ImageViewer(QGraphicsView):
123
109
  def mousePressEvent(self, event):
124
110
  if self.empty:
125
111
  return
126
- if event.button() == Qt.LeftButton and self.image_editor.master_layer is not None:
112
+ if event.button() == Qt.LeftButton and self.image_editor.layer_collection.master_layer is not None:
127
113
  if self.space_pressed:
128
114
  self.scrolling = True
129
115
  self.last_mouse_pos = event.position()
@@ -245,7 +231,13 @@ class ImageViewer(QGraphicsView):
245
231
  if self.cursor_style == 'preview' and allow_cursor_preview:
246
232
  self._setup_outline_style()
247
233
  self.brush_cursor.hide()
248
- self.brush_preview.update(self.image_editor, QCursor.pos(), int(size))
234
+ pos = QCursor.pos()
235
+ if isinstance(pos, QPointF):
236
+ scene_pos = pos
237
+ else:
238
+ cursor_pos = self.image_editor.image_viewer.mapFromGlobal(pos)
239
+ scene_pos = self.image_editor.image_viewer.mapToScene(cursor_pos)
240
+ self.brush_preview.update(scene_pos, int(size))
249
241
  else:
250
242
  self.brush_preview.hide()
251
243
  if self.cursor_style == 'outline' or not allow_cursor_preview:
@@ -0,0 +1,57 @@
1
+ import cv2
2
+ from .. core.exceptions import ShapeError, BitDepthError
3
+ from .. algorithms.utils import read_img, validate_image, get_img_metadata
4
+ from .. algorithms.exif import get_exif, write_image_with_exif_data
5
+ from .. algorithms.multilayer import write_multilayer_tiff_from_images
6
+
7
+
8
+ class IOManager:
9
+ def __init__(self, layer_collection):
10
+ self.layer_collection = layer_collection
11
+ self.current_file_path = ''
12
+ self.exif_path = ''
13
+ self.exif_data = None
14
+
15
+ def import_frames(self, file_paths):
16
+ stack = []
17
+ labels = []
18
+ master = None
19
+ shape, dtype = get_img_metadata(self.layer_collection.master_layer)
20
+ for path in file_paths:
21
+ try:
22
+ label = path.split("/")[-1].split(".")[0]
23
+ img = cv2.cvtColor(read_img(path), cv2.COLOR_BGR2RGB)
24
+ if shape is not None and dtype is not None:
25
+ validate_image(img, shape, dtype)
26
+ else:
27
+ shape, dtype = get_img_metadata(img)
28
+ label_x = label
29
+ i = 0
30
+ while label_x in labels:
31
+ i += 1
32
+ label_x = f"{label} ({i})"
33
+ labels.append(label_x)
34
+ stack.append(img)
35
+ if master is None:
36
+ master = img.copy()
37
+ except ShapeError as e:
38
+ raise ShapeError(f"All files must have the same shape.\n{str(e)}")
39
+ except BitDepthError as e:
40
+ raise BitDepthError(f"All files must have the same bit depth.\n{str(e)}")
41
+ except Exception as e:
42
+ raise RuntimeError(f"Error loading file: {path}.\n{str(e)}")
43
+ return stack, labels, master
44
+
45
+ def save_multilayer(self, path):
46
+ master_layer = {'Master': self.layer_collection.master_layer}
47
+ individual_layers = {label: image for label, image in zip(
48
+ self.layer_collection.layer_labels, self.layer_collection.layer_stack)}
49
+ write_multilayer_tiff_from_images({**master_layer, **individual_layers}, path, exif_path=self.exif_path)
50
+
51
+ def save_master(self, path):
52
+ img = cv2.cvtColor(self.layer_collection.master_layer, cv2.COLOR_RGB2BGR)
53
+ write_image_with_exif_data(self.exif_data, img, path)
54
+
55
+ def set_exif_data(self, path):
56
+ self.exif_path = path
57
+ self.exif_data = get_exif(path)
@@ -0,0 +1,54 @@
1
+ import numpy as np
2
+
3
+
4
+ class LayerCollection:
5
+ def __init__(self):
6
+ self.master_layer = None
7
+ self.master_layer_copy = None
8
+ self.layer_stack = None
9
+ self.layer_labels = None
10
+ self.current_layer_idx = 0
11
+
12
+ def number_of_layers(self):
13
+ return len(self.layer_stack)
14
+
15
+ def valid_current_layer_idx(self):
16
+ return 0 <= self.current_layer_idx < self.number_of_layers()
17
+
18
+ def current_layer(self):
19
+ if self.layer_stack is not None and self.valid_current_layer_idx():
20
+ return self.layer_stack[self.current_layer_idx]
21
+ return None
22
+
23
+ def copy_master_layer(self):
24
+ self.master_layer_copy = self.master_layer.copy()
25
+
26
+ def sort_layers(self, order):
27
+ master_index = -1
28
+ master_label = None
29
+ master_layer = None
30
+ for i, label in enumerate(self.layer_labels):
31
+ if label.lower() == "master":
32
+ master_index = i
33
+ master_label = self.layer_labels.pop(i)
34
+ master_layer = self.layer_stack[i]
35
+ self.layer_stack = np.delete(self.layer_stack, i, axis=0)
36
+ break
37
+ if order == 'asc':
38
+ self.sorted_indices = sorted(range(len(self.layer_labels)),
39
+ key=lambda i: self.layer_labels[i].lower())
40
+ elif order == 'desc':
41
+ self.sorted_indices = sorted(range(len(self.layer_labels)),
42
+ key=lambda i: self.layer_labels[i].lower(),
43
+ reverse=True)
44
+ else:
45
+ raise ValueError(f"Invalid sorting order: {order}")
46
+ self.layer_labels = [self.layer_labels[i] for i in self.sorted_indices]
47
+ self.layer_stack = self.layer_stack[self.sorted_indices]
48
+ if master_index != -1:
49
+ self.layer_labels.insert(0, master_label)
50
+ self.layer_stack = np.insert(self.layer_stack, 0, master_layer, axis=0)
51
+ self.master_layer = master_layer.copy()
52
+ self.master_layer.setflags(write=True)
53
+ if self.current_layer_idx >= self.number_of_layers():
54
+ self.current_layer_idx = self.number_of_layers() - 1