shinestacker 0.2.0.post1.dev1__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 (67) hide show
  1. shinestacker/__init__.py +3 -0
  2. shinestacker/_version.py +1 -0
  3. shinestacker/algorithms/__init__.py +14 -0
  4. shinestacker/algorithms/align.py +307 -0
  5. shinestacker/algorithms/balance.py +367 -0
  6. shinestacker/algorithms/core_utils.py +22 -0
  7. shinestacker/algorithms/depth_map.py +164 -0
  8. shinestacker/algorithms/exif.py +238 -0
  9. shinestacker/algorithms/multilayer.py +187 -0
  10. shinestacker/algorithms/noise_detection.py +182 -0
  11. shinestacker/algorithms/pyramid.py +176 -0
  12. shinestacker/algorithms/stack.py +112 -0
  13. shinestacker/algorithms/stack_framework.py +248 -0
  14. shinestacker/algorithms/utils.py +71 -0
  15. shinestacker/algorithms/vignetting.py +137 -0
  16. shinestacker/app/__init__.py +0 -0
  17. shinestacker/app/about_dialog.py +24 -0
  18. shinestacker/app/app_config.py +39 -0
  19. shinestacker/app/gui_utils.py +35 -0
  20. shinestacker/app/help_menu.py +16 -0
  21. shinestacker/app/main.py +176 -0
  22. shinestacker/app/open_frames.py +39 -0
  23. shinestacker/app/project.py +91 -0
  24. shinestacker/app/retouch.py +82 -0
  25. shinestacker/config/__init__.py +4 -0
  26. shinestacker/config/config.py +53 -0
  27. shinestacker/config/constants.py +174 -0
  28. shinestacker/config/gui_constants.py +85 -0
  29. shinestacker/core/__init__.py +5 -0
  30. shinestacker/core/colors.py +60 -0
  31. shinestacker/core/core_utils.py +52 -0
  32. shinestacker/core/exceptions.py +50 -0
  33. shinestacker/core/framework.py +210 -0
  34. shinestacker/core/logging.py +89 -0
  35. shinestacker/gui/__init__.py +0 -0
  36. shinestacker/gui/action_config.py +879 -0
  37. shinestacker/gui/actions_window.py +283 -0
  38. shinestacker/gui/colors.py +57 -0
  39. shinestacker/gui/gui_images.py +152 -0
  40. shinestacker/gui/gui_logging.py +213 -0
  41. shinestacker/gui/gui_run.py +393 -0
  42. shinestacker/gui/img/close-round-line-icon.png +0 -0
  43. shinestacker/gui/img/forward-button-icon.png +0 -0
  44. shinestacker/gui/img/play-button-round-icon.png +0 -0
  45. shinestacker/gui/img/plus-round-line-icon.png +0 -0
  46. shinestacker/gui/main_window.py +599 -0
  47. shinestacker/gui/new_project.py +170 -0
  48. shinestacker/gui/project_converter.py +148 -0
  49. shinestacker/gui/project_editor.py +539 -0
  50. shinestacker/gui/project_model.py +138 -0
  51. shinestacker/retouch/__init__.py +0 -0
  52. shinestacker/retouch/brush.py +9 -0
  53. shinestacker/retouch/brush_controller.py +57 -0
  54. shinestacker/retouch/brush_preview.py +126 -0
  55. shinestacker/retouch/exif_data.py +65 -0
  56. shinestacker/retouch/file_loader.py +104 -0
  57. shinestacker/retouch/image_editor.py +651 -0
  58. shinestacker/retouch/image_editor_ui.py +380 -0
  59. shinestacker/retouch/image_viewer.py +356 -0
  60. shinestacker/retouch/shortcuts_help.py +98 -0
  61. shinestacker/retouch/undo_manager.py +38 -0
  62. shinestacker-0.2.0.post1.dev1.dist-info/METADATA +55 -0
  63. shinestacker-0.2.0.post1.dev1.dist-info/RECORD +67 -0
  64. shinestacker-0.2.0.post1.dev1.dist-info/WHEEL +5 -0
  65. shinestacker-0.2.0.post1.dev1.dist-info/entry_points.txt +4 -0
  66. shinestacker-0.2.0.post1.dev1.dist-info/licenses/LICENSE +1 -0
  67. shinestacker-0.2.0.post1.dev1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,283 @@
1
+ import os.path
2
+ import os
3
+ import json
4
+ import jsonpickle
5
+ import traceback
6
+ from PySide6.QtWidgets import QMessageBox, QFileDialog, QDialog
7
+ from .. core.core_utils import get_app_base_path
8
+ from .. config.constants import constants
9
+ from .project_model import ActionConfig
10
+ from .action_config import ActionConfigDialog
11
+ from .project_editor import ProjectEditor
12
+ from .new_project import NewProjectDialog
13
+ from .project_model import Project
14
+
15
+
16
+ class ActionsWindow(ProjectEditor):
17
+ def __init__(self):
18
+ super().__init__()
19
+ self._current_file = None
20
+ self._current_file_wd = ''
21
+ self._modified_project = False
22
+ self.update_title()
23
+
24
+ def update_title(self):
25
+ title = constants.APP_TITLE
26
+ if self._current_file:
27
+ title += f" - {os.path.basename(self._current_file)}"
28
+ if self._modified_project:
29
+ title += " *"
30
+ self.window().setWindowTitle(title)
31
+
32
+ def mark_as_modified(self):
33
+ self._modified_project = True
34
+ self._project_buffer.append(self.project.clone())
35
+ self.update_title()
36
+
37
+ def close_project(self):
38
+ if self._check_unsaved_changes():
39
+ self.set_project(Project())
40
+ self._current_file = None
41
+ self.update_title()
42
+ self.job_list.clear()
43
+ self.action_list.clear()
44
+ self._modified_project = False
45
+
46
+ def new_project(self):
47
+ if not self._check_unsaved_changes():
48
+ return
49
+ os.chdir(get_app_base_path())
50
+ self._current_file = None
51
+ self._modified_project = False
52
+ self.update_title()
53
+ self.job_list.clear()
54
+ self.action_list.clear()
55
+ dialog = NewProjectDialog(self)
56
+ if dialog.exec() == QDialog.Accepted:
57
+ input_folder = dialog.get_input_folder().split('/')
58
+ working_path = '/'.join(input_folder[:-1])
59
+ input_path = input_folder[-1]
60
+ project = Project()
61
+ if dialog.get_noise_detection():
62
+ job_noise = ActionConfig(constants.ACTION_JOB,
63
+ {'name': 'detect-noise', 'working_path': working_path,
64
+ 'input_path': input_path})
65
+ noise_detection = ActionConfig(constants.ACTION_NOISEDETECTION,
66
+ {'name': 'detect-noise'})
67
+ job_noise.add_sub_action(noise_detection)
68
+ project.jobs.append(job_noise)
69
+ job = ActionConfig(constants.ACTION_JOB,
70
+ {'name': 'focus-stack', 'working_path': working_path,
71
+ 'input_path': input_path})
72
+ if dialog.get_noise_detection() or dialog.get_vignetting_correction() or \
73
+ dialog.get_align_frames() or dialog.get_balance_frames():
74
+ combo_action = ActionConfig(constants.ACTION_COMBO, {'name': 'align'})
75
+ if dialog.get_noise_detection():
76
+ mask_noise = ActionConfig(constants.ACTION_MASKNOISE, {'name': 'mask-noise'})
77
+ combo_action.add_sub_action(mask_noise)
78
+ if dialog.get_vignetting_correction():
79
+ vignetting = ActionConfig(constants.ACTION_VIGNETTING, {'name': 'vignetting'})
80
+ combo_action.add_sub_action(vignetting)
81
+ if dialog.get_align_frames():
82
+ align = ActionConfig(constants.ACTION_ALIGNFRAMES, {'name': 'align'})
83
+ combo_action.add_sub_action(align)
84
+ if dialog.get_balance_frames():
85
+ balance = ActionConfig(constants.ACTION_BALANCEFRAMES, {'name': 'balance'})
86
+ combo_action.add_sub_action(balance)
87
+ job.add_sub_action(combo_action)
88
+ if dialog.get_bunch_stack():
89
+ bunch_stack = ActionConfig(constants.ACTION_FOCUSSTACKBUNCH,
90
+ {'name': 'bunches', 'frames': dialog.get_bunch_frames(),
91
+ 'overlap': dialog.get_bunch_overlap()})
92
+ job.add_sub_action(bunch_stack)
93
+ if dialog.get_focus_stack_pyramid():
94
+ focus_pyramid = ActionConfig(constants.ACTION_FOCUSSTACK,
95
+ {'name': 'focus-stack-pyramid',
96
+ 'stacker': constants.STACK_ALGO_PYRAMID})
97
+ job.add_sub_action(focus_pyramid)
98
+ if dialog.get_focus_stack_depth_map():
99
+ focus_depth_map = ActionConfig(constants.ACTION_FOCUSSTACK,
100
+ {'name': 'focus-stack-depth-map',
101
+ 'stacker': constants.STACK_ALGO_DEPTH_MAP})
102
+ job.add_sub_action(focus_depth_map)
103
+ if dialog.get_multi_layer():
104
+ input_path = []
105
+ if dialog.get_focus_stack_pyramid():
106
+ input_path.append("focus-stack-pyramid")
107
+ if dialog.get_focus_stack_depth_map():
108
+ input_path.append("focus-stack-depth-map")
109
+ if dialog.get_bunch_stack():
110
+ input_path.append("bunches")
111
+ else:
112
+ input_path.append(input_path)
113
+ multi_layer = ActionConfig(constants.ACTION_MULTILAYER,
114
+ {'name': 'multi-layer',
115
+ 'input_path': ','.join(input_path)})
116
+ job.add_sub_action(multi_layer)
117
+ project.jobs.append(job)
118
+ self.set_project(project)
119
+ self._modified_project = True
120
+ self.refresh_ui(0, -1)
121
+
122
+ def open_project(self, file_path=False):
123
+ if not self._check_unsaved_changes():
124
+ return
125
+ if file_path is False:
126
+ file_path, _ = QFileDialog.getOpenFileName(self, "Open Project", "", "Project Files (*.fsp);;All Files (*)")
127
+ if file_path:
128
+ try:
129
+ self._current_file_wd = '' if os.path.isabs(file_path) else os.getcwd()
130
+ file = open(file_path, 'r')
131
+ pp = file_path.split('/')
132
+ if len(pp) > 1:
133
+ os.chdir('/'.join(pp[:-1]))
134
+ json_obj = json.load(file)
135
+ project = Project.from_dict(json_obj['project'])
136
+ if project is None:
137
+ raise RuntimeError(f"Project from file {file_path} produced a null project.")
138
+ self.set_project(project)
139
+ self._modified_project = False
140
+ self._current_file = file_path
141
+ self.update_title()
142
+ self.refresh_ui(0, -1)
143
+ if self.job_list.count() > 0:
144
+ self.job_list.setCurrentRow(0)
145
+ except Exception as e:
146
+ traceback.print_tb(e.__traceback__)
147
+ QMessageBox.critical(self, "Error", f"Cannot open file {file_path}:\n{str(e)}")
148
+ if len(self.project.jobs) > 0:
149
+ self.job_list.setCurrentRow(0)
150
+ self.activateWindow()
151
+ for job in self.project.jobs:
152
+ if 'working_path' in job.params.keys():
153
+ working_path = job.params['working_path']
154
+ if not os.path.isdir(working_path):
155
+ QMessageBox.warning(self, "Working path not found",
156
+ f'''The working path specified in the project file for the job:
157
+ "{job.params['name']}"
158
+ was not found.\n
159
+ Please, select a valid working path.''')
160
+ self.edit_action(job)
161
+ for action in job.sub_actions:
162
+ if 'working_path' in job.params.keys():
163
+ working_path = job.params['working_path']
164
+ if working_path != '' and not os.path.isdir(working_path):
165
+ QMessageBox.warning(self, "Working path not found",
166
+ f'''The working path specified in the project file for the job:
167
+ "{job.params['name']}"
168
+ was not found.\n
169
+ Please, select a valid working path.''')
170
+ self.edit_action(action)
171
+
172
+ def current_file_name(self):
173
+ return os.path.basename(self._current_file) if self._current_file else ''
174
+
175
+ def save_project(self):
176
+ if self._current_file:
177
+ self.do_save(self._current_file)
178
+ else:
179
+ self.save_project_as()
180
+
181
+ def save_project_as(self):
182
+ file_path, _ = QFileDialog.getSaveFileName(self, "Save Project As", "", "Project Files (*.fsp);;All Files (*)")
183
+ if file_path:
184
+ if not file_path.endswith('.fsp'):
185
+ file_path += '.fsp'
186
+ self._current_file_wd = ''
187
+ self.do_save(file_path)
188
+ self._current_file = file_path
189
+ self._modified_project = False
190
+ self.update_title()
191
+
192
+ def do_save(self, file_path):
193
+ try:
194
+ json_obj = jsonpickle.encode({
195
+ 'project': self.project.to_dict(),
196
+ 'version': 1
197
+ })
198
+ path = f"{self._current_file_wd}/{file_path}" if self._current_file_wd != '' else file_path
199
+ f = open(path, 'w')
200
+ f.write(json_obj)
201
+ self._modified_project = False
202
+ except Exception as e:
203
+ QMessageBox.critical(self, "Error", f"Cannot save file:\n{str(e)}")
204
+
205
+ def _check_unsaved_changes(self) -> bool:
206
+ if self._modified_project:
207
+ reply = QMessageBox.question(
208
+ self, "Unsaved Changes",
209
+ "The project has unsaved changes. Do you want to continue?",
210
+ QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel
211
+ )
212
+ if reply == QMessageBox.Save:
213
+ self.save_project()
214
+ return True
215
+ elif reply == QMessageBox.Discard:
216
+ return True
217
+ else:
218
+ return False
219
+ else:
220
+ return True
221
+
222
+ def on_job_edit(self, item):
223
+ index = self.job_list.row(item)
224
+ if 0 <= index < len(self.project.jobs):
225
+ job = self.project.jobs[index]
226
+ dialog = ActionConfigDialog(job, self)
227
+ if dialog.exec() == QDialog.Accepted:
228
+ current_row = self.job_list.currentRow()
229
+ if current_row >= 0:
230
+ self.job_list.item(current_row).setText(job.params['name'])
231
+ self.refresh_ui()
232
+
233
+ def on_action_edit(self, item):
234
+ job_index = self.job_list.currentRow()
235
+ if 0 <= job_index < len(self.project.jobs):
236
+ job = self.project.jobs[job_index]
237
+ action_index = self.action_list.row(item)
238
+ action_counter = -1
239
+ current_action = None
240
+ is_sub_action = False
241
+ for action in job.sub_actions:
242
+ action_counter += 1
243
+ if action_counter == action_index:
244
+ current_action = action
245
+ break
246
+ if len(action.type_name) > 0:
247
+ for sub_action in action.sub_actions:
248
+ action_counter += 1
249
+ if action_counter == action_index:
250
+ current_action = sub_action
251
+ is_sub_action = True
252
+ break
253
+ if current_action:
254
+ break
255
+ if current_action:
256
+ if not is_sub_action:
257
+ self.set_enabled_sub_actions_gui(current_action.type_name == constants.ACTION_COMBO)
258
+ dialog = ActionConfigDialog(current_action, self)
259
+ if dialog.exec() == QDialog.Accepted:
260
+ self.on_job_selected(job_index)
261
+ self.refresh_ui()
262
+ self.job_list.setCurrentRow(job_index)
263
+ self.action_list.setCurrentRow(action_index)
264
+
265
+ def edit_current_action(self):
266
+ current_action = None
267
+ job_row = self.job_list.currentRow()
268
+ if 0 <= job_row < len(self.project.jobs):
269
+ job = self.project.jobs[job_row]
270
+ if self.job_list.hasFocus():
271
+ current_action = job
272
+ elif self.action_list.hasFocus():
273
+ job_row, action_row, pos = self.get_current_action()
274
+ if pos.actions is not None:
275
+ current_action = pos.action if not pos.is_sub_action else pos.sub_action
276
+ if current_action is not None:
277
+ self.edit_action(current_action)
278
+
279
+ def edit_action(self, action):
280
+ dialog = ActionConfigDialog(action, self)
281
+ if dialog.exec() == QDialog.Accepted:
282
+ self.on_job_selected(self.job_list.currentRow())
283
+ self.mark_as_modified()
@@ -0,0 +1,57 @@
1
+ from PySide6.QtGui import QColor
2
+
3
+
4
+ class ColorEntry:
5
+ def __init__(self, r, g, b):
6
+ self.r = r
7
+ self.g = g
8
+ self.b = b
9
+
10
+ def tuple(self):
11
+ return self.r, self.g, self.b
12
+
13
+ def hex(self):
14
+ return f"{self.r:02x}{self.g:02x}{self.b:02x}"
15
+
16
+ def q_color(self):
17
+ return QColor(self.r, self.g, self.b)
18
+
19
+
20
+ class ColorPalette:
21
+ BLACK = ColorEntry(0, 0, 0)
22
+ WHITE = ColorEntry(255, 255, 255)
23
+ LIGHT_BLUE = ColorEntry(210, 210, 240)
24
+ LIGHT_RED = ColorEntry(240, 210, 210)
25
+ DARK_BLUE = ColorEntry(0, 0, 160)
26
+ DARK_RED = ColorEntry(160, 0, 0)
27
+ MEDIUM_BLUE = ColorEntry(160, 160, 200)
28
+ MEDIUM_GREEN = ColorEntry(160, 200, 160)
29
+ MEDIUM_RED = ColorEntry(200, 160, 160)
30
+
31
+
32
+ RED_BUTTON_STYLE = f"""
33
+ QPushButton {{
34
+ color: #{ColorPalette.DARK_RED.hex()};
35
+ }}
36
+ QPushButton:disabled {{
37
+ color: #{ColorPalette.MEDIUM_RED.hex()};
38
+ }}
39
+ """
40
+
41
+ BLUE_BUTTON_STYLE = f"""
42
+ QPushButton {{
43
+ color: #{ColorPalette.DARK_BLUE.hex()};
44
+ }}
45
+ QPushButton:disabled {{
46
+ color: #{ColorPalette.MEDIUM_BLUE.hex()};
47
+ }}
48
+ """
49
+
50
+ BLUE_COMBO_STYLE = f"""
51
+ QComboBox {{
52
+ color: #{ColorPalette.DARK_BLUE.hex()};
53
+ }}
54
+ QComboBox:disabled {{
55
+ color: #{ColorPalette.MEDIUM_BLUE.hex()};
56
+ }}
57
+ """
@@ -0,0 +1,152 @@
1
+ import webbrowser
2
+ import subprocess
3
+ import os
4
+ from PySide6.QtWidgets import QSizePolicy, QVBoxLayout, QWidget, QLabel, QStackedWidget
5
+ from PySide6.QtPdf import QPdfDocument
6
+ from PySide6.QtPdfWidgets import QPdfView
7
+ from PySide6.QtCore import Qt, QMargins
8
+ from PySide6.QtGui import QPixmap
9
+ from .. config.gui_constants import gui_constants
10
+ from .. core.core_utils import running_under_windows, running_under_macos
11
+
12
+
13
+ def open_file(file_path):
14
+ try:
15
+ if running_under_macos():
16
+ subprocess.call(('open', file_path))
17
+ elif running_under_windows():
18
+ os.startfile(file_path)
19
+ else:
20
+ subprocess.call(('xdg-open', file_path))
21
+ except Exception:
22
+ webbrowser.open("file://" + file_path)
23
+
24
+
25
+ class GuiPdfView(QPdfView):
26
+ def __init__(self, file_path, parent=None):
27
+ super().__init__(parent)
28
+ self.file_path = file_path
29
+ self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
30
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
31
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
32
+ self.setPageSpacing(0)
33
+ self.setDocumentMargins(QMargins(0, 0, 0, 0))
34
+ self.pdf_document = QPdfDocument()
35
+ err = self.pdf_document.load(file_path)
36
+ if err == QPdfDocument.Error.None_:
37
+ self.setDocument(self.pdf_document)
38
+ first_page_size = self.pdf_document.pagePointSize(0)
39
+ zoom_factor = gui_constants.GUI_IMG_WIDTH / first_page_size.width()
40
+ extra_zoom = 0.75 if running_under_windows() else 1.0
41
+ self.setZoomFactor(zoom_factor * extra_zoom)
42
+ self.setFixedSize(gui_constants.GUI_IMG_WIDTH,
43
+ int(first_page_size.height() * zoom_factor))
44
+ else:
45
+ raise RuntimeError(f"Can't load file: {file_path}. Error code: {err}.")
46
+ self.setStyleSheet('''
47
+ QWidget {
48
+ border: 2px solid #0000a0;
49
+ }
50
+ QWidget:hover {
51
+ border: 2px solid #a0a0ff;
52
+ }
53
+ ''')
54
+
55
+ def sizeHint(self):
56
+ return self.size()
57
+
58
+ def mouseReleaseEvent(self, event):
59
+ if event.button() == Qt.LeftButton:
60
+ open_file(self.file_path)
61
+ super().mouseReleaseEvent(event)
62
+
63
+
64
+ class GuiImageView(QWidget):
65
+ def __init__(self, file_path, parent=None):
66
+ super().__init__(parent)
67
+ self.file_path = file_path
68
+ self.setFixedWidth(gui_constants.GUI_IMG_WIDTH)
69
+ self.layout = QVBoxLayout()
70
+ self.layout.setContentsMargins(0, 0, 0, 0)
71
+ self.layout.setSpacing(0)
72
+ self.image_label = QLabel()
73
+ self.image_label.setAlignment(Qt.AlignCenter)
74
+ self.layout.addWidget(self.image_label)
75
+ self.setLayout(self.layout)
76
+ pixmap = QPixmap(file_path)
77
+ if pixmap:
78
+ scaled_pixmap = pixmap.scaledToWidth(gui_constants.GUI_IMG_WIDTH, Qt.SmoothTransformation)
79
+ self.image_label.setPixmap(scaled_pixmap)
80
+ else:
81
+ raise RuntimeError(f"Can't load file: {file_path}.")
82
+ self.setStyleSheet('''
83
+ QWidget {
84
+ border: 2px solid #0000a0;
85
+ }
86
+ QWidget:hover {
87
+ border: 2px solid #a0a0ff;
88
+ }
89
+ ''')
90
+
91
+ def sizeHint(self):
92
+ return self.size()
93
+
94
+ def mouseReleaseEvent(self, event):
95
+ if event.button() == Qt.LeftButton:
96
+ open_file(self.file_path)
97
+ super().mouseReleaseEvent(event)
98
+
99
+
100
+ class GuiOpenApp(QWidget):
101
+ def __init__(self, app, file_path, parent=None):
102
+ super().__init__(parent)
103
+ self.file_path = file_path
104
+ self.app = app
105
+ self.setFixedWidth(gui_constants.GUI_IMG_WIDTH)
106
+ self.layout = QVBoxLayout()
107
+ self.layout.setContentsMargins(0, 0, 0, 0)
108
+ self.layout.setSpacing(0)
109
+ self.image_label = QLabel()
110
+ self.image_label.setAlignment(Qt.AlignCenter)
111
+ self.layout.addWidget(self.image_label)
112
+ self.setLayout(self.layout)
113
+ pixmap = QPixmap(file_path)
114
+ if pixmap:
115
+ scaled_pixmap = pixmap.scaledToWidth(gui_constants.GUI_IMG_WIDTH, Qt.SmoothTransformation)
116
+ self.image_label.setPixmap(scaled_pixmap)
117
+ else:
118
+ raise RuntimeError(f"Can't load file: {file_path}.")
119
+ self.setStyleSheet('''
120
+ QWidget {
121
+ border: 2px solid #a00000;
122
+ }
123
+ QWidget:hover {
124
+ border: 2px solid #ffa0a0;
125
+ }
126
+ ''')
127
+
128
+ def sizeHint(self):
129
+ return self.size()
130
+
131
+ def mouseReleaseEvent(self, event):
132
+ if event.button() == Qt.LeftButton:
133
+ if self.app != 'internal_retouch_app':
134
+ try:
135
+ os.system(f"{self.app} -f {self.file_path} &")
136
+ except Exception as e:
137
+ raise RuntimeError(f"Can't open file {self.file_path} with app: {self.app}.\n{str(e)}")
138
+ else:
139
+ app = None
140
+ stacked_widget = self.parent().window().findChild(QStackedWidget)
141
+ if stacked_widget:
142
+ for child in stacked_widget.children():
143
+ if child.metaObject().className() == 'MainWindow':
144
+ app = child
145
+ break
146
+ else:
147
+ raise RuntimeError("MainWindow object not found")
148
+ if app:
149
+ app.retouch_callback(self.file_path)
150
+ else:
151
+ raise RuntimeError("MainWindow object not found")
152
+ super().mouseReleaseEvent(event)