shinestacker 1.8.0__py3-none-any.whl → 1.9.3__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 (46) hide show
  1. shinestacker/_version.py +1 -1
  2. shinestacker/algorithms/align.py +202 -81
  3. shinestacker/algorithms/align_auto.py +13 -11
  4. shinestacker/algorithms/align_parallel.py +50 -21
  5. shinestacker/algorithms/balance.py +1 -1
  6. shinestacker/algorithms/base_stack_algo.py +1 -1
  7. shinestacker/algorithms/exif.py +848 -127
  8. shinestacker/algorithms/multilayer.py +6 -4
  9. shinestacker/algorithms/noise_detection.py +10 -8
  10. shinestacker/algorithms/pyramid_tiles.py +1 -1
  11. shinestacker/algorithms/stack.py +33 -17
  12. shinestacker/algorithms/stack_framework.py +16 -11
  13. shinestacker/algorithms/utils.py +18 -2
  14. shinestacker/algorithms/vignetting.py +16 -3
  15. shinestacker/app/main.py +1 -1
  16. shinestacker/app/settings_dialog.py +297 -173
  17. shinestacker/config/constants.py +10 -6
  18. shinestacker/config/settings.py +25 -7
  19. shinestacker/core/exceptions.py +1 -1
  20. shinestacker/core/framework.py +2 -2
  21. shinestacker/gui/action_config.py +23 -20
  22. shinestacker/gui/action_config_dialog.py +38 -25
  23. shinestacker/gui/config_dialog.py +6 -5
  24. shinestacker/gui/folder_file_selection.py +3 -2
  25. shinestacker/gui/gui_images.py +27 -3
  26. shinestacker/gui/gui_run.py +2 -2
  27. shinestacker/gui/main_window.py +6 -0
  28. shinestacker/gui/menu_manager.py +8 -2
  29. shinestacker/gui/new_project.py +23 -12
  30. shinestacker/gui/project_controller.py +14 -6
  31. shinestacker/gui/project_editor.py +12 -2
  32. shinestacker/gui/project_model.py +4 -4
  33. shinestacker/retouch/brush_tool.py +20 -0
  34. shinestacker/retouch/exif_data.py +106 -38
  35. shinestacker/retouch/file_loader.py +3 -3
  36. shinestacker/retouch/image_editor_ui.py +79 -3
  37. shinestacker/retouch/image_viewer.py +6 -1
  38. shinestacker/retouch/io_gui_handler.py +13 -16
  39. shinestacker/retouch/shortcuts_help.py +15 -8
  40. shinestacker/retouch/view_strategy.py +12 -2
  41. {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/METADATA +37 -39
  42. {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/RECORD +46 -46
  43. {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/WHEEL +0 -0
  44. {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/entry_points.txt +0 -0
  45. {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/licenses/LICENSE +0 -0
  46. {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/top_level.txt +0 -0
@@ -1,52 +1,120 @@
1
- # pylint: disable=C0114, C0115, C0116, E0611
1
+ # pylint: disable=C0114, C0115, C0116, E0611, W0718, R0912
2
+ from fractions import Fraction
3
+ from xml.dom import minidom
2
4
  from PIL.TiffImagePlugin import IFDRational
3
- from PySide6.QtWidgets import QWidget, QHBoxLayout, QPushButton, QLabel
5
+ from PySide6.QtWidgets import QLabel, QTextEdit
4
6
  from PySide6.QtCore import Qt
7
+ from PySide6.QtGui import QFontDatabase
5
8
  from .. algorithms.exif import exif_dict
6
- from .icon_container import icon_container
7
- from .. gui.base_form_dialog import BaseFormDialog
9
+ from .. gui.config_dialog import ConfigDialog
8
10
 
9
11
 
10
- class ExifData(BaseFormDialog):
11
- def __init__(self, exif, parent=None):
12
- super().__init__("EXIF data", parent=parent)
12
+ class ExifData(ConfigDialog):
13
+ def __init__(self, exif, title="EXIF Data", parent=None, show_buttons=True):
13
14
  self.exif = exif
14
- self.create_form()
15
- button_container = QWidget()
16
- button_layout = QHBoxLayout(button_container)
17
- button_layout.setAlignment(Qt.AlignCenter)
18
- ok_button = QPushButton("OK")
19
- ok_button.setFixedWidth(100)
20
- ok_button.setFocus()
21
- button_layout.addWidget(ok_button)
22
- self.add_row_to_layout(button_container)
23
- ok_button.clicked.connect(self.accept)
24
-
25
- def add_bold_label(self, label):
26
- label = QLabel(label)
27
- label.setStyleSheet("font-weight: bold")
28
- self.form_layout.addRow(label)
29
-
30
- def create_form(self):
31
- self.form_layout.addRow(icon_container())
32
-
33
- spacer = QLabel("")
34
- spacer.setFixedHeight(10)
35
- self.form_layout.addRow(spacer)
36
- self.add_bold_label("EXIF data")
37
- shortcuts = {}
15
+ super().__init__(title, parent)
16
+ self.reset_button.setVisible(False)
17
+ self.cancel_button.setVisible(show_buttons)
18
+ if not show_buttons:
19
+ self.ok_button.setFixedWidth(100)
20
+ self.button_box.setAlignment(Qt.AlignCenter)
21
+
22
+ def format_aperture(self, value):
23
+ if isinstance(value, IFDRational):
24
+ if value.denominator == 0:
25
+ return "f/>1024"
26
+ aperture_value = value.numerator / value.denominator
27
+ return f"f/{aperture_value:.1f}"
28
+ if isinstance(value, (int, float)):
29
+ return f"f/{float(value):.1f}"
30
+ return str(value)
31
+
32
+ def format_exposure_time(self, value):
33
+ if isinstance(value, IFDRational):
34
+ exposure_time = value.numerator / value.denominator
35
+ elif isinstance(value, (int, float)):
36
+ exposure_time = float(value)
37
+ else:
38
+ return str(value)
39
+ if exposure_time >= 0.5:
40
+ return f"{exposure_time:.1f} s"
41
+ if isinstance(value, IFDRational):
42
+ return f"{value.numerator}/{value.denominator} s"
43
+ frac = Fraction(exposure_time).limit_denominator(1000)
44
+ return f"{frac.numerator}/{frac.denominator} s"
45
+
46
+ def format_date_time(self, value):
47
+ if not isinstance(value, str):
48
+ return str(value)
49
+ try:
50
+ if ':' in value and ' ' in value:
51
+ date_part, time_part = value.split(' ', 1)
52
+ year, month, day = date_part.split(':', 2)
53
+ return f"{day}/{month}/{year} {time_part}"
54
+ return value
55
+ except (ValueError, IndexError):
56
+ return value
57
+
58
+ def is_likely_xml(self, text):
59
+ if not isinstance(text, str):
60
+ return False
61
+ text = text.strip()
62
+ return (text.startswith('<?xml') or
63
+ text.startswith('<x:xmpmeta') or
64
+ text.startswith('<rdf:RDF') or
65
+ text.startswith('<?xpacket') or
66
+ (text.startswith('<') and text.endswith('>') and
67
+ any(tag in text for tag in ['<rdf:', '<xmp:', '<dc:', '<tiff:'])))
68
+
69
+ def prettify_xml(self, xml_string):
70
+ try:
71
+ parsed = minidom.parseString(xml_string)
72
+ pretty_xml = parsed.toprettyxml(indent=" ")
73
+ lines = [line for line in pretty_xml.split('\n') if line.strip()]
74
+ if lines and lines[0].startswith('<?xml version="1.0" ?>'):
75
+ lines = lines[1:]
76
+ return '\n'.join(lines)
77
+ except Exception:
78
+ return xml_string
79
+
80
+ def create_form_content(self):
38
81
  if self.exif is None:
39
- shortcuts['Warning:'] = 'no EXIF data found'
40
82
  data = {}
41
83
  else:
42
84
  data = exif_dict(self.exif)
43
85
  if len(data) > 0:
44
86
  for k, (_, d) in data.items():
45
- if isinstance(d, IFDRational):
46
- d = f"{d.numerator}/{d.denominator}"
87
+ if k in ['FNumber', 'ApertureValue']:
88
+ display_value = self.format_aperture(d)
89
+ elif k in ['ExposureTime', 'ShutterSpeedValue']:
90
+ display_value = self.format_exposure_time(d)
91
+ elif k in ['DateTime', 'DateTimeOriginal', 'DateTimeDigitized']:
92
+ display_value = self.format_date_time(d)
93
+ elif isinstance(d, IFDRational):
94
+ display_value = f"{d.numerator}/{d.denominator}"
47
95
  else:
48
- d = f"{d}"
49
- if "<<<" not in d and k != 'IPTCNAA':
50
- self.form_layout.addRow(f"<b>{k}:</b>", QLabel(d))
96
+ display_value = str(d)
97
+ d_str = display_value
98
+ if "<<<" not in d_str and k != 'IPTCNAA':
99
+ if len(d_str) <= 40:
100
+ self.container_layout.addRow(f"<b>{k}:</b>", QLabel(d_str))
101
+ else:
102
+ if self.is_likely_xml(d_str):
103
+ d_str = self.prettify_xml(d_str)
104
+ text_edit = QTextEdit()
105
+ text_edit.setPlainText(d_str)
106
+ text_edit.setReadOnly(True)
107
+ text_edit.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
108
+ text_edit.setLineWrapMode(QTextEdit.WidgetWidth)
109
+ text_edit.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
110
+ text_edit.setFixedWidth(400)
111
+ font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
112
+ font.setPointSize(10)
113
+ text_edit.setFont(font)
114
+ font.setPointSize(11)
115
+ text_edit.setFont(font)
116
+ text_edit.setFixedHeight(200)
117
+ text_edit.setFixedHeight(100)
118
+ self.container_layout.addRow(f"<b>{k}:</b>", text_edit)
51
119
  else:
52
- self.form_layout.addRow("-", QLabel("Empty EXIF dictionary"))
120
+ self.container_layout.addRow("No EXIF Data", QLabel(''))
@@ -5,7 +5,7 @@ import numpy as np
5
5
  import cv2
6
6
  from psdtags import PsdChannelId
7
7
  from PySide6.QtCore import QThread, Signal
8
- from .. algorithms.utils import read_img, extension_tif, extension_jpg
8
+ from .. algorithms.utils import read_img, extension_tif, extension_jpg, extension_png
9
9
  from .. algorithms.multilayer import read_multilayer_tiff
10
10
 
11
11
 
@@ -50,10 +50,10 @@ class FileLoader(QThread):
50
50
  raise RuntimeError(f"Path {path} does not exist.")
51
51
  if not os.path.isfile(path):
52
52
  raise RuntimeError(f"Path {path} is not a file.")
53
- if extension_jpg(path):
53
+ if extension_jpg(path) or extension_png(path):
54
54
  try:
55
55
  stack = np.array([cv2.cvtColor(read_img(path), cv2.COLOR_BGR2RGB)])
56
- return stack, [path.split('/')[-1].split('.')[0]]
56
+ return stack, [os.path.splitext(os.path.basename(path))[0]]
57
57
  except Exception as e:
58
58
  traceback.print_tb(e.__traceback__)
59
59
  return None, None
@@ -1,7 +1,8 @@
1
- # pylint: disable=C0114, C0115, C0116, E0611, R0902, R0914, R0915, R0904, W0108
1
+ # pylint: disable=C0114, C0115, C0116, E0611, R0902, R0914, R0915, R0904, W0108, R0911
2
2
  from functools import partial
3
3
  from PySide6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QFrame, QLabel, QMenu,
4
- QListWidget, QSlider, QMainWindow, QMessageBox)
4
+ QFileDialog, QListWidget, QSlider, QMainWindow, QMessageBox,
5
+ QDialog)
5
6
  from PySide6.QtGui import QShortcut, QKeySequence, QAction, QActionGroup
6
7
  from PySide6.QtCore import Qt
7
8
  from PySide6.QtGui import QGuiApplication
@@ -9,6 +10,7 @@ from .. config.constants import constants
9
10
  from .. config.app_config import AppConfig
10
11
  from .. config.gui_constants import gui_constants
11
12
  from .. gui.recent_file_manager import RecentFileManager
13
+ from .. algorithms.exif import get_exif
12
14
  from .image_viewer import ImageViewer
13
15
  from .shortcuts_help import ShortcutsHelp
14
16
  from .brush import Brush
@@ -26,6 +28,7 @@ from .white_balance_filter import WhiteBalanceFilter
26
28
  from .vignetting_filter import VignettingFilter
27
29
  from .adjustments import LumiContrastFilter, SaturationVibranceFilter
28
30
  from .transformation_manager import TransfromationManager
31
+ from .exif_data import ExifData
29
32
 
30
33
 
31
34
  class ImageEditorUI(QMainWindow, LayerCollectionHandler):
@@ -57,6 +60,9 @@ class ImageEditorUI(QMainWindow, LayerCollectionHandler):
57
60
  self.handle_temp_view,
58
61
  self.end_copy_brush_area,
59
62
  self.handle_brush_size_change,
63
+ self.handle_brush_hardness_change,
64
+ self.handle_brush_opacity_change,
65
+ self.handle_brush_flow_change,
60
66
  self.handle_needs_update)
61
67
  side_panel = QWidget()
62
68
  side_layout = QVBoxLayout(side_panel)
@@ -183,6 +189,7 @@ class ImageEditorUI(QMainWindow, LayerCollectionHandler):
183
189
  self.thumbnail_list.setFixedWidth(gui_constants.THUMB_WIDTH)
184
190
  self.thumbnail_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
185
191
  self.thumbnail_list.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
192
+ self.exif_dialog = None
186
193
 
187
194
  def change_layer_item(item):
188
195
  layer_idx = self.thumbnail_list.row(item)
@@ -266,8 +273,17 @@ class ImageEditorUI(QMainWindow, LayerCollectionHandler):
266
273
 
267
274
  file_menu.addAction("&Close", self.close_file, "Ctrl+W")
268
275
  file_menu.addSeparator()
276
+ show_exif_action = QAction("Show EXIF Data", self)
277
+ show_exif_action.triggered.connect(self.show_exif_data)
278
+ show_exif_action.setProperty("requires_file", True)
279
+ file_menu.addAction(show_exif_action)
280
+ delete_exif_action = QAction("Delete EXIF Data", self)
281
+ delete_exif_action.triggered.connect(self.delete_exif_data)
282
+ delete_exif_action.setProperty("requires_file", True)
283
+ file_menu.addAction(delete_exif_action)
284
+ file_menu.addSeparator()
269
285
  file_menu.addAction("&Import Frames", self.io_gui_handler.import_frames)
270
- file_menu.addAction("Import &EXIF Data", self.io_gui_handler.select_exif_path)
286
+ file_menu.addAction("Import &EXIF Data", self.select_exif_path)
271
287
 
272
288
  edit_menu = menubar.addMenu("&Edit")
273
289
  self.undo_action = QAction("Undo", self)
@@ -595,6 +611,18 @@ class ImageEditorUI(QMainWindow, LayerCollectionHandler):
595
611
  if event.text() == '}':
596
612
  self.brush_tool.increase_brush_hardness()
597
613
  return
614
+ if event.text() == ',':
615
+ self.brush_tool.decrease_brush_opacity()
616
+ return
617
+ if event.text() == '.':
618
+ self.brush_tool.increase_brush_opacity()
619
+ return
620
+ if event.text() == ';':
621
+ self.brush_tool.decrease_brush_flow()
622
+ return
623
+ if event.text() == ':':
624
+ self.brush_tool.increase_brush_flow()
625
+ return
598
626
  super().keyPressEvent(event)
599
627
  # pylint: enable=C0103
600
628
 
@@ -676,6 +704,36 @@ class ImageEditorUI(QMainWindow, LayerCollectionHandler):
676
704
  self.redo_action.setText("Redo")
677
705
  self.redo_action.setEnabled(False)
678
706
 
707
+ def select_exif_path(self):
708
+ path, _ = QFileDialog.getOpenFileName(None, "Select file with exif data")
709
+ if path:
710
+ temp_exif_data = get_exif(path)
711
+ self.exif_dialog = ExifData(temp_exif_data, "Import Selected EXIF Data",
712
+ self.parent(), show_buttons=True)
713
+ result = self.exif_dialog.exec()
714
+ if result == QDialog.Accepted:
715
+ self.io_gui_handler.set_exif_data(temp_exif_data, path)
716
+ self.show_status_message(f"EXIF data loaded from {path}.")
717
+ else:
718
+ self.show_status_message("EXIF data loading cancelled.")
719
+
720
+ def show_exif_data(self):
721
+ self.exif_dialog = ExifData(self.io_gui_handler.exif_data, "EXIF Data",
722
+ self.parent(), show_buttons=False)
723
+ self.exif_dialog.exec()
724
+
725
+ def delete_exif_data(self):
726
+ reply = QMessageBox.question(
727
+ self,
728
+ "Confirm Delete",
729
+ "Warning: the current EXIF data will be erased.\n\nDo you want to continue?",
730
+ QMessageBox.Yes | QMessageBox.No,
731
+ QMessageBox.No
732
+ )
733
+ if reply == QMessageBox.Yes:
734
+ self.io_gui_handler.exif_data = None
735
+ self.io_gui_handler.exif_path = ''
736
+
679
737
  def luminosity_filter(self):
680
738
  self.filter_manager.apply("Luminosity, Contrast")
681
739
 
@@ -763,5 +821,23 @@ class ImageEditorUI(QMainWindow, LayerCollectionHandler):
763
821
  else:
764
822
  self.brush_tool.decrease_brush_size()
765
823
 
824
+ def handle_brush_hardness_change(self, delta):
825
+ if delta > 0:
826
+ self.brush_tool.increase_brush_hardness()
827
+ else:
828
+ self.brush_tool.decrease_brush_hardness()
829
+
830
+ def handle_brush_opacity_change(self, delta):
831
+ if delta > 0:
832
+ self.brush_tool.increase_brush_opacity()
833
+ else:
834
+ self.brush_tool.decrease_brush_opacity()
835
+
836
+ def handle_brush_flow_change(self, delta):
837
+ if delta > 0:
838
+ self.brush_tool.increase_brush_flow()
839
+ else:
840
+ self.brush_tool.decrease_brush_flow()
841
+
766
842
  def handle_set_zoom_factor(self, zoom_factor):
767
843
  self.zoom_factor_label.setText(f"zoom: {zoom_factor:.1%}")
@@ -132,10 +132,15 @@ class ImageViewer(QWidget):
132
132
 
133
133
  def connect_signals(
134
134
  self, handle_temp_view, end_copy_brush_area,
135
- handle_brush_size_change, handle_needs_update):
135
+ handle_brush_size_change, handle_brush_hardness_change,
136
+ handle_brush_opacity_change, handle_brush_flow_change,
137
+ handle_needs_update):
136
138
  for st in self._strategies.values():
137
139
  st.temp_view_requested.connect(handle_temp_view)
138
140
  st.end_copy_brush_area_requested.connect(end_copy_brush_area)
139
141
  st.brush_size_change_requested.connect(handle_brush_size_change)
142
+ st.brush_hardness_change_requested.connect(handle_brush_hardness_change)
143
+ st.brush_opacity_change_requested.connect(handle_brush_opacity_change)
144
+ st.brush_flow_change_requested.connect(handle_brush_flow_change)
140
145
  st.needs_update_requested.connect(handle_needs_update)
141
146
  st.setFocusPolicy(Qt.StrongFocus)
@@ -7,9 +7,9 @@ from PySide6.QtWidgets import (QFileDialog, QMessageBox, QVBoxLayout, QLabel, QD
7
7
  QApplication, QProgressBar)
8
8
  from PySide6.QtGui import QGuiApplication, QCursor
9
9
  from PySide6.QtCore import Qt, QObject, QTimer, Signal
10
+ from .. algorithms.utils import EXTENSIONS_GUI_STR, EXTENSIONS_GUI_SAVE_STR
10
11
  from .. algorithms.exif import get_exif, write_image_with_exif_data
11
12
  from .file_loader import FileLoader
12
- from .exif_data import ExifData
13
13
  from .io_threads import FileMultilayerSaver, FrameImporter
14
14
  from .layer_collection import LayerCollectionHandler
15
15
 
@@ -32,7 +32,6 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
32
32
  self.image_viewer = None
33
33
  self.loading_dialog = None
34
34
  self.loading_timer = None
35
- self.exif_dialog = None
36
35
  self.saver_thread = None
37
36
  self.saving_dialog = None
38
37
  self.saving_timer = None
@@ -46,6 +45,10 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
46
45
  self.exif_data = None
47
46
  self.exif_path = ''
48
47
 
48
+ def set_exif_data(self, data, path):
49
+ self.exif_data = data
50
+ self.exif_path = path
51
+
49
52
  def current_file_path(self):
50
53
  return self.current_file_path_master if self.save_master_only.isChecked() \
51
54
  else self.current_file_path_multi
@@ -135,7 +138,7 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
135
138
  if file_paths is None:
136
139
  file_paths, _ = QFileDialog.getOpenFileNames(
137
140
  self.parent(), "Open Image", "",
138
- "Images (*.tif *.tiff *.jpg *.jpeg);;All Files (*)")
141
+ F"Images ({EXTENSIONS_GUI_STR});;All Files (*)")
139
142
  if not file_paths:
140
143
  return
141
144
  if self.loader_thread and self.loader_thread.isRunning():
@@ -163,11 +166,13 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
163
166
  self.loader_thread.finished.connect(self.on_file_loaded)
164
167
  self.loader_thread.error.connect(self.on_file_error)
165
168
  self.loader_thread.start()
169
+ self.exif_path = self.current_file_path_master
170
+ self.exif_data = get_exif(self.exif_path)
166
171
 
167
172
  def import_frames(self):
168
173
  file_paths, _ = QFileDialog.getOpenFileNames(
169
174
  self.parent(), "Select frames", "",
170
- "Images Images (*.tif *.tiff *.jpg *.jpeg);;All Files (*)")
175
+ f"Images Images ({EXTENSIONS_GUI_STR});;All Files (*)")
171
176
  if file_paths:
172
177
  self.import_frames_from_files(file_paths)
173
178
 
@@ -195,6 +200,9 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
195
200
  self.frame_importer_thread.error.connect(self.on_frames_import_error)
196
201
  self.frame_importer_thread.progress.connect(self.update_import_progress)
197
202
  self.frame_importer_thread.start()
203
+ if self.exif_data is None:
204
+ self.exif_path = file_paths[0]
205
+ self.exif_data = get_exif(self.exif_path)
198
206
 
199
207
  def update_import_progress(self, percent, filename):
200
208
  if hasattr(self, 'progress_bar'):
@@ -286,8 +294,7 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
286
294
  if self.layer_stack() is None:
287
295
  return
288
296
  path, _ = QFileDialog.getSaveFileName(
289
- self.parent(), "Save Image", "",
290
- "TIFF Files (*.tif *.tiff);;JPEG Files (*.jpg *.jpeg);;All Files (*)")
297
+ self.parent(), "Save Image", "", EXTENSIONS_GUI_SAVE_STR)
291
298
  if path:
292
299
  self.save_master_to_path(path)
293
300
 
@@ -296,7 +303,6 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
296
303
  img = cv2.cvtColor(self.master_layer(), cv2.COLOR_RGB2BGR)
297
304
  write_image_with_exif_data(self.exif_data, img, path)
298
305
  self.current_file_path_master = os.path.abspath(path)
299
- # self.mark_as_modified_requested.emit(False)
300
306
  self.update_title_requested.emit()
301
307
  self.add_recent_file_requested.emit(self.current_file_path_master)
302
308
  self.status_message_requested.emit(f"Saved master layer to: {path}")
@@ -304,15 +310,6 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
304
310
  traceback.print_tb(e.__traceback__)
305
311
  QMessageBox.critical(self.parent(), "Save Error", f"Could not save file: {str(e)}")
306
312
 
307
- def select_exif_path(self):
308
- path, _ = QFileDialog.getOpenFileName(None, "Select file with exif data")
309
- if path:
310
- self.exif_path = path
311
- self.exif_data = get_exif(path)
312
- self.status_message_requested.emit(f"EXIF data extracted from {path}.")
313
- self.exif_dialog = ExifData(self.exif_data, self.parent())
314
- self.exif_dialog.exec()
315
-
316
313
  def close_file(self):
317
314
  self.mark_as_modified_requested.emit(False)
318
315
  self.layer_collection.reset()
@@ -61,7 +61,10 @@ class ShortcutsHelp(QDialog):
61
61
  "Ctrl + +": "Zoom in",
62
62
  "Ctrl + -": "Zoom out",
63
63
  "Ctrl + 0": "Fit to screen",
64
- "Ctrl + R": "Actual size"
64
+ "Ctrl + R": "Actual size",
65
+ "Ctrl + 1": "View: overlaid",
66
+ "Ctrl + 2": "View: side by side",
67
+ "Ctrl + 3": "View: top-bottom",
65
68
  }
66
69
 
67
70
  self.add_bold_label(left_layout, "Keyboard Shortcuts")
@@ -69,13 +72,14 @@ class ShortcutsHelp(QDialog):
69
72
  left_layout.addRow(f"<b>{k}</b>", QLabel(v))
70
73
 
71
74
  shortcuts = {
72
- "Ctrl + 1": "View: overlaid",
73
- "Ctrl + 2": "View: side by side",
74
- "Ctrl + 3": "View: top-bottom",
75
- "[": "Increase brush size",
76
- "]": "Decrease brush size",
77
- "{": "Increase brush hardness",
78
- "}": "Decrease brush hardness"
75
+ "[": "Decrease brush size",
76
+ "]": "Increase brush size",
77
+ "{": "Decrease brush hardness",
78
+ "}": "Increase brush hardness",
79
+ ",": "Decrease brush opacity",
80
+ ".": "Increase brush opacity",
81
+ ";": "Decrease brush flow",
82
+ ":": "Increase brush flow"
79
83
  }
80
84
 
81
85
  self.add_bold_label(right_layout, "Keyboard Shortcuts")
@@ -86,6 +90,9 @@ class ShortcutsHelp(QDialog):
86
90
  "Space + Drag": "Move",
87
91
  "Wheel": "Zoom in/out",
88
92
  "Ctrl + Wheel": "Adjust brush size",
93
+ "Shift + Wheel": "Adjust brush hardness",
94
+ "Alt + Wheel": "Adjust brush opacity",
95
+ "Ctrl + Shift + Wheel": "Adjust brush flow",
89
96
  "Left Click": "Use brush to copy from selected layer to master",
90
97
  }
91
98
 
@@ -6,7 +6,7 @@ import numpy as np
6
6
  from PySide6.QtCore import Qt, QPointF, QTime, QPoint, Signal, QRectF
7
7
  from PySide6.QtGui import QImage, QPainter, QColor, QBrush, QPen, QCursor, QPixmap, QPainterPath
8
8
  from PySide6.QtWidgets import (
9
- QGraphicsEllipseItem, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem,
9
+ QGraphicsEllipseItem, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QApplication,
10
10
  QGraphicsItemGroup, QGraphicsPathItem)
11
11
  from .. config.gui_constants import gui_constants
12
12
  from .. config.app_config import AppConfig
@@ -81,6 +81,9 @@ class ViewSignals:
81
81
  temp_view_requested = Signal(bool)
82
82
  end_copy_brush_area_requested = Signal()
83
83
  brush_size_change_requested = Signal(int) # +1 or -1
84
+ brush_hardness_change_requested = Signal(int)
85
+ brush_opacity_change_requested = Signal(int)
86
+ brush_flow_change_requested = Signal(int)
84
87
  needs_update_requested = Signal()
85
88
 
86
89
 
@@ -444,8 +447,15 @@ class ViewStrategy(LayerCollectionHandler):
444
447
  if self.empty() or self.gesture_active:
445
448
  return
446
449
  if event.source() == Qt.MouseEventNotSynthesized: # Physical mouse
447
- if self.control_pressed:
450
+ modifiers = QApplication.keyboardModifiers()
451
+ if modifiers & Qt.ControlModifier and modifiers & Qt.ShiftModifier:
452
+ self.brush_flow_change_requested.emit(1 if event.angleDelta().y() > 0 else -1)
453
+ elif modifiers & Qt.ControlModifier:
448
454
  self.brush_size_change_requested.emit(1 if event.angleDelta().y() > 0 else -1)
455
+ elif modifiers & Qt.ShiftModifier:
456
+ self.brush_hardness_change_requested.emit(1 if event.angleDelta().y() > 0 else -1)
457
+ elif modifiers & Qt.AltModifier:
458
+ self.brush_opacity_change_requested.emit(1 if event.angleDelta().y() > 0 else -1)
449
459
  else:
450
460
  self.handle_zoom_wheel(self.get_view_with_mouse(event), event)
451
461
  self.update_brush_cursor()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shinestacker
3
- Version: 1.8.0
3
+ Version: 1.9.3
4
4
  Summary: ShineStacker
5
5
  Author-email: Luca Lista <luka.lista@gmail.com>
6
6
  License-Expression: LGPL-3.0
@@ -30,11 +30,9 @@ Provides-Extra: dev
30
30
  Requires-Dist: pytest; extra == "dev"
31
31
  Dynamic: license-file
32
32
 
33
- <img src='https://raw.githubusercontent.com/lucalista/shinestacker/main/src/shinestacker/gui/ico/shinestacker.png' width="150" referrerpolicy="no-referrer" alt="Shine Stacker Logo">
34
-
35
33
  # Shine Stacker
36
34
 
37
- ## Focus Stacking Processing Framework and GUI
35
+ Focus Stacking Processing Framework and GUI designed for macro photographers, microscopists, and researchers who need precise control and reproducible stacking results.
38
36
 
39
37
  [![CI multiplatform](https://github.com/lucalista/shinestacker/actions/workflows/ci-multiplatform.yml/badge.svg)](https://github.com/lucalista/shinestacker/actions/workflows/ci-multiplatform.yml)
40
38
  [![PyPI version](https://img.shields.io/pypi/v/shinestacker?color=success)](https://pypi.org/project/shinestacker/)
@@ -45,60 +43,55 @@ Dynamic: license-file
45
43
  [![Documentation Status](https://readthedocs.org/projects/shinestacker/badge/?version=latest)](https://shinestacker.readthedocs.io/en/latest/?badge=latest)
46
44
  [![License: LGPL v3](https://img.shields.io/badge/License-LGPL_v3-blue.svg)](https://www.gnu.org/licenses/lgpl-3.0)
47
45
  [![PyPI Downloads](https://static.pepy.tech/badge/shinestacker)](https://pepy.tech/projects/shinestacker)
48
-
49
- <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">
50
-
51
- <img src='https://raw.githubusercontent.com/lucalista/shinestacker/main/img/coffee.gif' width="400" referrerpolicy="no-referrer"> <img src='https://raw.githubusercontent.com/lucalista/shinestacker/main/img/coffee_stack.jpg' width="400" referrerpolicy="no-referrer">
52
-
53
- > **Focus stacking** for microscopy, macro photography, and computational imaging
46
+ <center><img src='https://raw.githubusercontent.com/lucalista/shinestacker/main/src/shinestacker/gui/ico/shinestacker.png' width="150" referrerpolicy="no-referrer" alt="Shine Stacker Logo"></center>
54
47
 
55
48
  ## Key Features
56
- - 🚀 **Batch Processing**: Align, balance, and stack hundreds of images
57
- - 🧩 **Modular Architecture**: Mix-and-match processing modules
58
- - 🖌️ **Retouch Editing**: Final interactive retouch of stacked image from individual frames
59
- - 📊 **Jupyter Integration**: Image processing python notebooks
49
+ - 🪟 **Cross-Platform GUI**: Native app built with Qt6, available for Windows, macOS, and Linux.
50
+ - 🚀 **Batch Processing**: Automatically align, balance, and stack hundreds of images — perfect for macro or microscopy datasets.
51
+ - 🧩 **Modular Architecture**: Combine configurable modules for alignment, normalization, and blending to build custom workflows.
52
+ - 🖌️ **Retouch Editor**: Interactively refine your stacked image by painting in details from individual frames.
53
+ - 📊 **Jupyter & Python Integration**: Use Shine Stacker as a library inside your Python or Jupyter workflows.
54
+
55
+ <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">
60
56
 
61
57
  ## Interactive GUI
62
58
 
63
- The GUI has two main working areas:
59
+ The graphical interface makes complex stacking tasks simple:
60
+ - **Project View** – Configure, preview, and run stacking workflows with optional intermediate results.
61
+ - **Retouch View** – Manually refine the final image by blending details from selected frames and applying filters.
64
62
 
65
- * *Project*: manage and run focus stacking workflows in a flexible and configurable way, with optional intermediate batch stacking.
63
+ Ideal for users who want the power of scripting and the comfort of a modern UI.
66
64
 
67
- <img src='https://raw.githubusercontent.com/lucalista/shinestacker/main/img/gui-project-run.png' width="600" referrerpolicy="no-referrer">
65
+ <img src='https://raw.githubusercontent.com/lucalista/shinestacker/main/img/coffee.gif' width="400" referrerpolicy="no-referrer"> <img src='https://raw.githubusercontent.com/lucalista/shinestacker/main/img/coffee_stack.jpg' width="400" referrerpolicy="no-referrer">
68
66
 
69
- * *Retouch*: select interactively details from individual frames and apply final filters to the blended image.
67
+ ## Get Started
70
68
 
71
- <img src='https://raw.githubusercontent.com/lucalista/shinestacker/main/img/gui-retouch.png' width="600" referrerpolicy="no-referrer">
69
+ - 📦 [Install via PyPI](https://pypi.org/project/shinestacker/)
70
+ - 💻 [Run the GUI app](https://shinestacker.readthedocs.io/en/latest/gui.html)
71
+ - 🧠 [Reference](https://shinestacker.readthedocs.io/en/)
72
+ - 🐛 [Report an issue](https://github.com/lucalista/shinestacker/issues)
72
73
 
73
74
  ## Resources
74
75
 
75
76
  🌍 [Website on WordPress](https://shinestacker.wordpress.com) • 📖 [Main documentation](https://shinestacker.readthedocs.io) • 📝 [Changelog](https://github.com/lucalista/shinestacker/blob/main/CHANGELOG.md)
76
77
 
77
- ## Note for macOS users
78
-
79
- **The following note is only relevant if you download the application as compressed archive from the [release page](https://github.com/lucalista/shinestacker/releases).**
78
+ ## Installation
80
79
 
81
- The macOS system security protection prevent to run applications downloaded from the web that come from developers that don't hold an Apple Developer Certificate.
80
+ See the [main documentation](https://github.com/lucalista/shinestacker/blob/main/docs/main.md) for detailed installation instructions.
82
81
 
83
- In order to prevent this, follow the instructions below:
82
+ **Platform notes:**
83
+ - **Windows:** If you download the installer or ZIP archive, you may need to whitelist the app in your antivirus software.
84
+ - **macOS:** See the [installation note for macOS users](https://github.com/lucalista/shinestacker/blob/main/docs/macos-install.md).
84
85
 
85
- 1. Download the compressed archive ```shinestacker-macos.tar.gz``` in your ```Download``` folder.
86
- 2. Double-click the archive to uncompress it. You will find a new folder ```shinestacker```.
87
- 3. Open a terminal (*Applications > Utilities > Terminal*)
88
- 4. Type the folliwng command on the terminal (assuming you installed the app from the ```dmg``` image under ```Applications```):
89
- ```bash
90
- xattr -cr /Applications/shinestacker/shinestacker.app
91
- ```
92
- 5. Now you can double-click the Sine Stacker icon app and it should run.
93
86
 
94
- macOS adds a quarantine flag to all files downloaded from the internet. The above command removes that flag while preserving all other application functionality.
87
+ ## Acknowledgements & References
95
88
 
96
- ## Credits
97
-
98
- The first version of the core focus stack algorithm was initially inspired by the [Laplacian pyramids method](https://github.com/sjawhar/focus-stacking) implementation by Sami Jawhar that was used under permission of the author. The implementation in the latest releases was rewritten from the original code.
99
-
100
- ## Resources
89
+ The first version of the core focus stack algorithm was inspired by the
90
+ [Laplacian pyramids method](https://github.com/sjawhar/focus-stacking) implementation
91
+ by Sami Jawhar, used under permission. The implementation in the latest releases
92
+ was rewritten from the original code.
101
93
 
94
+ Key references:
102
95
  * [Pyramid Methods in Image Processing](https://www.researchgate.net/publication/246727904_Pyramid_Methods_in_Image_Processing), E. H. Adelson, C. H. Anderson, J. R. Bergen, P. J. Burt, J. M. Ogden, RCA Engineer, 29-6, Nov/Dec 1984
103
96
  Pyramid methods in image processing
104
97
  * [A Multi-focus Image Fusion Method Based on Laplacian Pyramid](http://www.jcomputers.us/vol6/jcp0612-07.pdf), Wencheng Wang, Faliang Chang, Journal of Computers 6 (12), 2559, December 2011
@@ -108,13 +101,18 @@ Pyramid methods in image processing
108
101
  <img src="https://www.gnu.org/graphics/lgplv3-147x51.png" alt="LGPL 3 logo">
109
102
 
110
103
  - **Code**: 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.
111
- <img src='https://raw.githubusercontent.com/lucalista/shinestacker/main/src/shinestacker/gui/ico/shinestacker.png' width="150" referrerpolicy="no-referrer" alt="Shine Stacker Logo">
112
104
 
113
105
  - **Logo**: The Shine Stacker logo was designed by [Alessandro Lista](https://linktr.ee/alelista). Copyright © Alessandro Lista. All rights reserved. The logo is not covered by the LGPL-3.0 license of this project.
114
106
 
115
107
  ## Attribution request
108
+
116
109
  📸 If you publish images created with Shine Stacker, please consider adding a note such as:
117
110
 
118
111
  *Created with Shine Stacker – https://github.com/lucalista/shinestacker*
119
112
 
120
113
  This is not mandatory, but highly appreciated.
114
+
115
+ ---
116
+ > Developed and maintained by [Luca Lista](https://github.com/lucalista).
117
+ > 💡 Contributions, feedback, and feature suggestions are warmly welcome.
118
+ > If you enjoy Shine Stacker, consider giving it a ⭐️ on GitHub — it really helps visibility!