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.
- shinestacker/_version.py +1 -1
- shinestacker/algorithms/denoise.py +9 -0
- shinestacker/algorithms/sharpen.py +22 -0
- shinestacker/algorithms/stack.py +2 -2
- shinestacker/algorithms/utils.py +4 -0
- shinestacker/algorithms/white_balance.py +13 -0
- shinestacker/gui/new_project.py +1 -0
- shinestacker/retouch/brush_controller.py +4 -4
- shinestacker/retouch/brush_gradient.py +20 -0
- shinestacker/retouch/brush_preview.py +11 -14
- shinestacker/retouch/image_editor.py +114 -202
- shinestacker/retouch/image_editor_ui.py +42 -13
- shinestacker/retouch/image_filters.py +391 -0
- shinestacker/retouch/image_viewer.py +13 -21
- shinestacker/retouch/io_manager.py +57 -0
- shinestacker/retouch/layer_collection.py +54 -0
- shinestacker/retouch/undo_manager.py +49 -11
- {shinestacker-0.2.2.dist-info → shinestacker-0.3.1.dist-info}/METADATA +27 -3
- {shinestacker-0.2.2.dist-info → shinestacker-0.3.1.dist-info}/RECORD +23 -16
- {shinestacker-0.2.2.dist-info → shinestacker-0.3.1.dist-info}/WHEEL +0 -0
- {shinestacker-0.2.2.dist-info → shinestacker-0.3.1.dist-info}/entry_points.txt +0 -0
- {shinestacker-0.2.2.dist-info → shinestacker-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {shinestacker-0.2.2.dist-info → shinestacker-0.3.1.dist-info}/top_level.txt +0 -0
|
@@ -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 .
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
229
|
-
undo_action.
|
|
230
|
-
undo_action.
|
|
231
|
-
|
|
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.
|
|
389
|
+
self.layer_collection.layer_labels[i] = new_label
|
|
369
390
|
|
|
370
|
-
def
|
|
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
|
|
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
|
-
|
|
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
|