shinestacker 0.3.0__py3-none-any.whl → 0.3.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of shinestacker might be problematic. Click here for more details.
- shinestacker/__init__.py +6 -6
- shinestacker/_version.py +1 -1
- shinestacker/algorithms/balance.py +6 -7
- shinestacker/algorithms/noise_detection.py +2 -0
- shinestacker/algorithms/utils.py +4 -0
- shinestacker/algorithms/white_balance.py +13 -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/gui/new_project.py +1 -0
- shinestacker/retouch/brush_gradient.py +20 -0
- shinestacker/retouch/brush_preview.py +10 -14
- 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 +108 -543
- shinestacker/retouch/image_editor_ui.py +42 -75
- shinestacker/retouch/image_filters.py +27 -423
- shinestacker/retouch/image_viewer.py +31 -31
- shinestacker/retouch/io_gui_handler.py +208 -0
- shinestacker/retouch/io_manager.py +53 -0
- shinestacker/retouch/layer_collection.py +118 -0
- shinestacker/retouch/unsharp_mask_filter.py +84 -0
- shinestacker/retouch/white_balance_filter.py +111 -0
- {shinestacker-0.3.0.dist-info → shinestacker-0.3.2.dist-info}/METADATA +3 -2
- {shinestacker-0.3.0.dist-info → shinestacker-0.3.2.dist-info}/RECORD +42 -31
- shinestacker/retouch/brush_controller.py +0 -57
- {shinestacker-0.3.0.dist-info → shinestacker-0.3.2.dist-info}/WHEEL +0 -0
- {shinestacker-0.3.0.dist-info → shinestacker-0.3.2.dist-info}/entry_points.txt +0 -0
- {shinestacker-0.3.0.dist-info → shinestacker-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {shinestacker-0.3.0.dist-info → shinestacker-0.3.2.dist-info}/top_level.txt +0 -0
|
@@ -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")
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# pylint: disable=E1101, C0114, C0115, C0116
|
|
2
|
+
import cv2
|
|
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
|
+
layer_collection.add_to(self)
|
|
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.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 Exception as e:
|
|
38
|
+
raise RuntimeError(f"Error loading file: {path}.\n{str(e)}") from e
|
|
39
|
+
return stack, labels, master
|
|
40
|
+
|
|
41
|
+
def save_multilayer(self, path):
|
|
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)
|
|
46
|
+
|
|
47
|
+
def save_master(self, path):
|
|
48
|
+
img = cv2.cvtColor(self.master_layer(), cv2.COLOR_RGB2BGR)
|
|
49
|
+
write_image_with_exif_data(self.exif_data, img, path)
|
|
50
|
+
|
|
51
|
+
def set_exif_data(self, path):
|
|
52
|
+
self.exif_path = path
|
|
53
|
+
self.exif_data = get_exif(path)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
|
|
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
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
self.reset()
|
|
29
|
+
|
|
30
|
+
def reset(self):
|
|
31
|
+
self.master_layer = None
|
|
32
|
+
self.master_layer_copy = None
|
|
33
|
+
self.layer_stack = None
|
|
34
|
+
self.layer_labels = []
|
|
35
|
+
self.current_layer_idx = 0
|
|
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
|
+
|
|
49
|
+
def number_of_layers(self):
|
|
50
|
+
return len(self.layer_stack)
|
|
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
|
+
|
|
67
|
+
def valid_current_layer_idx(self):
|
|
68
|
+
return 0 <= self.current_layer_idx < self.number_of_layers()
|
|
69
|
+
|
|
70
|
+
def current_layer(self):
|
|
71
|
+
if self.layer_stack is not None and self.valid_current_layer_idx():
|
|
72
|
+
return self.layer_stack[self.current_layer_idx]
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def set_master_layer(self, img):
|
|
76
|
+
self.master_layer = img
|
|
77
|
+
|
|
78
|
+
def copy_master_layer(self):
|
|
79
|
+
self.master_layer_copy = self.master_layer.copy()
|
|
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
|
+
|
|
90
|
+
def sort_layers(self, order):
|
|
91
|
+
master_index = -1
|
|
92
|
+
master_label = None
|
|
93
|
+
master_layer = None
|
|
94
|
+
for i, label in enumerate(self.layer_labels):
|
|
95
|
+
if label.lower() == "master":
|
|
96
|
+
master_index = i
|
|
97
|
+
master_label = self.layer_labels.pop(i)
|
|
98
|
+
master_layer = self.layer_stack[i]
|
|
99
|
+
self.layer_stack = np.delete(self.layer_stack, i, axis=0)
|
|
100
|
+
break
|
|
101
|
+
if order == 'asc':
|
|
102
|
+
self.sorted_indices = sorted(range(len(self.layer_labels)),
|
|
103
|
+
key=lambda i: self.layer_labels[i].lower())
|
|
104
|
+
elif order == 'desc':
|
|
105
|
+
self.sorted_indices = sorted(range(len(self.layer_labels)),
|
|
106
|
+
key=lambda i: self.layer_labels[i].lower(),
|
|
107
|
+
reverse=True)
|
|
108
|
+
else:
|
|
109
|
+
raise ValueError(f"Invalid sorting order: {order}")
|
|
110
|
+
self.layer_labels = [self.layer_labels[i] for i in self.sorted_indices]
|
|
111
|
+
self.layer_stack = self.layer_stack[self.sorted_indices]
|
|
112
|
+
if master_index != -1:
|
|
113
|
+
self.layer_labels.insert(0, master_label)
|
|
114
|
+
self.layer_stack = np.insert(self.layer_stack, 0, master_layer, axis=0)
|
|
115
|
+
self.master_layer = master_layer.copy()
|
|
116
|
+
self.master_layer.setflags(write=True)
|
|
117
|
+
if self.current_layer_idx >= self.number_of_layers():
|
|
118
|
+
self.current_layer_idx = self.number_of_layers() - 1
|
|
@@ -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))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shinestacker
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: ShineStacker
|
|
5
5
|
Author-email: Luca Lista <luka.lista@gmail.com>
|
|
6
6
|
License-Expression: LGPL-3.0
|
|
@@ -41,6 +41,7 @@ Dynamic: license-file
|
|
|
41
41
|
|
|
42
42
|
<img src='https://raw.githubusercontent.com/lucalista/shinestacker/main/img/flies.gif' width="400" referrerpolicy="no-referrer"> <img src='https://raw.githubusercontent.com/lucalista/shinestacker/main/img/flies_stack.jpg' width="400" referrerpolicy="no-referrer">
|
|
43
43
|
|
|
44
|
+
<img src='https://raw.githubusercontent.com/lucalista/shinestacker/main/img/coins.gif' width="400" referrerpolicy="no-referrer"> <img src='https://raw.githubusercontent.com/lucalista/shinestacker/main/img/coins_stack.jpg' width="400" referrerpolicy="no-referrer">
|
|
44
45
|
> **Focus stacking** for microscopy, macro photography, and computational imaging
|
|
45
46
|
|
|
46
47
|
## Key Features
|
|
@@ -58,7 +59,7 @@ The GUI has two main working areas:
|
|
|
58
59
|
|
|
59
60
|
<img src='https://raw.githubusercontent.com/lucalista/shinestacker/main/img/gui-project-run.png' width="600" referrerpolicy="no-referrer">
|
|
60
61
|
|
|
61
|
-
* *Retouch*:
|
|
62
|
+
* *Retouch*: select interactively details from individual frames and apply final filters to the blended image.
|
|
62
63
|
|
|
63
64
|
<img src='https://raw.githubusercontent.com/lucalista/shinestacker/main/img/gui-retouch.png' width="600" referrerpolicy="no-referrer">
|
|
64
65
|
|