shinestacker 0.3.1__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.
- shinestacker/__init__.py +6 -6
- shinestacker/_version.py +1 -1
- shinestacker/algorithms/balance.py +6 -7
- shinestacker/algorithms/noise_detection.py +2 -0
- shinestacker/app/open_frames.py +6 -4
- shinestacker/config/__init__.py +2 -1
- shinestacker/config/config.py +1 -0
- shinestacker/config/constants.py +1 -0
- shinestacker/config/gui_constants.py +1 -0
- shinestacker/core/__init__.py +4 -3
- shinestacker/core/colors.py +1 -0
- shinestacker/core/core_utils.py +6 -6
- shinestacker/core/exceptions.py +1 -0
- shinestacker/core/framework.py +2 -1
- shinestacker/gui/action_config.py +47 -42
- shinestacker/gui/actions_window.py +8 -5
- shinestacker/retouch/brush_preview.py +5 -6
- shinestacker/retouch/brush_tool.py +164 -0
- shinestacker/retouch/denoise_filter.py +56 -0
- shinestacker/retouch/display_manager.py +177 -0
- shinestacker/retouch/exif_data.py +2 -1
- shinestacker/retouch/filter_base.py +114 -0
- shinestacker/retouch/filter_manager.py +14 -0
- shinestacker/retouch/image_editor.py +104 -430
- shinestacker/retouch/image_editor_ui.py +32 -72
- shinestacker/retouch/image_filters.py +25 -349
- shinestacker/retouch/image_viewer.py +22 -14
- shinestacker/retouch/io_gui_handler.py +208 -0
- shinestacker/retouch/io_manager.py +9 -13
- shinestacker/retouch/layer_collection.py +65 -1
- shinestacker/retouch/unsharp_mask_filter.py +84 -0
- shinestacker/retouch/white_balance_filter.py +111 -0
- {shinestacker-0.3.1.dist-info → shinestacker-0.3.2.dist-info}/METADATA +3 -2
- {shinestacker-0.3.1.dist-info → shinestacker-0.3.2.dist-info}/RECORD +38 -31
- shinestacker/retouch/brush_controller.py +0 -57
- {shinestacker-0.3.1.dist-info → shinestacker-0.3.2.dist-info}/WHEEL +0 -0
- {shinestacker-0.3.1.dist-info → shinestacker-0.3.2.dist-info}/entry_points.txt +0 -0
- {shinestacker-0.3.1.dist-info → shinestacker-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {shinestacker-0.3.1.dist-info → shinestacker-0.3.2.dist-info}/top_level.txt +0 -0
|
@@ -9,10 +9,15 @@ from .brush_gradient import create_brush_gradient
|
|
|
9
9
|
|
|
10
10
|
class ImageViewer(QGraphicsView):
|
|
11
11
|
temp_view_requested = Signal(bool)
|
|
12
|
+
brush_operation_started = Signal(QPoint)
|
|
13
|
+
brush_operation_continued = Signal(QPoint)
|
|
14
|
+
brush_operation_ended = Signal()
|
|
15
|
+
brush_size_change_requested = Signal(int) # +1 or -1
|
|
12
16
|
|
|
13
|
-
def __init__(self, parent=None):
|
|
17
|
+
def __init__(self, layer_collection, parent=None):
|
|
14
18
|
super().__init__(parent)
|
|
15
|
-
self.
|
|
19
|
+
self.display_manager = None
|
|
20
|
+
self.layer_collection = layer_collection
|
|
16
21
|
self.brush = None
|
|
17
22
|
self.cursor_style = gui_constants.DEFAULT_CURSOR_STYLE
|
|
18
23
|
self.scene = QGraphicsScene(self)
|
|
@@ -39,8 +44,10 @@ class ImageViewer(QGraphicsView):
|
|
|
39
44
|
self.dragging = False
|
|
40
45
|
self.last_update_time = QTime.currentTime()
|
|
41
46
|
self.brush_preview = BrushPreviewItem()
|
|
47
|
+
self.layer_collection.add_to(self.brush_preview)
|
|
42
48
|
self.scene.addItem(self.brush_preview)
|
|
43
49
|
self.empty = True
|
|
50
|
+
self.allow_cursor_preview = True
|
|
44
51
|
|
|
45
52
|
def set_image(self, qimage):
|
|
46
53
|
pixmap = QPixmap.fromImage(qimage)
|
|
@@ -58,7 +65,6 @@ class ImageViewer(QGraphicsView):
|
|
|
58
65
|
self.empty = False
|
|
59
66
|
self.setFocus()
|
|
60
67
|
self.activateWindow()
|
|
61
|
-
self.brush_preview.layer_collection = self.image_editor.layer_collection
|
|
62
68
|
self.brush_preview.brush = self.brush
|
|
63
69
|
|
|
64
70
|
def clear_image(self):
|
|
@@ -109,7 +115,7 @@ class ImageViewer(QGraphicsView):
|
|
|
109
115
|
def mousePressEvent(self, event):
|
|
110
116
|
if self.empty:
|
|
111
117
|
return
|
|
112
|
-
if event.button() == Qt.LeftButton and self.
|
|
118
|
+
if event.button() == Qt.LeftButton and self.layer_collection.has_master_layer():
|
|
113
119
|
if self.space_pressed:
|
|
114
120
|
self.scrolling = True
|
|
115
121
|
self.last_mouse_pos = event.position()
|
|
@@ -118,7 +124,7 @@ class ImageViewer(QGraphicsView):
|
|
|
118
124
|
self.brush_cursor.hide()
|
|
119
125
|
else:
|
|
120
126
|
self.last_brush_pos = event.position()
|
|
121
|
-
self.
|
|
127
|
+
self.brush_operation_started.emit(event.position().toPoint())
|
|
122
128
|
self.dragging = True
|
|
123
129
|
if self.brush_cursor:
|
|
124
130
|
self.brush_cursor.show()
|
|
@@ -145,7 +151,7 @@ class ImageViewer(QGraphicsView):
|
|
|
145
151
|
for i in range(0, n_steps + 1):
|
|
146
152
|
pos = QPoint(self.last_brush_pos.x() + i * delta_x,
|
|
147
153
|
self.last_brush_pos.y() + i * delta_y)
|
|
148
|
-
self.
|
|
154
|
+
self.brush_operation_continued.emit(pos)
|
|
149
155
|
self.last_brush_pos = position
|
|
150
156
|
self.last_update_time = current_time
|
|
151
157
|
if self.scrolling and event.buttons() & Qt.LeftButton:
|
|
@@ -177,17 +183,14 @@ class ImageViewer(QGraphicsView):
|
|
|
177
183
|
self.last_mouse_pos = None
|
|
178
184
|
elif hasattr(self, 'dragging') and self.dragging:
|
|
179
185
|
self.dragging = False
|
|
180
|
-
self.
|
|
186
|
+
self.brush_operation_ended.emit()
|
|
181
187
|
super().mouseReleaseEvent(event)
|
|
182
188
|
|
|
183
189
|
def wheelEvent(self, event):
|
|
184
190
|
if self.empty:
|
|
185
191
|
return
|
|
186
192
|
if self.control_pressed:
|
|
187
|
-
if event.angleDelta().y() > 0
|
|
188
|
-
self.image_editor.decrease_brush_size()
|
|
189
|
-
else:
|
|
190
|
-
self.image_editor.increase_brush_size()
|
|
193
|
+
self.brush_size_change_requested.emit(1 if event.angleDelta().y() > 0 else -1)
|
|
191
194
|
else:
|
|
192
195
|
zoom_in_factor = 1.10
|
|
193
196
|
zoom_out_factor = 1 / zoom_in_factor
|
|
@@ -227,7 +230,7 @@ class ImageViewer(QGraphicsView):
|
|
|
227
230
|
center_y = scene_pos.y()
|
|
228
231
|
radius = size / 2
|
|
229
232
|
self.brush_cursor.setRect(center_x - radius, center_y - radius, size, size)
|
|
230
|
-
allow_cursor_preview = self.
|
|
233
|
+
allow_cursor_preview = self.display_manager.allow_cursor_preview()
|
|
231
234
|
if self.cursor_style == 'preview' and allow_cursor_preview:
|
|
232
235
|
self._setup_outline_style()
|
|
233
236
|
self.brush_cursor.hide()
|
|
@@ -235,8 +238,8 @@ class ImageViewer(QGraphicsView):
|
|
|
235
238
|
if isinstance(pos, QPointF):
|
|
236
239
|
scene_pos = pos
|
|
237
240
|
else:
|
|
238
|
-
cursor_pos = self.
|
|
239
|
-
scene_pos = self.
|
|
241
|
+
cursor_pos = self.mapFromGlobal(pos)
|
|
242
|
+
scene_pos = self.mapToScene(cursor_pos)
|
|
240
243
|
self.brush_preview.update(scene_pos, int(size))
|
|
241
244
|
else:
|
|
242
245
|
self.brush_preview.hide()
|
|
@@ -346,3 +349,8 @@ class ImageViewer(QGraphicsView):
|
|
|
346
349
|
self.cursor_style = style
|
|
347
350
|
if self.brush_cursor:
|
|
348
351
|
self.update_brush_cursor()
|
|
352
|
+
|
|
353
|
+
def position_on_image(self, pos):
|
|
354
|
+
scene_pos = self.mapToScene(pos)
|
|
355
|
+
item_pos = self.pixmap_item.mapFromScene(scene_pos)
|
|
356
|
+
return item_pos
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import traceback
|
|
2
|
+
import numpy as np
|
|
3
|
+
from PySide6.QtWidgets import QFileDialog, QMessageBox, QVBoxLayout, QLabel, QDialog, QApplication
|
|
4
|
+
from PySide6.QtGui import QGuiApplication, QCursor
|
|
5
|
+
from PySide6.QtCore import Qt, QObject, QTimer, Signal
|
|
6
|
+
from .file_loader import FileLoader
|
|
7
|
+
from .exif_data import ExifData
|
|
8
|
+
from .io_manager import IOManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class IOGuiHandler(QObject):
|
|
12
|
+
status_message_requested = Signal(str)
|
|
13
|
+
update_title_requested = Signal()
|
|
14
|
+
|
|
15
|
+
def __init__(self, layer_collection, undo_manager, parent):
|
|
16
|
+
super().__init__(parent)
|
|
17
|
+
self.io_manager = IOManager(layer_collection)
|
|
18
|
+
self.undo_manager = undo_manager
|
|
19
|
+
layer_collection.add_to(self)
|
|
20
|
+
self.loader_thread = None
|
|
21
|
+
|
|
22
|
+
def setup_ui(self, display_manager, image_viewer):
|
|
23
|
+
self.display_manager = display_manager
|
|
24
|
+
self.image_viewer = image_viewer
|
|
25
|
+
|
|
26
|
+
def on_file_loaded(self, stack, labels, master_layer):
|
|
27
|
+
QApplication.restoreOverrideCursor()
|
|
28
|
+
self.loading_timer.stop()
|
|
29
|
+
self.loading_dialog.hide()
|
|
30
|
+
self.set_layer_stack(stack)
|
|
31
|
+
if labels is None:
|
|
32
|
+
self.set_layer_labels([f'Layer {i:03d}' for i in range(len(stack))])
|
|
33
|
+
else:
|
|
34
|
+
self.set_layer_labels(labels)
|
|
35
|
+
self.set_master_layer(master_layer)
|
|
36
|
+
self.modified = False
|
|
37
|
+
self.undo_manager.reset()
|
|
38
|
+
self.blank_layer = np.zeros(master_layer.shape[:2])
|
|
39
|
+
self.display_manager.update_thumbnails()
|
|
40
|
+
self.image_viewer.setup_brush_cursor()
|
|
41
|
+
self.parent().change_layer(0)
|
|
42
|
+
self.image_viewer.reset_zoom()
|
|
43
|
+
self.status_message_requested.emit(f"Loaded: {self.io_manager.current_file_path}")
|
|
44
|
+
self.parent().thumbnail_list.setFocus()
|
|
45
|
+
self.update_title_requested.emit()
|
|
46
|
+
|
|
47
|
+
def on_file_error(self, error_msg):
|
|
48
|
+
QApplication.restoreOverrideCursor()
|
|
49
|
+
self.loading_timer.stop()
|
|
50
|
+
self.loading_dialog.accept()
|
|
51
|
+
self.loading_dialog.deleteLater()
|
|
52
|
+
QMessageBox.critical(self, "Error", error_msg)
|
|
53
|
+
self.status_message_requested.emit(f"Error loading: {self.io_manager.current_file_path}")
|
|
54
|
+
|
|
55
|
+
def open_file(self, file_paths=None):
|
|
56
|
+
if file_paths is None:
|
|
57
|
+
file_paths, _ = QFileDialog.getOpenFileNames(
|
|
58
|
+
self.parent(), "Open Image", "", "Images (*.tif *.tiff *.jpg *.jpeg);;All Files (*)")
|
|
59
|
+
if not file_paths:
|
|
60
|
+
return
|
|
61
|
+
if self.loader_thread and self.loader_thread.isRunning():
|
|
62
|
+
if not self.loader_thread.wait(10000):
|
|
63
|
+
raise RuntimeError("Loading timeout error.")
|
|
64
|
+
if isinstance(file_paths, list) and len(file_paths) > 1:
|
|
65
|
+
self.import_frames_from_files(file_paths)
|
|
66
|
+
return
|
|
67
|
+
path = file_paths[0] if isinstance(file_paths, list) else file_paths
|
|
68
|
+
self.io_manager.current_file_path = path
|
|
69
|
+
QGuiApplication.setOverrideCursor(QCursor(Qt.BusyCursor))
|
|
70
|
+
self.loading_dialog = QDialog(self.parent())
|
|
71
|
+
self.loading_dialog.setWindowTitle("Loading")
|
|
72
|
+
self.loading_dialog.setWindowFlags(Qt.Window | Qt.FramelessWindowHint)
|
|
73
|
+
self.loading_dialog.setModal(True)
|
|
74
|
+
layout = QVBoxLayout()
|
|
75
|
+
layout.addWidget(QLabel("File loading..."))
|
|
76
|
+
self.loading_dialog.setLayout(layout)
|
|
77
|
+
self.loading_timer = QTimer()
|
|
78
|
+
self.loading_timer.setSingleShot(True)
|
|
79
|
+
self.loading_timer.timeout.connect(self.loading_dialog.show)
|
|
80
|
+
self.loading_timer.start(100)
|
|
81
|
+
self.loader_thread = FileLoader(path)
|
|
82
|
+
self.loader_thread.finished.connect(self.on_file_loaded)
|
|
83
|
+
self.loader_thread.error.connect(self.on_file_error)
|
|
84
|
+
self.loader_thread.start()
|
|
85
|
+
|
|
86
|
+
def import_frames(self):
|
|
87
|
+
file_paths, _ = QFileDialog.getOpenFileNames(self.parent(), "Select frames", "",
|
|
88
|
+
"Images Images (*.tif *.tiff *.jpg *.jpeg);;All Files (*)")
|
|
89
|
+
if file_paths:
|
|
90
|
+
self.import_frames_from_files(file_paths)
|
|
91
|
+
self.status_message_requested.emit("Imported selected frames")
|
|
92
|
+
|
|
93
|
+
def import_frames_from_files(self, file_paths):
|
|
94
|
+
try:
|
|
95
|
+
stack, labels, master = self.io_manager.import_frames(file_paths)
|
|
96
|
+
except Exception as e:
|
|
97
|
+
msg = QMessageBox()
|
|
98
|
+
msg.setIcon(QMessageBox.Critical)
|
|
99
|
+
msg.setWindowTitle("Import error")
|
|
100
|
+
msg.setText(str(e))
|
|
101
|
+
msg.exec()
|
|
102
|
+
return
|
|
103
|
+
if self.layer_stack() is None and len(stack) > 0:
|
|
104
|
+
self.set_layer_stack(np.array(stack))
|
|
105
|
+
self.set_layer_labels(labels)
|
|
106
|
+
self.set_master_layer(master)
|
|
107
|
+
self.blank_layer = np.zeros(master.shape[:2])
|
|
108
|
+
else:
|
|
109
|
+
for img, label in zip(stack, labels):
|
|
110
|
+
self.add_layer_label(label)
|
|
111
|
+
self.add_layer(img)
|
|
112
|
+
self.parent().mark_as_modified()
|
|
113
|
+
self.parent().change_layer(0)
|
|
114
|
+
self.image_viewer.reset_zoom()
|
|
115
|
+
self.parent().thumbnail_list.setFocus()
|
|
116
|
+
self.display_manager.update_thumbnails()
|
|
117
|
+
|
|
118
|
+
def save_file(self):
|
|
119
|
+
if self.parent().save_master_only.isChecked():
|
|
120
|
+
self.save_master()
|
|
121
|
+
else:
|
|
122
|
+
self.save_multilayer()
|
|
123
|
+
|
|
124
|
+
def save_file_as(self):
|
|
125
|
+
if self.parent().save_master_only.isChecked():
|
|
126
|
+
self.save_master_as()
|
|
127
|
+
else:
|
|
128
|
+
self.save_multilayer_as()
|
|
129
|
+
|
|
130
|
+
def save_multilayer(self):
|
|
131
|
+
if self.layer_stack() is None:
|
|
132
|
+
return
|
|
133
|
+
if self.io_manager.current_file_path != '':
|
|
134
|
+
extension = self.io_manager.current_file_path.split('.')[-1]
|
|
135
|
+
if extension in ['tif', 'tiff']:
|
|
136
|
+
self.save_multilayer_to_path(self.io_manager.current_file_path)
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
def save_multilayer_as(self):
|
|
140
|
+
if self.layer_stack() is None:
|
|
141
|
+
return
|
|
142
|
+
path, _ = QFileDialog.getSaveFileName(self.parent(), "Save Image", "",
|
|
143
|
+
"TIFF Files (*.tif *.tiff);;All Files (*)")
|
|
144
|
+
if path:
|
|
145
|
+
if not path.lower().endswith(('.tif', '.tiff')):
|
|
146
|
+
path += '.tif'
|
|
147
|
+
self.save_multilayer_to_path(path)
|
|
148
|
+
|
|
149
|
+
def save_multilayer_to_path(self, path):
|
|
150
|
+
try:
|
|
151
|
+
self.io_manager.save_multilayer(path)
|
|
152
|
+
self.io_manager.current_file_path = path
|
|
153
|
+
self.modified = False
|
|
154
|
+
self.update_title_requested.emit()
|
|
155
|
+
self.status_message_requested.emit(f"Saved multilayer to: {path}")
|
|
156
|
+
except Exception as e:
|
|
157
|
+
traceback.print_tb(e.__traceback__)
|
|
158
|
+
QMessageBox.critical(self.parent(), "Save Error", f"Could not save file: {str(e)}")
|
|
159
|
+
|
|
160
|
+
def save_master(self):
|
|
161
|
+
if self.master_layer() is None:
|
|
162
|
+
return
|
|
163
|
+
if self.io_manager.current_file_path != '':
|
|
164
|
+
self.save_master_to_path(self.io_manager.current_file_path)
|
|
165
|
+
return
|
|
166
|
+
self.save_master_as()
|
|
167
|
+
|
|
168
|
+
def save_master_as(self):
|
|
169
|
+
if self.layer_stack() is None:
|
|
170
|
+
return
|
|
171
|
+
path, _ = QFileDialog.getSaveFileName(self.parent(), "Save Image", "",
|
|
172
|
+
"TIFF Files (*.tif *.tiff);;JPEG Files (*.jpg *.jpeg);;All Files (*)")
|
|
173
|
+
if path:
|
|
174
|
+
self.save_master_to_path(path)
|
|
175
|
+
|
|
176
|
+
def save_master_to_path(self, path):
|
|
177
|
+
try:
|
|
178
|
+
self.io_manager.save_master(path)
|
|
179
|
+
self.io_manager.current_file_path = path
|
|
180
|
+
self.modified = False
|
|
181
|
+
self.update_title_requested.emit()
|
|
182
|
+
self.status_message_requested.emit(f"Saved master layer to: {path}")
|
|
183
|
+
except Exception as e:
|
|
184
|
+
traceback.print_tb(e.__traceback__)
|
|
185
|
+
QMessageBox.critical(self.parent(), "Save Error", f"Could not save file: {str(e)}")
|
|
186
|
+
|
|
187
|
+
def select_exif_path(self):
|
|
188
|
+
path, _ = QFileDialog.getOpenFileName(None, "Select file with exif data")
|
|
189
|
+
if path:
|
|
190
|
+
self.io_manager.set_exif_data(path)
|
|
191
|
+
self.status_message_requested.emit(f"EXIF data extracted from {path}.")
|
|
192
|
+
self._exif_dialog = ExifData(self.io_manager.exif_data, self.parent())
|
|
193
|
+
self._exif_dialog.exec()
|
|
194
|
+
|
|
195
|
+
def close_file(self):
|
|
196
|
+
if self.parent()._check_unsaved_changes():
|
|
197
|
+
self.set_master_layer(None)
|
|
198
|
+
self.blank_layer = None
|
|
199
|
+
self.current_stack = None
|
|
200
|
+
self.layer_collection.reset()
|
|
201
|
+
self.io_manager.current_file_path = ''
|
|
202
|
+
self.modified = False
|
|
203
|
+
self.undo_manager.reset()
|
|
204
|
+
self.image_viewer.clear_image()
|
|
205
|
+
self.display_manager.thumbnail_list.clear()
|
|
206
|
+
self.display_manager.update_thumbnails()
|
|
207
|
+
self.update_title_requested.emit()
|
|
208
|
+
self.status_message_requested.emit("File closed")
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
# pylint: disable=E1101, C0114, C0115, C0116
|
|
1
2
|
import cv2
|
|
2
|
-
from .. core.exceptions import ShapeError, BitDepthError
|
|
3
3
|
from .. algorithms.utils import read_img, validate_image, get_img_metadata
|
|
4
4
|
from .. algorithms.exif import get_exif, write_image_with_exif_data
|
|
5
5
|
from .. algorithms.multilayer import write_multilayer_tiff_from_images
|
|
@@ -7,7 +7,7 @@ from .. algorithms.multilayer import write_multilayer_tiff_from_images
|
|
|
7
7
|
|
|
8
8
|
class IOManager:
|
|
9
9
|
def __init__(self, layer_collection):
|
|
10
|
-
self
|
|
10
|
+
layer_collection.add_to(self)
|
|
11
11
|
self.current_file_path = ''
|
|
12
12
|
self.exif_path = ''
|
|
13
13
|
self.exif_data = None
|
|
@@ -16,7 +16,7 @@ class IOManager:
|
|
|
16
16
|
stack = []
|
|
17
17
|
labels = []
|
|
18
18
|
master = None
|
|
19
|
-
shape, dtype = get_img_metadata(self.
|
|
19
|
+
shape, dtype = get_img_metadata(self.master_layer())
|
|
20
20
|
for path in file_paths:
|
|
21
21
|
try:
|
|
22
22
|
label = path.split("/")[-1].split(".")[0]
|
|
@@ -34,22 +34,18 @@ class IOManager:
|
|
|
34
34
|
stack.append(img)
|
|
35
35
|
if master is None:
|
|
36
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
37
|
except Exception as e:
|
|
42
|
-
raise RuntimeError(f"Error loading file: {path}.\n{str(e)}")
|
|
38
|
+
raise RuntimeError(f"Error loading file: {path}.\n{str(e)}") from e
|
|
43
39
|
return stack, labels, master
|
|
44
40
|
|
|
45
41
|
def save_multilayer(self, path):
|
|
46
|
-
master_layer = {'Master': self.
|
|
47
|
-
individual_layers =
|
|
48
|
-
|
|
49
|
-
|
|
42
|
+
master_layer = {'Master': self.master_layer()}
|
|
43
|
+
individual_layers = dict(zip(self.layer_labels(), self.layer_stack()))
|
|
44
|
+
write_multilayer_tiff_from_images({**master_layer, **individual_layers},
|
|
45
|
+
path, exif_path=self.exif_path)
|
|
50
46
|
|
|
51
47
|
def save_master(self, path):
|
|
52
|
-
img = cv2.cvtColor(self.
|
|
48
|
+
img = cv2.cvtColor(self.master_layer(), cv2.COLOR_RGB2BGR)
|
|
53
49
|
write_image_with_exif_data(self.exif_data, img, path)
|
|
54
50
|
|
|
55
51
|
def set_exif_data(self, path):
|
|
@@ -2,16 +2,68 @@ import numpy as np
|
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
class LayerCollection:
|
|
5
|
+
def add_to(self, obj):
|
|
6
|
+
obj.layer_collection = self
|
|
7
|
+
obj.master_layer = lambda: obj.layer_collection.master_layer
|
|
8
|
+
obj.current_layer = lambda: obj.layer_collection.current_layer()
|
|
9
|
+
obj.layer_stack = lambda: obj.layer_collection.layer_stack
|
|
10
|
+
obj.layer_labels = lambda: obj.layer_collection.layer_labels
|
|
11
|
+
obj.set_layer_label = lambda i, val: obj.layer_collection.set_layer_label(i, val)
|
|
12
|
+
obj.set_layer_labels = lambda labels: obj.layer_collection.set_layer_labels(labels)
|
|
13
|
+
obj.current_layer_idx = lambda: obj.layer_collection.current_layer_idx
|
|
14
|
+
obj.has_no_master_layer = lambda: obj.layer_collection.has_no_master_layer()
|
|
15
|
+
obj.has_master_layer = lambda: obj.layer_collection.has_master_layer()
|
|
16
|
+
obj.set_layer_stack = lambda stk: obj.layer_collection.set_layer_stack(stk)
|
|
17
|
+
obj.set_master_layer = lambda img: obj.layer_collection.set_master_layer(img)
|
|
18
|
+
obj.add_layer_label = lambda label: obj.layer_collection.add_layer_label(label)
|
|
19
|
+
obj.add_layer = lambda img: obj.layer_collection.add_layer(img)
|
|
20
|
+
obj.master_layer_copy = lambda: obj.layer_collection.master_layer_copy
|
|
21
|
+
obj.copy_master_layer = lambda: obj.layer_collection.copy_master_layer()
|
|
22
|
+
obj.set_current_layer_idx = lambda idx: obj.layer_collection.set_current_layer_idx(idx)
|
|
23
|
+
obj.sort_layers = lambda order: obj.layer_collection.sort_layers(order)
|
|
24
|
+
obj.number_of_layers = lambda: obj.layer_collection.number_of_layers()
|
|
25
|
+
obj.valid_current_layer_idx = lambda: obj.layer_collection.valid_current_layer_idx()
|
|
26
|
+
|
|
5
27
|
def __init__(self):
|
|
28
|
+
self.reset()
|
|
29
|
+
|
|
30
|
+
def reset(self):
|
|
6
31
|
self.master_layer = None
|
|
7
32
|
self.master_layer_copy = None
|
|
8
33
|
self.layer_stack = None
|
|
9
|
-
self.layer_labels =
|
|
34
|
+
self.layer_labels = []
|
|
10
35
|
self.current_layer_idx = 0
|
|
11
36
|
|
|
37
|
+
def has_master_layer(self):
|
|
38
|
+
return self.master_layer is not None
|
|
39
|
+
|
|
40
|
+
def has_no_master_layer(self):
|
|
41
|
+
return self.master_layer is None
|
|
42
|
+
|
|
43
|
+
def has_master_layer_copy(self):
|
|
44
|
+
return self.master_layer_copy is not None
|
|
45
|
+
|
|
46
|
+
def has_no_master_layer_copy(self):
|
|
47
|
+
return self.master_layer_copy is None
|
|
48
|
+
|
|
12
49
|
def number_of_layers(self):
|
|
13
50
|
return len(self.layer_stack)
|
|
14
51
|
|
|
52
|
+
def layer_label(self, i):
|
|
53
|
+
return self.layer_labels[i]
|
|
54
|
+
|
|
55
|
+
def set_layer_label(self, i, val):
|
|
56
|
+
self.layer_labels[i] = val
|
|
57
|
+
|
|
58
|
+
def set_layer_labels(self, labels):
|
|
59
|
+
self.layer_labels = labels
|
|
60
|
+
|
|
61
|
+
def set_layer_stack(self, stk):
|
|
62
|
+
self.layer_stack = stk
|
|
63
|
+
|
|
64
|
+
def set_current_layer_idx(self, idx):
|
|
65
|
+
self.current_layer_idx = idx
|
|
66
|
+
|
|
15
67
|
def valid_current_layer_idx(self):
|
|
16
68
|
return 0 <= self.current_layer_idx < self.number_of_layers()
|
|
17
69
|
|
|
@@ -20,9 +72,21 @@ class LayerCollection:
|
|
|
20
72
|
return self.layer_stack[self.current_layer_idx]
|
|
21
73
|
return None
|
|
22
74
|
|
|
75
|
+
def set_master_layer(self, img):
|
|
76
|
+
self.master_layer = img
|
|
77
|
+
|
|
23
78
|
def copy_master_layer(self):
|
|
24
79
|
self.master_layer_copy = self.master_layer.copy()
|
|
25
80
|
|
|
81
|
+
def add_layer_label(self, label):
|
|
82
|
+
if self.layer_labels is None:
|
|
83
|
+
self.layer_labels = [label]
|
|
84
|
+
else:
|
|
85
|
+
self.layer_labels.append(label)
|
|
86
|
+
|
|
87
|
+
def add_layer(self, img):
|
|
88
|
+
self.layer_stack = np.append(self.layer_stack, [img], axis=0)
|
|
89
|
+
|
|
26
90
|
def sort_layers(self, order):
|
|
27
91
|
master_index = -1
|
|
28
92
|
master_label = None
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from PySide6.QtWidgets import QHBoxLayout, QLabel, QSlider, QCheckBox, QDialogButtonBox
|
|
2
|
+
from PySide6.QtCore import Qt, QTimer
|
|
3
|
+
from .filter_base import BaseFilter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class UnsharpMaskFilter(BaseFilter):
|
|
7
|
+
def __init__(self, editor):
|
|
8
|
+
super().__init__(editor)
|
|
9
|
+
self.max_range = 500.0
|
|
10
|
+
self.max_radius = 4.0
|
|
11
|
+
self.max_amount = 3.0
|
|
12
|
+
self.max_threshold = 64.0
|
|
13
|
+
self.initial_radius = 1.0
|
|
14
|
+
self.initial_amount = 0.5
|
|
15
|
+
self.initial_threshold = 0.0
|
|
16
|
+
self.radius_slider = None
|
|
17
|
+
self.amount_slider = None
|
|
18
|
+
self.threshold_slider = None
|
|
19
|
+
|
|
20
|
+
def setup_ui(self, dlg, layout, do_preview, restore_original, **kwargs):
|
|
21
|
+
dlg.setWindowTitle("Unsharp Mask")
|
|
22
|
+
dlg.setMinimumWidth(600)
|
|
23
|
+
params = {
|
|
24
|
+
"Radius": (self.max_radius, self.initial_radius, "{:.2f}"),
|
|
25
|
+
"Amount": (self.max_amount, self.initial_amount, "{:.1%}"),
|
|
26
|
+
"Threshold": (self.max_threshold, self.initial_threshold, "{:.2f}")
|
|
27
|
+
}
|
|
28
|
+
value_labels = {}
|
|
29
|
+
for name, (max_val, init_val, fmt) in params.items():
|
|
30
|
+
param_layout = QHBoxLayout()
|
|
31
|
+
name_label = QLabel(f"{name}:")
|
|
32
|
+
param_layout.addWidget(name_label)
|
|
33
|
+
slider = QSlider(Qt.Horizontal)
|
|
34
|
+
slider.setRange(0, self.max_range)
|
|
35
|
+
slider.setValue(int(init_val / max_val * self.max_range))
|
|
36
|
+
param_layout.addWidget(slider)
|
|
37
|
+
value_label = QLabel(fmt.format(init_val))
|
|
38
|
+
param_layout.addWidget(value_label)
|
|
39
|
+
layout.addLayout(param_layout)
|
|
40
|
+
if name == "Radius":
|
|
41
|
+
self.radius_slider = slider
|
|
42
|
+
elif name == "Amount":
|
|
43
|
+
self.amount_slider = slider
|
|
44
|
+
elif name == "Threshold":
|
|
45
|
+
self.threshold_slider = slider
|
|
46
|
+
value_labels[name] = value_label
|
|
47
|
+
|
|
48
|
+
preview_check = QCheckBox("Preview")
|
|
49
|
+
preview_check.setChecked(True)
|
|
50
|
+
layout.addWidget(preview_check)
|
|
51
|
+
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
|
52
|
+
layout.addWidget(button_box)
|
|
53
|
+
preview_timer = QTimer()
|
|
54
|
+
preview_timer.setSingleShot(True)
|
|
55
|
+
preview_timer.setInterval(200)
|
|
56
|
+
|
|
57
|
+
def update_value(name, value, max_val, fmt):
|
|
58
|
+
float_value = max_val * value / self.max_range
|
|
59
|
+
value_labels[name].setText(fmt.format(float_value))
|
|
60
|
+
if preview_check.isChecked():
|
|
61
|
+
preview_timer.start()
|
|
62
|
+
|
|
63
|
+
self.radius_slider.valueChanged.connect(
|
|
64
|
+
lambda v: update_value("Radius", v, self.max_radius, params["Radius"][2]))
|
|
65
|
+
self.amount_slider.valueChanged.connect(
|
|
66
|
+
lambda v: update_value("Amount", v, self.max_amount, params["Amount"][2]))
|
|
67
|
+
self.threshold_slider.valueChanged.connect(
|
|
68
|
+
lambda v: update_value("Threshold", v, self.max_threshold, params["Threshold"][2]))
|
|
69
|
+
preview_timer.timeout.connect(do_preview)
|
|
70
|
+
self.editor.connect_preview_toggle(preview_check, do_preview, restore_original)
|
|
71
|
+
button_box.accepted.connect(dlg.accept)
|
|
72
|
+
button_box.rejected.connect(dlg.reject)
|
|
73
|
+
QTimer.singleShot(0, do_preview)
|
|
74
|
+
|
|
75
|
+
def get_params(self):
|
|
76
|
+
return (
|
|
77
|
+
max(0.01, self.max_radius * self.radius_slider.value() / self.max_range),
|
|
78
|
+
self.max_amount * self.amount_slider.value() / self.max_range,
|
|
79
|
+
self.max_threshold * self.threshold_slider.value() / self.max_range
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def apply(self, image, radius, amount, threshold):
|
|
83
|
+
from .. algorithms.sharpen import unsharp_mask
|
|
84
|
+
return unsharp_mask(image, radius, amount, threshold)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from PySide6.QtWidgets import (QHBoxLayout,
|
|
2
|
+
QPushButton, QFrame, QVBoxLayout, QLabel, QDialog, QApplication, QSlider,
|
|
3
|
+
QCheckBox, QDialogButtonBox)
|
|
4
|
+
from PySide6.QtCore import Qt, QTimer
|
|
5
|
+
from PySide6.QtGui import QCursor
|
|
6
|
+
from .filter_base import BaseFilter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WhiteBalanceFilter(BaseFilter):
|
|
10
|
+
def __init__(self, editor):
|
|
11
|
+
super().__init__(editor)
|
|
12
|
+
self.max_range = 255
|
|
13
|
+
self.initial_val = (128, 128, 128)
|
|
14
|
+
self.sliders = {}
|
|
15
|
+
self.value_labels = {}
|
|
16
|
+
self.color_preview = None
|
|
17
|
+
self.preview_timer = None
|
|
18
|
+
|
|
19
|
+
def setup_ui(self, dlg, layout, do_preview, restore_original, init_val=None):
|
|
20
|
+
if init_val:
|
|
21
|
+
self.initial_val = init_val
|
|
22
|
+
dlg.setWindowTitle("White Balance")
|
|
23
|
+
dlg.setMinimumWidth(600)
|
|
24
|
+
row_layout = QHBoxLayout()
|
|
25
|
+
self.color_preview = QFrame()
|
|
26
|
+
self.color_preview.setFixedHeight(80)
|
|
27
|
+
self.color_preview.setFixedWidth(80)
|
|
28
|
+
self.color_preview.setStyleSheet(f"background-color: rgb{self.initial_val};")
|
|
29
|
+
row_layout.addWidget(self.color_preview)
|
|
30
|
+
sliders_layout = QVBoxLayout()
|
|
31
|
+
for name in ("R", "G", "B"):
|
|
32
|
+
row = QHBoxLayout()
|
|
33
|
+
label = QLabel(f"{name}:")
|
|
34
|
+
row.addWidget(label)
|
|
35
|
+
slider = QSlider(Qt.Horizontal)
|
|
36
|
+
slider.setRange(0, self.max_range)
|
|
37
|
+
slider.setValue(self.initial_val[["R", "G", "B"].index(name)])
|
|
38
|
+
row.addWidget(slider)
|
|
39
|
+
val_label = QLabel(str(self.initial_val[["R", "G", "B"].index(name)]))
|
|
40
|
+
row.addWidget(val_label)
|
|
41
|
+
sliders_layout.addLayout(row)
|
|
42
|
+
self.sliders[name] = slider
|
|
43
|
+
self.value_labels[name] = val_label
|
|
44
|
+
row_layout.addLayout(sliders_layout)
|
|
45
|
+
layout.addLayout(row_layout)
|
|
46
|
+
pick_button = QPushButton("Pick Color")
|
|
47
|
+
layout.addWidget(pick_button)
|
|
48
|
+
preview_check = QCheckBox("Preview")
|
|
49
|
+
preview_check.setChecked(True)
|
|
50
|
+
layout.addWidget(preview_check)
|
|
51
|
+
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Reset | QDialogButtonBox.Cancel)
|
|
52
|
+
layout.addWidget(button_box)
|
|
53
|
+
self.preview_timer = QTimer()
|
|
54
|
+
self.preview_timer.setSingleShot(True)
|
|
55
|
+
self.preview_timer.setInterval(200)
|
|
56
|
+
for slider in self.sliders.values():
|
|
57
|
+
slider.valueChanged.connect(self.on_slider_change)
|
|
58
|
+
self.preview_timer.timeout.connect(do_preview)
|
|
59
|
+
self.editor.connect_preview_toggle(preview_check, do_preview, restore_original)
|
|
60
|
+
pick_button.clicked.connect(self.start_color_pick)
|
|
61
|
+
button_box.accepted.connect(dlg.accept)
|
|
62
|
+
button_box.rejected.connect(dlg.reject)
|
|
63
|
+
button_box.button(QDialogButtonBox.Reset).clicked.connect(self.reset_rgb)
|
|
64
|
+
QTimer.singleShot(0, do_preview)
|
|
65
|
+
|
|
66
|
+
def on_slider_change(self):
|
|
67
|
+
for name in ("R", "G", "B"):
|
|
68
|
+
self.value_labels[name].setText(str(self.sliders[name].value()))
|
|
69
|
+
rgb = tuple(self.sliders[n].value() for n in ("R", "G", "B"))
|
|
70
|
+
self.color_preview.setStyleSheet(f"background-color: rgb{rgb};")
|
|
71
|
+
if self.preview_timer:
|
|
72
|
+
self.preview_timer.start()
|
|
73
|
+
|
|
74
|
+
def start_color_pick(self):
|
|
75
|
+
for widget in QApplication.topLevelWidgets():
|
|
76
|
+
if isinstance(widget, QDialog) and widget.isVisible():
|
|
77
|
+
widget.hide()
|
|
78
|
+
widget.reject()
|
|
79
|
+
break
|
|
80
|
+
self.editor.image_viewer.set_cursor_style('outline')
|
|
81
|
+
if self.editor.image_viewer.brush_cursor:
|
|
82
|
+
self.editor.image_viewer.brush_cursor.hide()
|
|
83
|
+
self.editor.brush_preview.hide()
|
|
84
|
+
QApplication.setOverrideCursor(QCursor(Qt.CrossCursor))
|
|
85
|
+
self.editor.image_viewer.setCursor(Qt.CrossCursor)
|
|
86
|
+
self.original_mouse_press = self.editor.image_viewer.mousePressEvent
|
|
87
|
+
self.editor.image_viewer.mousePressEvent = self.pick_color_from_click
|
|
88
|
+
|
|
89
|
+
def pick_color_from_click(self, event):
|
|
90
|
+
if event.button() == Qt.LeftButton:
|
|
91
|
+
pos = event.pos()
|
|
92
|
+
bgr = self.editor.get_pixel_color_at(pos, radius=int(self.editor.brush.size))
|
|
93
|
+
rgb = (bgr[2], bgr[1], bgr[0])
|
|
94
|
+
QApplication.restoreOverrideCursor()
|
|
95
|
+
self.editor.image_viewer.unsetCursor()
|
|
96
|
+
self.editor.image_viewer.mousePressEvent = self.original_mouse_press
|
|
97
|
+
self.editor.image_viewer.brush_cursor.show()
|
|
98
|
+
self.editor.brush_preview.show()
|
|
99
|
+
new_filter = WhiteBalanceFilter(self.editor)
|
|
100
|
+
new_filter.run_with_preview(init_val=rgb)
|
|
101
|
+
|
|
102
|
+
def reset_rgb(self):
|
|
103
|
+
for name, slider in self.sliders.items():
|
|
104
|
+
slider.setValue(self.initial_val[["R", "G", "B"].index(name)])
|
|
105
|
+
|
|
106
|
+
def get_params(self):
|
|
107
|
+
return tuple(self.sliders[n].value() for n in ("R", "G", "B"))
|
|
108
|
+
|
|
109
|
+
def apply(self, image, r, g, b):
|
|
110
|
+
from .. algorithms.white_balance import white_balance_from_rgb
|
|
111
|
+
return white_balance_from_rgb(image, (r, g, b))
|