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.
- shinestacker/__init__.py +3 -0
- shinestacker/_version.py +1 -0
- shinestacker/algorithms/__init__.py +14 -0
- shinestacker/algorithms/align.py +307 -0
- shinestacker/algorithms/balance.py +367 -0
- shinestacker/algorithms/core_utils.py +22 -0
- shinestacker/algorithms/depth_map.py +164 -0
- shinestacker/algorithms/exif.py +238 -0
- shinestacker/algorithms/multilayer.py +187 -0
- shinestacker/algorithms/noise_detection.py +182 -0
- shinestacker/algorithms/pyramid.py +176 -0
- shinestacker/algorithms/stack.py +112 -0
- shinestacker/algorithms/stack_framework.py +248 -0
- shinestacker/algorithms/utils.py +71 -0
- shinestacker/algorithms/vignetting.py +137 -0
- shinestacker/app/__init__.py +0 -0
- shinestacker/app/about_dialog.py +24 -0
- shinestacker/app/app_config.py +39 -0
- shinestacker/app/gui_utils.py +35 -0
- shinestacker/app/help_menu.py +16 -0
- shinestacker/app/main.py +176 -0
- shinestacker/app/open_frames.py +39 -0
- shinestacker/app/project.py +91 -0
- shinestacker/app/retouch.py +82 -0
- shinestacker/config/__init__.py +4 -0
- shinestacker/config/config.py +53 -0
- shinestacker/config/constants.py +174 -0
- shinestacker/config/gui_constants.py +85 -0
- shinestacker/core/__init__.py +5 -0
- shinestacker/core/colors.py +60 -0
- shinestacker/core/core_utils.py +52 -0
- shinestacker/core/exceptions.py +50 -0
- shinestacker/core/framework.py +210 -0
- shinestacker/core/logging.py +89 -0
- shinestacker/gui/__init__.py +0 -0
- shinestacker/gui/action_config.py +879 -0
- shinestacker/gui/actions_window.py +283 -0
- shinestacker/gui/colors.py +57 -0
- shinestacker/gui/gui_images.py +152 -0
- shinestacker/gui/gui_logging.py +213 -0
- shinestacker/gui/gui_run.py +393 -0
- shinestacker/gui/img/close-round-line-icon.png +0 -0
- shinestacker/gui/img/forward-button-icon.png +0 -0
- shinestacker/gui/img/play-button-round-icon.png +0 -0
- shinestacker/gui/img/plus-round-line-icon.png +0 -0
- shinestacker/gui/main_window.py +599 -0
- shinestacker/gui/new_project.py +170 -0
- shinestacker/gui/project_converter.py +148 -0
- shinestacker/gui/project_editor.py +539 -0
- shinestacker/gui/project_model.py +138 -0
- shinestacker/retouch/__init__.py +0 -0
- shinestacker/retouch/brush.py +9 -0
- shinestacker/retouch/brush_controller.py +57 -0
- shinestacker/retouch/brush_preview.py +126 -0
- shinestacker/retouch/exif_data.py +65 -0
- shinestacker/retouch/file_loader.py +104 -0
- shinestacker/retouch/image_editor.py +651 -0
- shinestacker/retouch/image_editor_ui.py +380 -0
- shinestacker/retouch/image_viewer.py +356 -0
- shinestacker/retouch/shortcuts_help.py +98 -0
- shinestacker/retouch/undo_manager.py +38 -0
- shinestacker-0.2.0.post1.dev1.dist-info/METADATA +55 -0
- shinestacker-0.2.0.post1.dev1.dist-info/RECORD +67 -0
- shinestacker-0.2.0.post1.dev1.dist-info/WHEEL +5 -0
- shinestacker-0.2.0.post1.dev1.dist-info/entry_points.txt +4 -0
- shinestacker-0.2.0.post1.dev1.dist-info/licenses/LICENSE +1 -0
- 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()
|