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.
- shinestacker/_version.py +1 -1
- shinestacker/algorithms/align.py +202 -81
- shinestacker/algorithms/align_auto.py +13 -11
- shinestacker/algorithms/align_parallel.py +50 -21
- shinestacker/algorithms/balance.py +1 -1
- shinestacker/algorithms/base_stack_algo.py +1 -1
- shinestacker/algorithms/exif.py +848 -127
- shinestacker/algorithms/multilayer.py +6 -4
- shinestacker/algorithms/noise_detection.py +10 -8
- shinestacker/algorithms/pyramid_tiles.py +1 -1
- shinestacker/algorithms/stack.py +33 -17
- shinestacker/algorithms/stack_framework.py +16 -11
- shinestacker/algorithms/utils.py +18 -2
- shinestacker/algorithms/vignetting.py +16 -3
- shinestacker/app/main.py +1 -1
- shinestacker/app/settings_dialog.py +297 -173
- shinestacker/config/constants.py +10 -6
- shinestacker/config/settings.py +25 -7
- shinestacker/core/exceptions.py +1 -1
- shinestacker/core/framework.py +2 -2
- shinestacker/gui/action_config.py +23 -20
- shinestacker/gui/action_config_dialog.py +38 -25
- shinestacker/gui/config_dialog.py +6 -5
- shinestacker/gui/folder_file_selection.py +3 -2
- shinestacker/gui/gui_images.py +27 -3
- shinestacker/gui/gui_run.py +2 -2
- shinestacker/gui/main_window.py +6 -0
- shinestacker/gui/menu_manager.py +8 -2
- shinestacker/gui/new_project.py +23 -12
- shinestacker/gui/project_controller.py +14 -6
- shinestacker/gui/project_editor.py +12 -2
- shinestacker/gui/project_model.py +4 -4
- shinestacker/retouch/brush_tool.py +20 -0
- shinestacker/retouch/exif_data.py +106 -38
- shinestacker/retouch/file_loader.py +3 -3
- shinestacker/retouch/image_editor_ui.py +79 -3
- shinestacker/retouch/image_viewer.py +6 -1
- shinestacker/retouch/io_gui_handler.py +13 -16
- shinestacker/retouch/shortcuts_help.py +15 -8
- shinestacker/retouch/view_strategy.py +12 -2
- {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/METADATA +37 -39
- {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/RECORD +46 -46
- {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/WHEEL +0 -0
- {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/entry_points.txt +0 -0
- {shinestacker-1.8.0.dist-info → shinestacker-1.9.3.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
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 .
|
|
7
|
-
from .. gui.base_form_dialog import BaseFormDialog
|
|
9
|
+
from .. gui.config_dialog import ConfigDialog
|
|
8
10
|
|
|
9
11
|
|
|
10
|
-
class ExifData(
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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 (
|
|
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 (
|
|
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
|
-
"
|
|
73
|
-
"
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
"
|
|
77
|
-
"
|
|
78
|
-
"
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
[](https://github.com/lucalista/shinestacker/actions/workflows/ci-multiplatform.yml)
|
|
40
38
|
[](https://pypi.org/project/shinestacker/)
|
|
@@ -45,60 +43,55 @@ Dynamic: license-file
|
|
|
45
43
|
[](https://shinestacker.readthedocs.io/en/latest/?badge=latest)
|
|
46
44
|
[](https://www.gnu.org/licenses/lgpl-3.0)
|
|
47
45
|
[](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
|
-
-
|
|
57
|
-
-
|
|
58
|
-
-
|
|
59
|
-
-
|
|
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
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
67
|
+
## Get Started
|
|
70
68
|
|
|
71
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
80
|
+
See the [main documentation](https://github.com/lucalista/shinestacker/blob/main/docs/main.md) for detailed installation instructions.
|
|
82
81
|
|
|
83
|
-
|
|
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
|
-
|
|
87
|
+
## Acknowledgements & References
|
|
95
88
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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!
|