shinestacker 0.3.6__py3-none-any.whl → 0.5.0__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 (37) hide show
  1. shinestacker/_version.py +1 -1
  2. shinestacker/algorithms/align.py +37 -20
  3. shinestacker/algorithms/balance.py +2 -1
  4. shinestacker/algorithms/base_stack_algo.py +2 -1
  5. shinestacker/algorithms/multilayer.py +11 -8
  6. shinestacker/algorithms/noise_detection.py +13 -7
  7. shinestacker/algorithms/stack.py +5 -4
  8. shinestacker/algorithms/stack_framework.py +12 -10
  9. shinestacker/app/about_dialog.py +69 -1
  10. shinestacker/app/main.py +1 -1
  11. shinestacker/config/config.py +1 -0
  12. shinestacker/config/constants.py +8 -1
  13. shinestacker/config/gui_constants.py +7 -5
  14. shinestacker/core/framework.py +15 -10
  15. shinestacker/gui/action_config.py +11 -7
  16. shinestacker/gui/actions_window.py +8 -0
  17. shinestacker/gui/gui_logging.py +8 -7
  18. shinestacker/gui/gui_run.py +8 -8
  19. shinestacker/gui/main_window.py +17 -12
  20. shinestacker/gui/new_project.py +31 -17
  21. shinestacker/gui/project_converter.py +0 -1
  22. shinestacker/gui/select_path_widget.py +3 -1
  23. shinestacker/retouch/brush_tool.py +23 -6
  24. shinestacker/retouch/display_manager.py +57 -20
  25. shinestacker/retouch/image_editor.py +5 -9
  26. shinestacker/retouch/image_editor_ui.py +55 -16
  27. shinestacker/retouch/image_viewer.py +104 -20
  28. shinestacker/retouch/io_gui_handler.py +74 -24
  29. shinestacker/retouch/io_manager.py +23 -8
  30. shinestacker/retouch/layer_collection.py +2 -1
  31. {shinestacker-0.3.6.dist-info → shinestacker-0.5.0.dist-info}/METADATA +5 -4
  32. {shinestacker-0.3.6.dist-info → shinestacker-0.5.0.dist-info}/RECORD +36 -36
  33. shinestacker-0.5.0.dist-info/licenses/LICENSE +165 -0
  34. shinestacker-0.3.6.dist-info/licenses/LICENSE +0 -1
  35. {shinestacker-0.3.6.dist-info → shinestacker-0.5.0.dist-info}/WHEEL +0 -0
  36. {shinestacker-0.3.6.dist-info → shinestacker-0.5.0.dist-info}/entry_points.txt +0 -0
  37. {shinestacker-0.3.6.dist-info → shinestacker-0.5.0.dist-info}/top_level.txt +0 -0
@@ -23,6 +23,7 @@ def brush_size_to_slider(size):
23
23
 
24
24
  class ImageEditorUI(ImageFilters):
25
25
  def __init__(self):
26
+ self.thumbnail_highlight = gui_constants.THUMB_MASTER_HI_COLOR
26
27
  super().__init__()
27
28
  self.brush = Brush()
28
29
  self.setup_ui()
@@ -125,17 +126,21 @@ class ImageEditorUI(ImageFilters):
125
126
  }
126
127
  """)
127
128
  master_label.setAlignment(Qt.AlignCenter)
128
- master_label.setFixedHeight(gui_constants.LABEL_HEIGHT)
129
+ master_label.setFixedHeight(gui_constants.UI_SIZES['label_height'])
129
130
  side_layout.addWidget(master_label)
130
131
  self.master_thumbnail_frame = QFrame()
132
+ self.master_thumbnail_frame.setObjectName("thumbnailContainer")
133
+ self.master_thumbnail_frame.setStyleSheet(
134
+ f"#thumbnailContainer{{ border: 2px solid {self.thumbnail_highlight}; }}")
131
135
  self.master_thumbnail_frame.setFrameShape(QFrame.StyledPanel)
132
136
  master_thumbnail_layout = QVBoxLayout(self.master_thumbnail_frame)
133
- master_thumbnail_layout.setContentsMargins(2, 2, 2, 2)
137
+ master_thumbnail_layout.setContentsMargins(8, 8, 8, 8)
134
138
  self.master_thumbnail_label = QLabel()
135
139
  self.master_thumbnail_label.setAlignment(Qt.AlignCenter)
136
- self.master_thumbnail_label.setFixedSize(
137
- gui_constants.THUMB_WIDTH, gui_constants.THUMB_HEIGHT)
138
- self.master_thumbnail_label.mousePressEvent = lambda e: self.set_view_master()
140
+ self.master_thumbnail_label.setFixedWidth(
141
+ gui_constants.UI_SIZES['thumbnail_width'])
142
+ self.master_thumbnail_label.mousePressEvent = \
143
+ lambda e: self.display_manager.set_view_master()
139
144
  master_thumbnail_layout.addWidget(self.master_thumbnail_label)
140
145
  side_layout.addWidget(self.master_thumbnail_frame)
141
146
  side_layout.addSpacing(10)
@@ -151,7 +156,7 @@ class ImageEditorUI(ImageFilters):
151
156
  }
152
157
  """)
153
158
  layers_label.setAlignment(Qt.AlignCenter)
154
- layers_label.setFixedHeight(gui_constants.LABEL_HEIGHT)
159
+ layers_label.setFixedHeight(gui_constants.UI_SIZES['label_height'])
155
160
  side_layout.addWidget(layers_label)
156
161
  self.thumbnail_list = QListWidget()
157
162
  self.thumbnail_list.setFocusPolicy(Qt.StrongFocus)
@@ -203,18 +208,29 @@ class ImageEditorUI(ImageFilters):
203
208
  layout.setSpacing(2)
204
209
  super().setup_ui()
205
210
 
211
+ def highlight_master_thumbnail(self):
212
+ self.master_thumbnail_frame.setStyleSheet(
213
+ f"#thumbnailContainer{{ border: 2px solid {self.thumbnail_highlight}; }}")
214
+
206
215
  def setup_menu(self):
207
216
  menubar = self.menuBar()
208
217
  file_menu = menubar.addMenu("&File")
209
218
  file_menu.addAction("&Open...", self.io_gui_handler.open_file, "Ctrl+O")
210
- file_menu.addAction("&Save", self.io_gui_handler.save_file, "Ctrl+S")
211
- file_menu.addAction("Save &As...", self.io_gui_handler.save_file_as, "Ctrl+Shift+S")
212
- self.save_master_only = QAction("Save Master &Only", self)
213
- self.save_master_only.setCheckable(True)
214
- self.save_master_only.setChecked(True)
215
- file_menu.addAction(self.save_master_only)
216
-
217
- file_menu.addAction("&Close", self.io_gui_handler.close_file, "Ctrl+W")
219
+ self.save_action = QAction("&Save", self)
220
+ self.save_action.setShortcut("Ctrl+S")
221
+ self.save_action.triggered.connect(self.io_gui_handler.save_file)
222
+ file_menu.addAction(self.save_action)
223
+ self.save_as_action = QAction("Save &As...", self)
224
+ self.save_as_action.setShortcut("Ctrl+Shift+S")
225
+ self.save_as_action.triggered.connect(self.io_gui_handler.save_file_as)
226
+ file_menu.addAction(self.save_as_action)
227
+ self.io_gui_handler.save_master_only = QAction("Save Master &Only", self)
228
+ self.io_gui_handler.save_master_only.setCheckable(True)
229
+ self.io_gui_handler.save_master_only.setChecked(True)
230
+ file_menu.addAction(self.io_gui_handler.save_master_only)
231
+ self.save_actions_set_enabled(False)
232
+
233
+ file_menu.addAction("&Close", self.close_file, "Ctrl+W")
218
234
  file_menu.addSeparator()
219
235
  file_menu.addAction("&Import frames", self.io_gui_handler.import_frames)
220
236
  file_menu.addAction("Import &EXIF data", self.io_gui_handler.select_exif_path)
@@ -270,12 +286,12 @@ class ImageEditorUI(ImageFilters):
270
286
 
271
287
  view_master_action = QAction("View Master", self)
272
288
  view_master_action.setShortcut("M")
273
- view_master_action.triggered.connect(self.display_manager.set_view_master)
289
+ view_master_action.triggered.connect(self.set_view_master)
274
290
  view_menu.addAction(view_master_action)
275
291
 
276
292
  view_individual_action = QAction("View Individual", self)
277
293
  view_individual_action.setShortcut("L")
278
- view_individual_action.triggered.connect(self.display_manager.set_view_individual)
294
+ view_individual_action.triggered.connect(self.set_view_individual)
279
295
  view_menu.addAction(view_individual_action)
280
296
  view_menu.addSeparator()
281
297
 
@@ -333,6 +349,25 @@ class ImageEditorUI(ImageFilters):
333
349
  shortcuts_help_action.triggered.connect(self.shortcuts_help)
334
350
  help_menu.addAction(shortcuts_help_action)
335
351
 
352
+ def save_actions_set_enabled(self, enabled):
353
+ self.save_action.setEnabled(enabled)
354
+ self.save_as_action.setEnabled(enabled)
355
+ self.io_gui_handler.save_master_only.setEnabled(enabled)
356
+
357
+ def close_file(self):
358
+ self.io_gui_handler.close_file()
359
+ self.save_actions_set_enabled(False)
360
+
361
+ def set_view_master(self):
362
+ self.display_manager.set_view_master()
363
+ self.thumbnail_highlight = gui_constants.THUMB_MASTER_HI_COLOR
364
+ self.highlight_master_thumbnail()
365
+
366
+ def set_view_individual(self):
367
+ self.display_manager.set_view_individual()
368
+ self.thumbnail_highlight = gui_constants.THUMB_MASTER_LO_COLOR
369
+ self.highlight_master_thumbnail()
370
+
336
371
  def shortcuts_help(self):
337
372
  self._dialog = ShortcutsHelp(self)
338
373
  self._dialog.exec()
@@ -364,8 +399,12 @@ class ImageEditorUI(ImageFilters):
364
399
  def handle_temp_view(self, start):
365
400
  if start:
366
401
  self.display_manager.start_temp_view()
402
+ self.thumbnail_highlight = gui_constants.THUMB_MASTER_LO_COLOR
403
+ self.highlight_master_thumbnail()
367
404
  else:
368
405
  self.display_manager.end_temp_view()
406
+ self.thumbnail_highlight = gui_constants.THUMB_MASTER_HI_COLOR
407
+ self.highlight_master_thumbnail()
369
408
 
370
409
  def handle_brush_size_change(self, delta):
371
410
  if delta > 0:
@@ -1,8 +1,8 @@
1
- # pylint: disable=C0114, C0115, C0116, E0611, R0904, R0902, R0914
1
+ # pylint: disable=C0114, C0115, C0116, E0611, R0904, R0902, R0914, R0912
2
2
  import math
3
3
  from PySide6.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsPixmapItem
4
4
  from PySide6.QtGui import QPixmap, QPainter, QColor, QPen, QBrush, QCursor, QShortcut, QKeySequence
5
- from PySide6.QtCore import Qt, QRectF, QTime, QPoint, QPointF, Signal
5
+ from PySide6.QtCore import Qt, QRectF, QTime, QPoint, QPointF, Signal, QEvent
6
6
  from .. config.gui_constants import gui_constants
7
7
  from .brush_preview import BrushPreviewItem
8
8
  from .brush_gradient import create_default_brush_gradient
@@ -52,6 +52,13 @@ class ImageViewer(QGraphicsView, LayerCollectionHandler):
52
52
  self.empty = True
53
53
  self.allow_cursor_preview = True
54
54
  self.last_brush_pos = None
55
+ self.grabGesture(Qt.PanGesture)
56
+ self.grabGesture(Qt.PinchGesture)
57
+ self.pinch_start_scale = 1.0
58
+ self.last_scroll_pos = QPointF()
59
+ self.gesture_active = False
60
+ self.pinch_center_view = None
61
+ self.pinch_center_scene = None
55
62
 
56
63
  def set_image(self, qimage):
57
64
  pixmap = QPixmap.fromImage(qimage)
@@ -193,25 +200,44 @@ class ImageViewer(QGraphicsView, LayerCollectionHandler):
193
200
  super().mouseReleaseEvent(event)
194
201
 
195
202
  def wheelEvent(self, event):
196
- if self.empty:
203
+ if self.empty or self.gesture_active:
197
204
  return
198
- if self.control_pressed:
199
- self.brush_size_change_requested.emit(1 if event.angleDelta().y() > 0 else -1)
200
- else:
201
- zoom_in_factor = 1.10
202
- zoom_out_factor = 1 / zoom_in_factor
203
- current_scale = self.get_current_scale()
204
- if event.angleDelta().y() > 0: # Zoom in
205
- new_scale = current_scale * zoom_in_factor
206
- if new_scale <= self.max_scale:
207
- self.scale(zoom_in_factor, zoom_in_factor)
208
- self.zoom_factor = new_scale
209
- else: # Zoom out
210
- new_scale = current_scale * zoom_out_factor
211
- if new_scale >= self.min_scale:
212
- self.scale(zoom_out_factor, zoom_out_factor)
213
- self.zoom_factor = new_scale
214
- self.update_brush_cursor()
205
+ if event.source() == Qt.MouseEventNotSynthesized: # Physical mouse
206
+ if self.control_pressed:
207
+ self.brush_size_change_requested.emit(1 if event.angleDelta().y() > 0 else -1)
208
+ else:
209
+ zoom_in_factor = 1.10
210
+ zoom_out_factor = 1 / zoom_in_factor
211
+ current_scale = self.get_current_scale()
212
+ if event.angleDelta().y() > 0: # Zoom in
213
+ new_scale = current_scale * zoom_in_factor
214
+ if new_scale <= self.max_scale:
215
+ self.scale(zoom_in_factor, zoom_in_factor)
216
+ self.zoom_factor = new_scale
217
+ else: # Zoom out
218
+ new_scale = current_scale * zoom_out_factor
219
+ if new_scale >= self.min_scale:
220
+ self.scale(zoom_out_factor, zoom_out_factor)
221
+ self.zoom_factor = new_scale
222
+ self.update_brush_cursor()
223
+ else: # Touchpad event - fallback for systems without gesture recognition
224
+ # Handle touchpad panning (two-finger scroll)
225
+ if not self.control_pressed:
226
+ delta = event.pixelDelta() or event.angleDelta() / 8
227
+ if delta:
228
+ self.horizontalScrollBar().setValue(
229
+ self.horizontalScrollBar().value() - delta.x()
230
+ )
231
+ self.verticalScrollBar().setValue(
232
+ self.verticalScrollBar().value() - delta.y()
233
+ )
234
+ else: # Control + touchpad scroll for zoom
235
+ zoom_in = event.angleDelta().y() > 0
236
+ if zoom_in:
237
+ self.zoom_in()
238
+ else:
239
+ self.zoom_out()
240
+ event.accept()
215
241
 
216
242
  def enterEvent(self, event):
217
243
  self.activateWindow()
@@ -230,6 +256,64 @@ class ImageViewer(QGraphicsView, LayerCollectionHandler):
230
256
  super().leaveEvent(event)
231
257
  # pylint: enable=C0103
232
258
 
259
+ def event(self, event):
260
+ if event.type() == QEvent.Gesture:
261
+ return self.handle_gesture_event(event)
262
+ return super().event(event)
263
+
264
+ def handle_gesture_event(self, event):
265
+ handled = False
266
+ pan_gesture = event.gesture(Qt.PanGesture)
267
+ if pan_gesture:
268
+ self.handle_pan_gesture(pan_gesture)
269
+ handled = True
270
+ pinch_gesture = event.gesture(Qt.PinchGesture)
271
+ if pinch_gesture:
272
+ self.handle_pinch_gesture(pinch_gesture)
273
+ handled = True
274
+ if handled:
275
+ event.accept()
276
+ return True
277
+ return False
278
+
279
+ def handle_pan_gesture(self, pan_gesture):
280
+ if pan_gesture.state() == Qt.GestureStarted:
281
+ self.last_scroll_pos = pan_gesture.delta()
282
+ self.gesture_active = True
283
+ elif pan_gesture.state() == Qt.GestureUpdated:
284
+ delta = pan_gesture.delta() - self.last_scroll_pos
285
+ self.last_scroll_pos = pan_gesture.delta()
286
+ zoom_factor = self.get_current_scale()
287
+ scaled_delta = delta * (1.0 / zoom_factor)
288
+ self.horizontalScrollBar().setValue(
289
+ self.horizontalScrollBar().value() - int(scaled_delta.x())
290
+ )
291
+ self.verticalScrollBar().setValue(
292
+ self.verticalScrollBar().value() - int(scaled_delta.y())
293
+ )
294
+ elif pan_gesture.state() == Qt.GestureFinished:
295
+ self.gesture_active = False
296
+
297
+ def handle_pinch_gesture(self, pinch):
298
+ if pinch.state() == Qt.GestureStarted:
299
+ self.pinch_start_scale = self.get_current_scale()
300
+ self.pinch_center_view = pinch.centerPoint()
301
+ self.pinch_center_scene = self.mapToScene(self.pinch_center_view.toPoint())
302
+ self.gesture_active = True
303
+ elif pinch.state() == Qt.GestureUpdated:
304
+ new_scale = self.pinch_start_scale * pinch.totalScaleFactor()
305
+ new_scale = max(self.min_scale, min(new_scale, self.max_scale))
306
+ if abs(new_scale - self.get_current_scale()) > 0.01:
307
+ self.resetTransform()
308
+ self.scale(new_scale, new_scale)
309
+ self.zoom_factor = new_scale
310
+ new_center = self.mapToScene(self.pinch_center_view.toPoint())
311
+ delta = self.pinch_center_scene - new_center
312
+ self.translate(delta.x(), delta.y())
313
+ self.update_brush_cursor()
314
+ elif pinch.state() in (Qt.GestureFinished, Qt.GestureCanceled):
315
+ self.gesture_active = False
316
+
233
317
  def setup_brush_cursor(self):
234
318
  self.setCursor(Qt.BlankCursor)
235
319
  pen = QPen(QColor(*gui_constants.BRUSH_COLORS['pen']), 1)
@@ -1,4 +1,5 @@
1
1
  # pylint: disable=C0114, C0115, C0116, E0611, R0902, W0718
2
+ import os
2
3
  import traceback
3
4
  import numpy as np
4
5
  from PySide6.QtWidgets import QFileDialog, QMessageBox, QVBoxLayout, QLabel, QDialog, QApplication
@@ -6,7 +7,7 @@ from PySide6.QtGui import QGuiApplication, QCursor
6
7
  from PySide6.QtCore import Qt, QObject, QTimer, Signal
7
8
  from .file_loader import FileLoader
8
9
  from .exif_data import ExifData
9
- from .io_manager import IOManager
10
+ from .io_manager import IOManager, FileMultilayerSaver
10
11
  from .layer_collection import LayerCollectionHandler
11
12
 
12
13
 
@@ -23,11 +24,19 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
23
24
  self.loader_thread = None
24
25
  self.display_manager = None
25
26
  self.image_viewer = None
26
- self.modified = None
27
27
  self.blank_layer = None
28
28
  self.loading_dialog = None
29
29
  self.loading_timer = None
30
30
  self.exif_dialog = None
31
+ self.saver_thread = None
32
+ self.saving_dialog = None
33
+ self.saving_timer = None
34
+ self.current_file_path_master = ''
35
+ self.current_file_path_multi = ''
36
+
37
+ def current_file_path(self):
38
+ return self.current_file_path_master if self.save_master_only.isChecked() \
39
+ else self.current_file_path_multi
31
40
 
32
41
  def setup_ui(self, display_manager, image_viewer):
33
42
  self.display_manager = display_manager
@@ -43,16 +52,18 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
43
52
  else:
44
53
  self.set_layer_labels(labels)
45
54
  self.set_master_layer(master_layer)
46
- self.modified = False
47
55
  self.undo_manager.reset()
48
56
  self.blank_layer = np.zeros(master_layer.shape[:2])
49
57
  self.display_manager.update_thumbnails()
50
58
  self.image_viewer.setup_brush_cursor()
51
- self.parent().change_layer(0)
52
59
  self.image_viewer.reset_zoom()
53
- self.status_message_requested.emit(f"Loaded: {self.io_manager.current_file_path}")
54
- self.parent().thumbnail_list.setFocus()
60
+ self.status_message_requested.emit(f"Loaded: {self.current_file_path()}")
55
61
  self.update_title_requested.emit()
62
+ self.current_file_path_master = ''
63
+ self.current_file_path_multi = ''
64
+ self.parent().mark_as_modified()
65
+ self.parent().change_layer(0)
66
+ self.parent().thumbnail_list.setFocus()
56
67
 
57
68
  def on_file_error(self, error_msg):
58
69
  QApplication.restoreOverrideCursor()
@@ -60,7 +71,23 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
60
71
  self.loading_dialog.accept()
61
72
  self.loading_dialog.deleteLater()
62
73
  QMessageBox.critical(self.parent(), "Error", error_msg)
63
- self.status_message_requested.emit(f"Error loading: {self.io_manager.current_file_path}")
74
+ self.status_message_requested.emit(f"Error loading: {self.current_file_path()}")
75
+
76
+ def on_multilayer_save_success(self):
77
+ QApplication.restoreOverrideCursor()
78
+ self.saving_timer.stop()
79
+ self.saving_dialog.hide()
80
+ self.saving_dialog.deleteLater()
81
+ self.parent().modified = False
82
+ self.update_title_requested.emit()
83
+ self.status_message_requested.emit(f"Saved multilayer to: {self.current_file_path_multi}")
84
+
85
+ def on_multilayer_save_error(self, error_msg):
86
+ QApplication.restoreOverrideCursor()
87
+ self.saving_timer.stop()
88
+ self.saving_dialog.hide()
89
+ self.saving_dialog.deleteLater()
90
+ QMessageBox.critical(self.parent(), "Save Error", f"Could not save file: {error_msg}")
64
91
 
65
92
  def open_file(self, file_paths=None):
66
93
  if file_paths is None:
@@ -76,7 +103,8 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
76
103
  self.import_frames_from_files(file_paths)
77
104
  return
78
105
  path = file_paths[0] if isinstance(file_paths, list) else file_paths
79
- self.io_manager.current_file_path = path
106
+ self.current_file_path_master = os.path.abspath(path)
107
+ self.current_file_path_multi = os.path.abspath(path)
80
108
  QGuiApplication.setOverrideCursor(QCursor(Qt.BusyCursor))
81
109
  self.loading_dialog = QDialog(self.parent())
82
110
  self.loading_dialog.setWindowTitle("Loading")
@@ -128,13 +156,13 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
128
156
  self.display_manager.update_thumbnails()
129
157
 
130
158
  def save_file(self):
131
- if self.parent().save_master_only.isChecked():
159
+ if self.save_master_only.isChecked():
132
160
  self.save_master()
133
161
  else:
134
162
  self.save_multilayer()
135
163
 
136
164
  def save_file_as(self):
137
- if self.parent().save_master_only.isChecked():
165
+ if self.save_master_only.isChecked():
138
166
  self.save_master_as()
139
167
  else:
140
168
  self.save_multilayer_as()
@@ -142,11 +170,13 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
142
170
  def save_multilayer(self):
143
171
  if self.layer_stack() is None:
144
172
  return
145
- if self.io_manager.current_file_path != '':
146
- extension = self.io_manager.current_file_path.split('.')[-1]
173
+ if self.current_file_path_multi != '':
174
+ extension = self.current_file_path_multi.split('.')[-1]
147
175
  if extension in ['tif', 'tiff']:
148
- self.save_multilayer_to_path(self.io_manager.current_file_path)
176
+ self.save_multilayer_to_path(self.current_file_path_multi)
149
177
  return
178
+ else:
179
+ self.save_multilayer_as()
150
180
 
151
181
  def save_multilayer_as(self):
152
182
  if self.layer_stack() is None:
@@ -160,11 +190,30 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
160
190
 
161
191
  def save_multilayer_to_path(self, path):
162
192
  try:
163
- self.io_manager.save_multilayer(path)
164
- self.io_manager.current_file_path = path
165
- self.modified = False
166
- self.update_title_requested.emit()
167
- self.status_message_requested.emit(f"Saved multilayer to: {path}")
193
+ master_layer = {'Master': self.master_layer().copy()}
194
+ individual_layers = dict(zip(
195
+ self.layer_labels(),
196
+ [layer.copy() for layer in self.layer_stack()]
197
+ ))
198
+ images_dict = {**master_layer, **individual_layers}
199
+ self.saver_thread = FileMultilayerSaver(
200
+ images_dict, path, exif_path=self.io_manager.exif_path)
201
+ self.saver_thread.finished.connect(self.on_multilayer_save_success)
202
+ self.saver_thread.error.connect(self.on_multilayer_save_error)
203
+ QGuiApplication.setOverrideCursor(QCursor(Qt.BusyCursor))
204
+ self.saving_dialog = QDialog(self.parent())
205
+ self.saving_dialog.setWindowTitle("Saving")
206
+ self.saving_dialog.setWindowFlags(Qt.Window | Qt.FramelessWindowHint)
207
+ self.saving_dialog.setModal(True)
208
+ layout = QVBoxLayout()
209
+ layout.addWidget(QLabel("Saving file..."))
210
+ self.saving_dialog.setLayout(layout)
211
+ self.saving_timer = QTimer()
212
+ self.saving_timer.setSingleShot(True)
213
+ self.saving_timer.timeout.connect(self.saving_dialog.show)
214
+ self.saving_timer.start(100)
215
+ self.saver_thread.start()
216
+
168
217
  except Exception as e:
169
218
  traceback.print_tb(e.__traceback__)
170
219
  QMessageBox.critical(self.parent(), "Save Error", f"Could not save file: {str(e)}")
@@ -172,8 +221,8 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
172
221
  def save_master(self):
173
222
  if self.master_layer() is None:
174
223
  return
175
- if self.io_manager.current_file_path != '':
176
- self.save_master_to_path(self.io_manager.current_file_path)
224
+ if self.current_file_path_master != '':
225
+ self.save_master_to_path(self.current_file_path_master)
177
226
  return
178
227
  self.save_master_as()
179
228
 
@@ -189,8 +238,8 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
189
238
  def save_master_to_path(self, path):
190
239
  try:
191
240
  self.io_manager.save_master(path)
192
- self.io_manager.current_file_path = path
193
- self.modified = False
241
+ self.current_file_path_master = os.path.abspath(path)
242
+ self.parent().modified = False
194
243
  self.update_title_requested.emit()
195
244
  self.status_message_requested.emit(f"Saved master layer to: {path}")
196
245
  except Exception as e:
@@ -210,8 +259,9 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
210
259
  self.set_master_layer(None)
211
260
  self.blank_layer = None
212
261
  self.layer_collection.reset()
213
- self.io_manager.current_file_path = ''
214
- self.modified = False
262
+ self.current_file_path_master = ''
263
+ self.current_file_path_multi = ''
264
+ self.parent().modified = False
215
265
  self.undo_manager.reset()
216
266
  self.image_viewer.clear_image()
217
267
  self.display_manager.thumbnail_list.clear()
@@ -1,15 +1,36 @@
1
- # pylint: disable=E1101, C0114, C0115, C0116
1
+ # pylint: disable=E1101, C0114, C0115, C0116, E0611, W0718, R0903
2
+ import traceback
2
3
  import cv2
4
+ from PySide6.QtCore import QThread, Signal
3
5
  from .. algorithms.utils import read_img, validate_image, get_img_metadata
4
6
  from .. algorithms.exif import get_exif, write_image_with_exif_data
5
7
  from .. algorithms.multilayer import write_multilayer_tiff_from_images
6
8
  from .layer_collection import LayerCollectionHandler
7
9
 
8
10
 
11
+ class FileMultilayerSaver(QThread):
12
+ finished = Signal()
13
+ error = Signal(str)
14
+
15
+ def __init__(self, images_dict, path, exif_path=None):
16
+ super().__init__()
17
+ self.images_dict = images_dict
18
+ self.path = path
19
+ self.exif_path = exif_path
20
+
21
+ def run(self):
22
+ try:
23
+ write_multilayer_tiff_from_images(
24
+ self.images_dict, self.path, exif_path=self.exif_path)
25
+ self.finished.emit()
26
+ except Exception as e:
27
+ traceback.print_tb(e.__traceback__)
28
+ self.error.emit(str(e))
29
+
30
+
9
31
  class IOManager(LayerCollectionHandler):
10
32
  def __init__(self, layer_collection):
11
33
  super().__init__(layer_collection)
12
- self.current_file_path = ''
13
34
  self.exif_path = ''
14
35
  self.exif_data = None
15
36
 
@@ -39,12 +60,6 @@ class IOManager(LayerCollectionHandler):
39
60
  raise RuntimeError(f"Error loading file: {path}.\n{str(e)}") from e
40
61
  return stack, labels, master
41
62
 
42
- def save_multilayer(self, path):
43
- master_layer = {'Master': self.master_layer()}
44
- individual_layers = dict(zip(self.layer_labels(), self.layer_stack()))
45
- write_multilayer_tiff_from_images({**master_layer, **individual_layers},
46
- path, exif_path=self.exif_path)
47
-
48
63
  def save_master(self, path):
49
64
  img = cv2.cvtColor(self.master_layer(), cv2.COLOR_RGB2BGR)
50
65
  write_image_with_exif_data(self.exif_data, img, path)
@@ -80,7 +80,8 @@ class LayerCollection:
80
80
  master_label = None
81
81
  master_layer = None
82
82
  for i, label in enumerate(self.layer_labels):
83
- if label.lower() == "master":
83
+ label_lower = label.lower()
84
+ if "master" in label_lower or "stack" in label_lower:
84
85
  master_index = i
85
86
  master_label = self.layer_labels.pop(i)
86
87
  master_layer = self.layer_stack[i]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shinestacker
3
- Version: 0.3.6
3
+ Version: 0.5.0
4
4
  Summary: ShineStacker
5
5
  Author-email: Luca Lista <luka.lista@gmail.com>
6
6
  License-Expression: LGPL-3.0
@@ -37,10 +37,10 @@ Dynamic: license-file
37
37
  [![PyPI version](https://img.shields.io/pypi/v/shinestacker?color=success)](https://pypi.org/project/shinestacker/)
38
38
  [![Python Versions](https://img.shields.io/pypi/pyversions/shinestacker)](https://pypi.org/project/shinestacker/)
39
39
  [![Qt Versions](https://img.shields.io/badge/Qt-6-blue.svg?&logo=Qt&logoWidth=18&logoColor=white)](https://www.qt.io/qt-for-python)
40
- [![pylint](https://img.shields.io/badge/PyLint-9.98-yellow?logo=python&logoColor=white)](https://github.com/lucalista/shinestacker/blob/main/.github/workflows/pylint.yml)
40
+ [![pylint](https://img.shields.io/badge/PyLint-10.00-brightgreen?logo=python&logoColor=white)](https://github.com/lucalista/shinestacker/blob/main/.github/workflows/pylint.yml)
41
41
  [![codecov](https://codecov.io/github/lucalista/shinestacker/graph/badge.svg?token=Y5NKW6VH5G)](https://codecov.io/github/lucalista/shinestacker)
42
42
  [![Documentation Status](https://readthedocs.org/projects/shinestacker/badge/?version=latest)](https://shinestacker.readthedocs.io/en/latest/?badge=latest)
43
-
43
+ [![License: LGPL v3](https://img.shields.io/badge/License-LGPL_v3-blue.svg)](https://www.gnu.org/licenses/lgpl-3.0)
44
44
 
45
45
  <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">
46
46
 
@@ -83,7 +83,8 @@ Pyramid methods in image processing
83
83
 
84
84
  # License
85
85
 
86
- The software is provided as is under the [GNU Lesser General Public License v3.0](https://choosealicense.com/licenses/lgpl-3.0/).
86
+ <img src="https://www.gnu.org/graphics/lgplv3-147x51.png" alt="LGPL 3 logo">
87
+ The software is provided as is under the [GNU Lesser General Public License v3.0](https://www.gnu.org/licenses/lgpl-3.0.en.html). See [LICENSE](https://github.com/lucalista/shinestacker/blob/main/LICENSE) for details.
87
88
 
88
89
  # Attribution request
89
90
  📸 If you publish images created with Shine Stacker, please consider adding a note such as: