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.

Files changed (43) hide show
  1. shinestacker/__init__.py +6 -6
  2. shinestacker/_version.py +1 -1
  3. shinestacker/algorithms/balance.py +6 -7
  4. shinestacker/algorithms/noise_detection.py +2 -0
  5. shinestacker/algorithms/utils.py +4 -0
  6. shinestacker/algorithms/white_balance.py +13 -0
  7. shinestacker/app/open_frames.py +6 -4
  8. shinestacker/config/__init__.py +2 -1
  9. shinestacker/config/config.py +1 -0
  10. shinestacker/config/constants.py +1 -0
  11. shinestacker/config/gui_constants.py +1 -0
  12. shinestacker/core/__init__.py +4 -3
  13. shinestacker/core/colors.py +1 -0
  14. shinestacker/core/core_utils.py +6 -6
  15. shinestacker/core/exceptions.py +1 -0
  16. shinestacker/core/framework.py +2 -1
  17. shinestacker/gui/action_config.py +47 -42
  18. shinestacker/gui/actions_window.py +8 -5
  19. shinestacker/gui/new_project.py +1 -0
  20. shinestacker/retouch/brush_gradient.py +20 -0
  21. shinestacker/retouch/brush_preview.py +10 -14
  22. shinestacker/retouch/brush_tool.py +164 -0
  23. shinestacker/retouch/denoise_filter.py +56 -0
  24. shinestacker/retouch/display_manager.py +177 -0
  25. shinestacker/retouch/exif_data.py +2 -1
  26. shinestacker/retouch/filter_base.py +114 -0
  27. shinestacker/retouch/filter_manager.py +14 -0
  28. shinestacker/retouch/image_editor.py +108 -543
  29. shinestacker/retouch/image_editor_ui.py +42 -75
  30. shinestacker/retouch/image_filters.py +27 -423
  31. shinestacker/retouch/image_viewer.py +31 -31
  32. shinestacker/retouch/io_gui_handler.py +208 -0
  33. shinestacker/retouch/io_manager.py +53 -0
  34. shinestacker/retouch/layer_collection.py +118 -0
  35. shinestacker/retouch/unsharp_mask_filter.py +84 -0
  36. shinestacker/retouch/white_balance_filter.py +111 -0
  37. {shinestacker-0.3.0.dist-info → shinestacker-0.3.2.dist-info}/METADATA +3 -2
  38. {shinestacker-0.3.0.dist-info → shinestacker-0.3.2.dist-info}/RECORD +42 -31
  39. shinestacker/retouch/brush_controller.py +0 -57
  40. {shinestacker-0.3.0.dist-info → shinestacker-0.3.2.dist-info}/WHEEL +0 -0
  41. {shinestacker-0.3.0.dist-info → shinestacker-0.3.2.dist-info}/entry_points.txt +0 -0
  42. {shinestacker-0.3.0.dist-info → shinestacker-0.3.2.dist-info}/licenses/LICENSE +0 -0
  43. {shinestacker-0.3.0.dist-info → shinestacker-0.3.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,164 @@
1
+ import numpy as np
2
+ from PySide6.QtGui import QPixmap, QPainter, QColor, QPen, QBrush
3
+ from PySide6.QtCore import Qt, QPoint
4
+ from .brush_gradient import create_brush_gradient
5
+ from .. config.gui_constants import gui_constants
6
+ from .. config.constants import constants
7
+ from .brush_preview import create_brush_mask
8
+
9
+
10
+ class BrushTool:
11
+ def __init__(self):
12
+ self.brush = None
13
+ self.brush_preview = None
14
+ self.image_viewer = None
15
+ self.size_slider = None
16
+ self.hardness_slider = None
17
+ self.opacity_slider = None
18
+ self.flow_slider = None
19
+ self._brush_mask_cache = {}
20
+
21
+ def setup_ui(self, brush, brush_preview, image_viewer, size_slider, hardness_slider,
22
+ opacity_slider, flow_slider):
23
+ self.brush = brush
24
+ self.brush_preview = brush_preview
25
+ self.image_viewer = image_viewer
26
+ self.size_slider = size_slider
27
+ self.hardness_slider = hardness_slider
28
+ self.opacity_slider = opacity_slider
29
+ self.flow_slider = flow_slider
30
+ self.size_slider.valueChanged.connect(self.update_brush_size)
31
+ self.hardness_slider.valueChanged.connect(self.update_brush_hardness)
32
+ self.opacity_slider.valueChanged.connect(self.update_brush_opacity)
33
+ self.flow_slider.valueChanged.connect(self.update_brush_flow)
34
+ self.update_brush_size(self.size_slider.value())
35
+ self.update_brush_hardness(self.hardness_slider.value())
36
+ self.update_brush_opacity(self.opacity_slider.value())
37
+ self.update_brush_flow(self.flow_slider.value())
38
+
39
+ def update_brush_size(self, slider_val):
40
+
41
+ def slider_to_brush_size(slider_val):
42
+ normalized = slider_val / gui_constants.BRUSH_SIZE_SLIDER_MAX
43
+ size = gui_constants.BRUSH_SIZES['min'] + \
44
+ gui_constants.BRUSH_SIZES['max'] * (normalized ** gui_constants.BRUSH_GAMMA)
45
+ return max(gui_constants.BRUSH_SIZES['min'], min(gui_constants.BRUSH_SIZES['max'], size))
46
+
47
+ self.brush.size = slider_to_brush_size(slider_val)
48
+ self.update_brush_thumb()
49
+
50
+ def increase_brush_size(self, amount=5):
51
+ val = min(self.size_slider.value() + amount, self.size_slider.maximum())
52
+ self.size_slider.setValue(val)
53
+ self.update_brush_size(val)
54
+
55
+ def decrease_brush_size(self, amount=5):
56
+ val = max(self.size_slider.value() - amount, self.size_slider.minimum())
57
+ self.size_slider.setValue(val)
58
+ self.update_brush_size(val)
59
+
60
+ def increase_brush_hardness(self, amount=2):
61
+ val = min(self.hardness_slider.value() + amount, self.hardness_slider.maximum())
62
+ self.hardness_slider.setValue(val)
63
+ self.update_brush_hardness(val)
64
+
65
+ def decrease_brush_hardness(self, amount=2):
66
+ val = max(self.hardness_slider.value() - amount, self.hardness_slider.minimum())
67
+ self.hardness_slider.setValue(val)
68
+ self.update_brush_hardness(val)
69
+
70
+ def update_brush_hardness(self, hardness):
71
+ self.brush.hardness = hardness
72
+ self.update_brush_thumb()
73
+
74
+ def update_brush_opacity(self, opacity):
75
+ self.brush.opacity = opacity
76
+ self.update_brush_thumb()
77
+
78
+ def update_brush_flow(self, flow):
79
+ self.brush.flow = flow
80
+ self.update_brush_thumb()
81
+
82
+ def update_brush_thumb(self):
83
+ width, height = gui_constants.UI_SIZES['brush_preview']
84
+ pixmap = QPixmap(width, height)
85
+ pixmap.fill(Qt.transparent)
86
+ painter = QPainter(pixmap)
87
+ painter.setRenderHint(QPainter.Antialiasing)
88
+ preview_size = min(self.brush.size, width + 30, height + 30)
89
+ center_x, center_y = width // 2, height // 2
90
+ radius = preview_size // 2
91
+ if self.image_viewer.cursor_style == 'preview':
92
+ gradient = create_brush_gradient(
93
+ center_x, center_y, radius,
94
+ self.brush.hardness,
95
+ inner_color=QColor(*gui_constants.BRUSH_COLORS['inner']),
96
+ outer_color=QColor(*gui_constants.BRUSH_COLORS['gradient_end']),
97
+ opacity=self.brush.opacity
98
+ )
99
+ painter.setBrush(QBrush(gradient))
100
+ painter.setPen(QPen(QColor(*gui_constants.BRUSH_COLORS['outer']), gui_constants.BRUSH_PREVIEW_LINE_WIDTH))
101
+ elif self.image_viewer.cursor_style == 'outline':
102
+ painter.setBrush(Qt.NoBrush)
103
+ painter.setPen(QPen(QColor(*gui_constants.BRUSH_COLORS['outer']), gui_constants.BRUSH_PREVIEW_LINE_WIDTH))
104
+ else:
105
+ painter.setBrush(QBrush(QColor(*gui_constants.BRUSH_COLORS['cursor_inner'])))
106
+ painter.setPen(QPen(QColor(*gui_constants.BRUSH_COLORS['pen']), gui_constants.BRUSH_PREVIEW_LINE_WIDTH))
107
+ painter.drawEllipse(QPoint(center_x, center_y), radius, radius)
108
+ if self.image_viewer.cursor_style == 'preview':
109
+ painter.setPen(QPen(QColor(0, 0, 160)))
110
+ painter.drawText(0, 10, f"Size: {int(self.brush.size)}px")
111
+ painter.drawText(0, 25, f"Hardness: {self.brush.hardness}%")
112
+ painter.drawText(0, 40, f"Opacity: {self.brush.opacity}%")
113
+ painter.drawText(0, 55, f"Flow: {self.brush.flow}%")
114
+ painter.end()
115
+ self.brush_preview.setPixmap(pixmap)
116
+ self.image_viewer.update_brush_cursor()
117
+
118
+ def apply_brush_operation(self, master_layer, source_layer, dest_layer, mask_layer, view_pos, image_viewer):
119
+ if master_layer is None or source_layer is None:
120
+ return False
121
+ if dest_layer is None:
122
+ dest_layer = master_layer
123
+ scene_pos = image_viewer.mapToScene(view_pos)
124
+ x_center = int(round(scene_pos.x()))
125
+ y_center = int(round(scene_pos.y()))
126
+ radius = int(round(self.brush.size // 2))
127
+ h, w = master_layer.shape[:2]
128
+ x_start, x_end = max(0, x_center - radius), min(w, x_center + radius + 1)
129
+ y_start, y_end = max(0, y_center - radius), min(h, y_center + radius + 1)
130
+ if x_start >= x_end or y_start >= y_end:
131
+ return 0, 0, 0, 0
132
+ mask = self.get_brush_mask(radius)
133
+ if mask is None:
134
+ return 0, 0, 0, 0
135
+ master_area = master_layer[y_start:y_end, x_start:x_end]
136
+ source_area = source_layer[y_start:y_end, x_start:x_end]
137
+ dest_area = dest_layer[y_start:y_end, x_start:x_end]
138
+ mask_layer_area = mask_layer[y_start:y_end, x_start:x_end]
139
+ mask_area = mask[y_start - (y_center - radius):y_end - (y_center - radius), x_start - (x_center - radius):x_end - (x_center - radius)]
140
+ mask_layer_area[:] = np.clip(mask_layer_area + mask_area * self.brush.flow / 100.0, 0.0, 1.0) # np.maximum(mask_layer_area, mask_area)
141
+ self.apply_mask(master_area, source_area, mask_layer_area, dest_area)
142
+ return x_start, y_start, x_end, y_end
143
+
144
+ def get_brush_mask(self, radius):
145
+ mask_key = (radius, self.brush.hardness)
146
+ if mask_key not in self._brush_mask_cache.keys():
147
+ full_mask = create_brush_mask(size=radius * 2 + 1, hardness_percent=self.brush.hardness,
148
+ opacity_percent=self.brush.opacity)
149
+ self._brush_mask_cache[mask_key] = full_mask
150
+ return self._brush_mask_cache[mask_key]
151
+
152
+ def apply_mask(self, master_area, source_area, mask_area, dest_area):
153
+ opacity_factor = float(self.brush.opacity) / 100.0
154
+ effective_mask = np.clip(mask_area * opacity_factor, 0, 1)
155
+ dtype = master_area.dtype
156
+ max_px_value = constants.MAX_UINT16 if dtype == np.uint16 else constants.MAX_UINT8
157
+ if master_area.ndim == 3:
158
+ dest_area[:] = np.clip(master_area * (1 - effective_mask[..., np.newaxis]) + source_area * # noqa
159
+ effective_mask[..., np.newaxis], 0, max_px_value).astype(dtype)
160
+ else:
161
+ dest_area[:] = np.clip(master_area * (1 - effective_mask) + source_area * effective_mask, 0, max_px_value).astype(dtype)
162
+
163
+ def clear_cache(self):
164
+ self._brush_mask_cache.clear()
@@ -0,0 +1,56 @@
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 DenoiseFilter(BaseFilter):
7
+ def __init__(self, editor):
8
+ super().__init__(editor)
9
+ self.max_range = 500.0
10
+ self.max_value = 10.00
11
+ self.initial_value = 2.5
12
+ self.slider = None
13
+
14
+ def setup_ui(self, dlg, layout, do_preview, restore_original, **kwargs):
15
+ dlg.setWindowTitle("Denoise")
16
+ dlg.setMinimumWidth(600)
17
+ slider_layout = QHBoxLayout()
18
+ slider_local = QSlider(Qt.Horizontal)
19
+ slider_local.setRange(0, self.max_range)
20
+ slider_local.setValue(int(self.initial_value / self.max_value * self.max_range))
21
+ slider_layout.addWidget(slider_local)
22
+ value_label = QLabel(f"{self.max_value:.2f}")
23
+ slider_layout.addWidget(value_label)
24
+ layout.addLayout(slider_layout)
25
+ preview_check = QCheckBox("Preview")
26
+ preview_check.setChecked(True)
27
+ layout.addWidget(preview_check)
28
+ button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
29
+ layout.addWidget(button_box)
30
+ preview_timer = QTimer()
31
+ preview_timer.setSingleShot(True)
32
+ preview_timer.setInterval(200)
33
+
34
+ def do_preview_delayed():
35
+ preview_timer.start()
36
+
37
+ preview_timer.timeout.connect(do_preview)
38
+
39
+ def slider_changed(val):
40
+ float_val = self.max_value * float(val) / self.max_range
41
+ value_label.setText(f"{float_val:.2f}")
42
+ if preview_check.isChecked():
43
+ do_preview_delayed()
44
+
45
+ slider_local.valueChanged.connect(slider_changed)
46
+ self.editor.connect_preview_toggle(preview_check, do_preview_delayed, restore_original)
47
+ button_box.accepted.connect(dlg.accept)
48
+ button_box.rejected.connect(dlg.reject)
49
+ self.slider = slider_local
50
+
51
+ def get_params(self):
52
+ return (self.max_value * self.slider.value() / self.max_range,)
53
+
54
+ def apply(self, image, strength):
55
+ from .. algorithms.denoise import denoise
56
+ return denoise(image, strength)
@@ -0,0 +1,177 @@
1
+ import numpy as np
2
+ from PySide6.QtWidgets import QWidget, QListWidgetItem, QVBoxLayout, QLabel, QInputDialog
3
+ from PySide6.QtGui import QPixmap, QImage
4
+ from PySide6.QtCore import Qt, QObject, QTimer, QSize, Signal
5
+ from .. config.gui_constants import gui_constants
6
+
7
+
8
+ class ClickableLabel(QLabel):
9
+ double_clicked = Signal()
10
+
11
+ def __init__(self, text, parent=None):
12
+ super().__init__(text, parent)
13
+ self.setMouseTracking(True)
14
+
15
+ def mouseDoubleClickEvent(self, event):
16
+ if event.button() == Qt.LeftButton:
17
+ self.double_clicked.emit()
18
+ super().mouseDoubleClickEvent(event)
19
+
20
+
21
+ class DisplayManager(QObject):
22
+ status_message_requested = Signal(str)
23
+ cursor_preview_state_changed = Signal(bool)
24
+
25
+ def __init__(self, layer_collection, image_viewer, master_thumbnail_label,
26
+ thumbnail_list, parent=None):
27
+ super().__init__(parent)
28
+ layer_collection.add_to(self)
29
+ self.image_viewer = image_viewer
30
+ self.master_thumbnail_label = master_thumbnail_label
31
+ self.thumbnail_list = thumbnail_list
32
+ self.view_mode = 'master'
33
+ self.temp_view_individual = False
34
+ self.needs_update = False
35
+ self.update_timer = QTimer()
36
+ self.update_timer.setInterval(gui_constants.PAINT_REFRESH_TIMER)
37
+ self.update_timer.timeout.connect(self.process_pending_updates)
38
+
39
+ def process_pending_updates(self):
40
+ if self.needs_update:
41
+ self.display_master_layer()
42
+ self.needs_update = False
43
+
44
+ def display_image(self, img):
45
+ if img is None:
46
+ self.image_viewer.clear_image()
47
+ else:
48
+ self.image_viewer.set_image(self.numpy_to_qimage(img))
49
+
50
+ def display_current_layer(self):
51
+ self.display_image(self.current_layer())
52
+
53
+ def display_master_layer(self):
54
+ self.display_image(self.master_layer())
55
+
56
+ def display_current_view(self):
57
+ if self.temp_view_individual or self.view_mode == 'individual':
58
+ self.display_current_layer()
59
+ else:
60
+ self.display_master_layer()
61
+
62
+ def create_thumbnail(self, layer):
63
+ if layer.dtype == np.uint16:
64
+ layer = (layer // 256).astype(np.uint8)
65
+ height, width = layer.shape[:2]
66
+ if layer.ndim == 3 and layer.shape[-1] == 3:
67
+ qimg = QImage(layer.data, width, height, 3 * width, QImage.Format_RGB888)
68
+ else:
69
+ qimg = QImage(layer.data, width, height, width, QImage.Format_Grayscale8)
70
+ return QPixmap.fromImage(qimg.scaled(*gui_constants.UI_SIZES['thumbnail'], Qt.KeepAspectRatio))
71
+
72
+ def update_thumbnails(self):
73
+ self.update_master_thumbnail()
74
+ thumbnails = []
75
+ if self.layer_stack() is None:
76
+ return
77
+ for i, (layer, label) in enumerate(zip(self.layer_stack(), self.layer_labels())):
78
+ thumbnail = self.create_thumbnail(layer)
79
+ thumbnails.append((thumbnail, label, i, i == self.current_layer_idx()))
80
+ self._update_thumbnail_list(thumbnails)
81
+
82
+ def _update_thumbnail_list(self, thumbnails):
83
+ self.thumbnail_list.clear()
84
+ for thumb_data in thumbnails:
85
+ thumbnail, label, index, is_current = thumb_data
86
+ self.add_thumbnail_item(thumbnail, label, index, is_current)
87
+
88
+ def update_master_thumbnail(self):
89
+ if self.has_no_master_layer():
90
+ self._clear_master_thumbnail()
91
+ else:
92
+ self._set_master_thumbnail(self.create_thumbnail(self.master_layer()))
93
+
94
+ def _clear_master_thumbnail(self):
95
+ self.master_thumbnail_label.clear()
96
+
97
+ def _set_master_thumbnail(self, pixmap):
98
+ self.master_thumbnail_label.setPixmap(pixmap)
99
+
100
+ def add_thumbnail_item(self, thumbnail, label, i, is_current):
101
+ item_widget = QWidget()
102
+ layout = QVBoxLayout(item_widget)
103
+ layout.setContentsMargins(0, 0, 0, 0)
104
+ layout.setSpacing(0)
105
+
106
+ thumbnail_label = QLabel()
107
+ thumbnail_label.setPixmap(thumbnail)
108
+ thumbnail_label.setAlignment(Qt.AlignCenter)
109
+ layout.addWidget(thumbnail_label)
110
+
111
+ label_widget = ClickableLabel(label)
112
+ label_widget.setAlignment(Qt.AlignCenter)
113
+
114
+ def rename_label(label_widget, old_label, i):
115
+ new_label, ok = QInputDialog.getText(self.thumbnail_list, "Rename Label", "New label name:", text=old_label)
116
+ if ok and new_label and new_label != old_label:
117
+ label_widget.setText(new_label)
118
+ self.set_layer_labels(i, new_label)
119
+
120
+ label_widget.double_clicked.connect(lambda: rename_label(label_widget, label, i))
121
+ layout.addWidget(label_widget)
122
+ item = QListWidgetItem()
123
+ item.setSizeHint(QSize(gui_constants.IMG_WIDTH, gui_constants.IMG_HEIGHT))
124
+ self.thumbnail_list.addItem(item)
125
+ self.thumbnail_list.setItemWidget(item, item_widget)
126
+
127
+ if is_current:
128
+ self.thumbnail_list.setCurrentItem(item)
129
+
130
+ def set_view_master(self):
131
+ if self.has_no_master_layer():
132
+ return
133
+ self.view_mode = 'master'
134
+ self.temp_view_individual = False
135
+ self.display_master_layer()
136
+ self.status_message_requested.emit("View mode: Master")
137
+ self.cursor_preview_state_changed.emit(True) # True = allow preview
138
+
139
+ def set_view_individual(self):
140
+ if self.has_no_master_layer():
141
+ return
142
+ self.view_mode = 'individual'
143
+ self.temp_view_individual = False
144
+ self.display_current_layer()
145
+ self.status_message_requested.emit("View mode: Individual layers")
146
+ self.cursor_preview_state_changed.emit(False) # False = no preview
147
+
148
+ def start_temp_view(self):
149
+ if not self.temp_view_individual and self.view_mode == 'master':
150
+ self.temp_view_individual = True
151
+ self.image_viewer.update_brush_cursor()
152
+ self.display_current_layer()
153
+ self.status_message_requested.emit("Temporary view: Individual layer (hold X)")
154
+
155
+ def end_temp_view(self):
156
+ if self.temp_view_individual:
157
+ self.temp_view_individual = False
158
+ self.image_viewer.update_brush_cursor()
159
+ self.display_master_layer()
160
+ self.status_message_requested.emit("View mode: Master")
161
+ self.cursor_preview_state_changed.emit(True) # Restore preview
162
+
163
+ def numpy_to_qimage(self, array):
164
+ if array.dtype == np.uint16:
165
+ array = np.right_shift(array, 8).astype(np.uint8)
166
+ if array.ndim == 2:
167
+ height, width = array.shape
168
+ return QImage(memoryview(array), width, height, width, QImage.Format_Grayscale8)
169
+ if array.ndim == 3:
170
+ height, width, _ = array.shape
171
+ if not array.flags['C_CONTIGUOUS']:
172
+ array = np.ascontiguousarray(array)
173
+ return QImage(memoryview(array), width, height, 3 * width, QImage.Format_RGB888)
174
+ return QImage()
175
+
176
+ def allow_cursor_preview(self):
177
+ return self.view_mode == 'master' and not self.temp_view_individual
@@ -45,10 +45,11 @@ class ExifData(QDialog):
45
45
  shortcuts = {}
46
46
  if self.exif is None:
47
47
  shortcuts['Warning:'] = 'no EXIF data found'
48
+ data = {}
48
49
  else:
49
50
  data = exif_dict(self.exif)
50
51
  if len(data) > 0:
51
- for k, (t, d) in data.items():
52
+ for k, (_, d) in data.items():
52
53
  if isinstance(d, IFDRational):
53
54
  d = f"{d.numerator}/{d.denominator}"
54
55
  else:
@@ -0,0 +1,114 @@
1
+ import numpy as np
2
+ from abc import ABC, abstractmethod
3
+ from PySide6.QtWidgets import QDialog, QVBoxLayout
4
+ from PySide6.QtCore import Signal, QThread, QTimer
5
+
6
+
7
+ class BaseFilter(ABC):
8
+ def __init__(self, editor):
9
+ self.editor = editor
10
+ self.undo_label = self.__class__.__name__
11
+
12
+ @abstractmethod
13
+ def setup_ui(self, dlg, layout, do_preview, restore_original, **kwargs):
14
+ pass
15
+
16
+ @abstractmethod
17
+ def get_params(self):
18
+ pass
19
+
20
+ @abstractmethod
21
+ def apply(self, image, *params):
22
+ pass
23
+
24
+ def run_with_preview(self, **kwargs):
25
+ if self.editor.layer_collection.master_layer is None:
26
+ return
27
+
28
+ self.editor.layer_collection.copy_master_layer()
29
+ dlg = QDialog(self.editor)
30
+ layout = QVBoxLayout(dlg)
31
+ active_worker = None
32
+ last_request_id = 0
33
+
34
+ def set_preview(img, request_id, expected_id):
35
+ if request_id != expected_id:
36
+ return
37
+ self.editor.layer_collection.master_layer = img
38
+ self.editor.display_manager.display_master_layer()
39
+ try:
40
+ dlg.activateWindow()
41
+ except Exception:
42
+ pass
43
+
44
+ def do_preview():
45
+ nonlocal active_worker, last_request_id
46
+ if active_worker and active_worker.isRunning():
47
+ try:
48
+ active_worker.quit()
49
+ active_worker.wait()
50
+ except Exception:
51
+ pass
52
+ last_request_id += 1
53
+ current_id = last_request_id
54
+ params = tuple(self.get_params() or ())
55
+ worker = self.PreviewWorker(
56
+ self.apply,
57
+ args=(self.editor.layer_collection.master_layer_copy, *params),
58
+ request_id=current_id
59
+ )
60
+ active_worker = worker
61
+ active_worker.finished.connect(lambda img, rid: set_preview(img, rid, current_id))
62
+ active_worker.start()
63
+
64
+ def restore_original():
65
+ self.editor.layer_collection.master_layer = self.editor.layer_collection.master_layer_copy.copy()
66
+ self.editor.display_manager.display_master_layer()
67
+ try:
68
+ dlg.activateWindow()
69
+ except Exception:
70
+ pass
71
+
72
+ self.setup_ui(dlg, layout, do_preview, restore_original, **kwargs)
73
+ QTimer.singleShot(0, do_preview)
74
+ accepted = dlg.exec_() == QDialog.Accepted
75
+ if accepted:
76
+ params = tuple(self.get_params() or ())
77
+ try:
78
+ h, w = self.editor.layer_collection.master_layer.shape[:2]
79
+ except Exception:
80
+ h, w = self.editor.layer_collection.master_layer_copy.shape[:2]
81
+ if hasattr(self.editor, "undo_manager"):
82
+ try:
83
+ self.editor.undo_manager.extend_undo_area(0, 0, w, h)
84
+ self.editor.undo_manager.save_undo_state(
85
+ self.editor.layer_collection.master_layer_copy,
86
+ self.undo_label
87
+ )
88
+ except Exception:
89
+ pass
90
+ final_img = self.apply(self.editor.layer_collection.master_layer_copy, *params)
91
+ self.editor.layer_collection.master_layer = final_img
92
+ self.editor.layer_collection.copy_master_layer()
93
+ self.editor.display_manager.display_master_layer()
94
+ self.editor.display_manager.update_master_thumbnail()
95
+ self.editor.mark_as_modified()
96
+ else:
97
+ restore_original()
98
+
99
+ class PreviewWorker(QThread):
100
+ finished = Signal(np.ndarray, int)
101
+
102
+ def __init__(self, func, args=(), kwargs=None, request_id=0):
103
+ super().__init__()
104
+ self.func = func
105
+ self.args = args
106
+ self.kwargs = kwargs or {}
107
+ self.request_id = request_id
108
+
109
+ def run(self):
110
+ try:
111
+ result = self.func(*self.args, **self.kwargs)
112
+ except Exception:
113
+ raise
114
+ self.finished.emit(result, self.request_id)
@@ -0,0 +1,14 @@
1
+ class FilterManager:
2
+ def __init__(self, editor):
3
+ self.editor = editor
4
+ self.filters = {}
5
+
6
+ def register_filter(self, name, filter_class):
7
+ self.filters[name] = filter_class(self.editor)
8
+
9
+ def get_filter(self, name):
10
+ return self.filters.get(name)
11
+
12
+ def apply(self, name, **kwargs):
13
+ if name in self.filters:
14
+ self.filters[name].run_with_preview(**kwargs)