shinestacker 1.2.1__py3-none-any.whl → 1.3.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 (40) hide show
  1. shinestacker/_version.py +1 -1
  2. shinestacker/algorithms/align.py +126 -94
  3. shinestacker/algorithms/align_auto.py +64 -0
  4. shinestacker/algorithms/align_parallel.py +296 -0
  5. shinestacker/algorithms/balance.py +3 -1
  6. shinestacker/algorithms/base_stack_algo.py +11 -2
  7. shinestacker/algorithms/multilayer.py +8 -8
  8. shinestacker/algorithms/noise_detection.py +10 -10
  9. shinestacker/algorithms/pyramid.py +4 -4
  10. shinestacker/algorithms/pyramid_auto.py +16 -10
  11. shinestacker/algorithms/pyramid_tiles.py +19 -11
  12. shinestacker/algorithms/stack.py +21 -17
  13. shinestacker/algorithms/stack_framework.py +97 -46
  14. shinestacker/algorithms/vignetting.py +13 -10
  15. shinestacker/app/main.py +7 -3
  16. shinestacker/config/constants.py +60 -25
  17. shinestacker/config/gui_constants.py +1 -1
  18. shinestacker/core/core_utils.py +4 -0
  19. shinestacker/core/framework.py +104 -23
  20. shinestacker/gui/action_config.py +4 -5
  21. shinestacker/gui/action_config_dialog.py +152 -12
  22. shinestacker/gui/base_form_dialog.py +2 -2
  23. shinestacker/gui/folder_file_selection.py +101 -0
  24. shinestacker/gui/gui_run.py +12 -10
  25. shinestacker/gui/main_window.py +6 -1
  26. shinestacker/gui/new_project.py +171 -73
  27. shinestacker/gui/project_controller.py +10 -6
  28. shinestacker/gui/project_converter.py +4 -2
  29. shinestacker/gui/project_editor.py +37 -27
  30. shinestacker/gui/select_path_widget.py +1 -1
  31. shinestacker/gui/sys_mon.py +96 -0
  32. shinestacker/gui/time_progress_bar.py +4 -3
  33. shinestacker/retouch/exif_data.py +1 -1
  34. shinestacker/retouch/image_editor_ui.py +2 -0
  35. {shinestacker-1.2.1.dist-info → shinestacker-1.3.0.dist-info}/METADATA +6 -6
  36. {shinestacker-1.2.1.dist-info → shinestacker-1.3.0.dist-info}/RECORD +40 -36
  37. {shinestacker-1.2.1.dist-info → shinestacker-1.3.0.dist-info}/WHEEL +0 -0
  38. {shinestacker-1.2.1.dist-info → shinestacker-1.3.0.dist-info}/entry_points.txt +0 -0
  39. {shinestacker-1.2.1.dist-info → shinestacker-1.3.0.dist-info}/licenses/LICENSE +0 -0
  40. {shinestacker-1.2.1.dist-info → shinestacker-1.3.0.dist-info}/top_level.txt +0 -0
@@ -1,8 +1,8 @@
1
1
  # pylint: disable=C0114, C0115, C0116, E0611, R0913, R0917, R0915, R0912
2
- # pylint: disable=E0606, W0718, R1702, W0102, W0221
2
+ # pylint: disable=E0606, W0718, R1702, W0102, W0221, R0914
3
+ import os
3
4
  import traceback
4
- from typing import Dict, Any
5
- from PySide6.QtWidgets import (QWidget, QPushButton, QHBoxLayout, QLabel, QScrollArea,
5
+ from PySide6.QtWidgets import (QWidget, QPushButton, QHBoxLayout, QLabel, QScrollArea, QSizePolicy,
6
6
  QMessageBox, QStackedWidget, QFormLayout, QDialog)
7
7
  from PySide6.QtCore import Qt, QTimer
8
8
  from .. config.constants import constants
@@ -14,6 +14,7 @@ from . action_config import (
14
14
  FIELD_TEXT, FIELD_ABS_PATH, FIELD_REL_PATH, FIELD_FLOAT,
15
15
  FIELD_INT, FIELD_INT_TUPLE, FIELD_BOOL, FIELD_COMBO, FIELD_REF_IDX
16
16
  )
17
+ from .folder_file_selection import FolderFileSelectionWidget
17
18
 
18
19
 
19
20
  class ActionConfigDialog(QDialog):
@@ -104,8 +105,6 @@ class ActionConfigDialog(QDialog):
104
105
  if self.configurator.update_params(self.action.params):
105
106
  self.parent().mark_as_modified(True, "Modify Configuration")
106
107
  super().accept()
107
- else:
108
- self.parent().project_editor.pop_undo()
109
108
 
110
109
  def reset_to_defaults(self):
111
110
  builder = self.configurator.get_builder()
@@ -124,7 +123,7 @@ class NoNameActionConfigurator(ActionConfigurator):
124
123
  def get_builder(self):
125
124
  return self.builder
126
125
 
127
- def update_params(self, params: Dict[str, Any]) -> bool:
126
+ def update_params(self, params):
128
127
  return self.builder.update_params(params)
129
128
 
130
129
  def add_bold_label(self, label):
@@ -139,6 +138,24 @@ class NoNameActionConfigurator(ActionConfigurator):
139
138
  required=False, add_to_layout=None, **kwargs):
140
139
  return self.builder.add_field(tag, field_type, label, required, add_to_layout, **kwargs)
141
140
 
141
+ def labelled_widget(self, label, widget):
142
+ row = QWidget()
143
+ layout = QHBoxLayout()
144
+ layout.setContentsMargins(2, 2, 2, 2)
145
+ layout.setSpacing(8)
146
+ label_widget = QLabel(label)
147
+ label_widget.setFixedWidth(120)
148
+ label_widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
149
+ layout.addWidget(label_widget)
150
+ layout.addWidget(widget)
151
+ layout.setStretch(0, 1)
152
+ layout.setStretch(1, 3)
153
+ row.setLayout(layout)
154
+ return row
155
+
156
+ def add_labelled_row(self, label, widget):
157
+ self.add_row(self.labelled_widget(label, widget))
158
+
142
159
 
143
160
  class DefaultActionConfigurator(NoNameActionConfigurator):
144
161
  def create_form(self, layout, action, tag='Action'):
@@ -148,13 +165,98 @@ class DefaultActionConfigurator(NoNameActionConfigurator):
148
165
 
149
166
 
150
167
  class JobConfigurator(DefaultActionConfigurator):
168
+ def __init__(self, expert, current_wd):
169
+ super().__init__(expert, current_wd)
170
+ self.working_path_label = None
171
+ self.input_path_label = None
172
+ self.frames_label = None
173
+ self.input_widget = None
174
+
151
175
  def create_form(self, layout, action):
152
176
  super().create_form(layout, action, "Job")
153
- self.add_field(
154
- 'working_path', FIELD_ABS_PATH, 'Working path', required=True)
155
- self.add_field(
156
- 'input_path', FIELD_REL_PATH, 'Input path', required=False,
157
- must_exist=True, placeholder='relative to working path')
177
+ self.input_widget = FolderFileSelectionWidget()
178
+ self.frames_label = QLabel("0")
179
+ working_path = action.params.get('working_path', '')
180
+ input_path = action.params.get('input_path', '')
181
+ input_filepaths = action.params.get('input_filepaths', [])
182
+ if isinstance(input_filepaths, str) and input_filepaths:
183
+ input_filepaths = input_filepaths.split(constants.PATH_SEPARATOR)
184
+ self.working_path_label = QLabel(working_path or "Not set")
185
+ self.input_path_label = QLabel(input_path or "Not set")
186
+ has_existing_data = working_path or input_path or input_filepaths
187
+ if input_filepaths:
188
+ self.input_widget.files_mode_radio.setChecked(True)
189
+ self.input_widget.selected_files = input_filepaths
190
+ if input_filepaths:
191
+ parent_dir = os.path.dirname(input_filepaths[0])
192
+ self.input_widget.path_edit.setText(parent_dir)
193
+ elif input_path and working_path:
194
+ self.input_widget.folder_mode_radio.setChecked(True)
195
+ input_fullpath = os.path.join(working_path, input_path)
196
+ self.input_widget.path_edit.setText(input_fullpath)
197
+ elif input_path:
198
+ self.input_widget.folder_mode_radio.setChecked(True)
199
+ self.input_widget.path_edit.setText(input_path)
200
+ self.input_widget.text_changed_connect(self.update_paths_and_frames)
201
+ self.input_widget.folder_mode_radio.toggled.connect(self.update_paths_and_frames)
202
+ self.input_widget.files_mode_radio.toggled.connect(self.update_paths_and_frames)
203
+ self.add_bold_label("Input Selection:")
204
+ self.add_row(self.input_widget)
205
+ self.add_labelled_row("Number of frames: ", self.frames_label)
206
+ self.add_bold_label("Derived Paths:")
207
+ self.add_labelled_row("Working path: ", self.working_path_label)
208
+ self.add_labelled_row("Input path:", self.input_path_label)
209
+ if not has_existing_data:
210
+ self.update_paths_and_frames()
211
+ else:
212
+ self.update_frames_count()
213
+
214
+ def update_frames_count(self):
215
+ if self.input_widget.get_selection_mode() == 'files':
216
+ count = len(self.input_widget.get_selected_files())
217
+ else:
218
+ count = self.count_image_files(self.input_widget.get_path())
219
+ self.frames_label.setText(str(count))
220
+
221
+ def update_paths_and_frames(self):
222
+ self.update_frames_count()
223
+ selection_mode = self.input_widget.get_selection_mode()
224
+ selected_files = self.input_widget.get_selected_files()
225
+ input_path = self.input_widget.get_path()
226
+ if selection_mode == 'files' and selected_files:
227
+ input_path = os.path.dirname(selected_files[0])
228
+ input_path_value = os.path.basename(os.path.normpath(input_path)) if input_path else ""
229
+ working_path_value = os.path.dirname(input_path) if input_path else ""
230
+ self.input_path_label.setText(input_path_value or "Not set")
231
+ self.working_path_label.setText(working_path_value or "Not set")
232
+
233
+ def count_image_files(self, path):
234
+ if not path or not os.path.isdir(path):
235
+ return 0
236
+ count = 0
237
+ for filename in os.listdir(path):
238
+ if filename.lower().endswith(('.png', '.jpg', '.jpeg', '.tif', '.tiff')):
239
+ count += 1
240
+ return count
241
+
242
+ def update_params(self, params):
243
+ if not super().update_params(params):
244
+ return False
245
+ selection_mode = self.input_widget.get_selection_mode()
246
+ selected_files = self.input_widget.get_selected_files()
247
+ input_path = self.input_widget.get_path()
248
+ if selection_mode == 'files' and selected_files:
249
+ params['input_filepaths'] = self.input_widget.get_selected_filenames()
250
+ params['input_path'] = os.path.dirname(selected_files[0])
251
+ else:
252
+ params['input_filepaths'] = []
253
+ params['input_path'] = input_path
254
+ if 'working_path' not in params or not params['working_path']:
255
+ if params['input_path']:
256
+ params['working_path'] = os.path.dirname(params['input_path'])
257
+ else:
258
+ params['working_path'] = ''
259
+ return True
158
260
 
159
261
 
160
262
  class NoiseDetectionConfigurator(DefaultActionConfigurator):
@@ -435,6 +537,13 @@ class CombinedActionsConfigurator(DefaultActionConfigurator):
435
537
  self.add_field(
436
538
  'step_process', FIELD_BOOL, 'Step process', required=False,
437
539
  default=True)
540
+ self.add_field(
541
+ 'max_threads', FIELD_INT, 'Max num. of cores',
542
+ required=False, default=constants.DEFAULT_MAX_FWK_THREADS,
543
+ min_val=1, max_val=64)
544
+ self.add_field(
545
+ 'chunk_submit', FIELD_BOOL, 'Submit in chunks',
546
+ required=False, default=constants.DEFAULT_MAX_FWK_CHUNK_SUBMIT)
438
547
 
439
548
 
440
549
  class MaskNoiseConfigurator(DefaultActionConfigurator):
@@ -484,6 +593,7 @@ class AlignFramesConfigurator(SubsampleActionConfigurator):
484
593
  TRANSFORM_OPTIONS = ['Rigid', 'Homography']
485
594
  METHOD_OPTIONS = ['Random Sample Consensus (RANSAC)', 'Least Median (LMEDS)']
486
595
  MATCHING_METHOD_OPTIONS = ['K-nearest neighbors', 'Hamming distance']
596
+ MODE_OPTIONS = ['Auto', 'Sequential', 'Parallel']
487
597
 
488
598
  def __init__(self, expert, current_wd):
489
599
  super().__init__(expert, current_wd)
@@ -650,6 +760,36 @@ class AlignFramesConfigurator(SubsampleActionConfigurator):
650
760
  default=constants.DEFAULT_BORDER_BLUR,
651
761
  min_val=0, max_val=1000, step=1)
652
762
  self.add_bold_label("Miscellanea:")
763
+ if self.expert:
764
+ mode = self.add_field(
765
+ 'mode', FIELD_COMBO, 'Mode',
766
+ required=False, options=self.MODE_OPTIONS, values=constants.ALIGN_VALID_MODES,
767
+ default=dict(zip(constants.ALIGN_VALID_MODES,
768
+ self.MODE_OPTIONS))[constants.DEFAULT_ALIGN_MODE])
769
+ memory_limit = self.add_field(
770
+ 'memory_limit', FIELD_FLOAT, 'Memory limit (approx., GBytes)',
771
+ required=False, default=constants.DEFAULT_ALIGN_MEMORY_LIMIT_GB,
772
+ min_val=1.0, max_val=64.0)
773
+ max_threads = self.add_field(
774
+ 'max_threads', FIELD_INT, 'Max num. of cores',
775
+ required=False, default=constants.DEFAULT_ALIGN_MAX_THREADS,
776
+ min_val=1, max_val=64)
777
+ chunk_submit = self.add_field(
778
+ 'chunk_submit', FIELD_BOOL, 'Submit in chunks',
779
+ required=False, default=constants.DEFAULT_ALIGN_CHUNK_SUBMIT)
780
+ bw_matching = self.add_field(
781
+ 'bw_matching', FIELD_BOOL, 'Match using black & white',
782
+ required=False, default=constants.DEFAULT_ALIGN_BW_MATCHING)
783
+
784
+ def change_mode():
785
+ text = mode.currentText()
786
+ enabled = text != self.MODE_OPTIONS[1]
787
+ memory_limit.setEnabled(enabled)
788
+ max_threads.setEnabled(enabled)
789
+ chunk_submit.setEnabled(enabled)
790
+ bw_matching.setEnabled(enabled)
791
+
792
+ mode.currentIndexChanged.connect(change_mode)
653
793
  self.add_field(
654
794
  'plot_summary', FIELD_BOOL, 'Plot summary',
655
795
  required=False, default=False)
@@ -657,7 +797,7 @@ class AlignFramesConfigurator(SubsampleActionConfigurator):
657
797
  'plot_matches', FIELD_BOOL, 'Plot matches',
658
798
  required=False, default=False)
659
799
 
660
- def update_params(self, params: Dict[str, Any]) -> bool:
800
+ def update_params(self, params):
661
801
  if self.detector_field and self.descriptor_field and self.matching_method_field:
662
802
  try:
663
803
  detector = self.detector_field.currentText()
@@ -13,10 +13,10 @@ def create_form_layout(parent):
13
13
 
14
14
 
15
15
  class BaseFormDialog(QDialog):
16
- def __init__(self, title, parent=None):
16
+ def __init__(self, title, width=500, parent=None):
17
17
  super().__init__(parent)
18
18
  self.setWindowTitle(title)
19
- self.resize(500, self.height())
19
+ self.resize(width, self.height())
20
20
  self.form_layout = create_form_layout(self)
21
21
 
22
22
  def add_row_to_layout(self, item):
@@ -0,0 +1,101 @@
1
+ # pylint: disable=C0114, C0115, C0116, E0611
2
+ import os
3
+ from PySide6.QtWidgets import (QWidget, QRadioButton, QButtonGroup, QLineEdit,
4
+ QPushButton, QHBoxLayout, QVBoxLayout, QFileDialog, QMessageBox)
5
+ from PySide6.QtCore import Qt
6
+
7
+
8
+ class FolderFileSelectionWidget(QWidget):
9
+ def __init__(self, parent=None):
10
+ super().__init__(parent)
11
+ self.selection_mode = 'folder' # 'folder' or 'files'
12
+ self.selected_files = []
13
+ self.setup_ui()
14
+
15
+ def setup_ui(self):
16
+ self.mode_group = QButtonGroup(self)
17
+ self.folder_mode_radio = QRadioButton("Select Folder")
18
+ self.folder_mode_radio.setMaximumWidth(100)
19
+ self.files_mode_radio = QRadioButton("Select Files")
20
+ self.files_mode_radio.setMaximumWidth(100)
21
+ self.folder_mode_radio.setChecked(True)
22
+ self.mode_group.addButton(self.folder_mode_radio)
23
+ self.mode_group.addButton(self.files_mode_radio)
24
+ self.path_edit = QLineEdit()
25
+ self.path_edit.setPlaceholderText("input files folder")
26
+ self.browse_button = QPushButton("Browse Folder...")
27
+ self.browse_button.setFixedWidth(120)
28
+ main_layout = QVBoxLayout()
29
+ main_layout.setSpacing(10)
30
+ main_layout.setAlignment(Qt.AlignLeft)
31
+ mode_layout = QHBoxLayout()
32
+ mode_layout.setContentsMargins(2, 2, 2, 2)
33
+ mode_layout.setSpacing(20)
34
+ mode_layout.addWidget(self.folder_mode_radio)
35
+ mode_layout.addWidget(self.files_mode_radio)
36
+ mode_layout.addStretch()
37
+ main_layout.addLayout(mode_layout)
38
+ input_layout = QHBoxLayout()
39
+ input_layout.setContentsMargins(2, 2, 2, 2)
40
+ input_layout.setSpacing(8)
41
+ input_layout.setAlignment(Qt.AlignLeft)
42
+ input_layout.addWidget(self.path_edit)
43
+ input_layout.addWidget(self.browse_button)
44
+ main_layout.addLayout(input_layout)
45
+ self.setLayout(main_layout)
46
+ self.folder_mode_radio.toggled.connect(self.update_selection_mode)
47
+ self.files_mode_radio.toggled.connect(self.update_selection_mode)
48
+ self.browse_button.clicked.connect(self.handle_browse)
49
+
50
+ def update_selection_mode(self):
51
+ if self.folder_mode_radio.isChecked():
52
+ self.selection_mode = 'folder'
53
+ self.browse_button.setText("Browse Folder...")
54
+ # self.path_edit.setPlaceholderText("input files folder")
55
+ else:
56
+ self.selection_mode = 'files'
57
+ self.browse_button.setText("Browse Files...")
58
+ # self.path_edit.setPlaceholderText("input files")
59
+
60
+ def handle_browse(self):
61
+ if self.selection_mode == 'folder':
62
+ self.browse_folder()
63
+ else:
64
+ self.browse_files()
65
+
66
+ def browse_folder(self):
67
+ path = QFileDialog.getExistingDirectory(self, "Select Input Folder")
68
+ if path:
69
+ self.selected_files = []
70
+ self.path_edit.setText(path)
71
+
72
+ def browse_files(self):
73
+ files, _ = QFileDialog.getOpenFileNames(
74
+ self, "Select Input Files", "",
75
+ "Image files (*.png *.jpg *.jpeg *.tif *.tiff)"
76
+ )
77
+ if files:
78
+ parent_dir = os.path.dirname(files[0])
79
+ if all(os.path.dirname(f) == parent_dir for f in files):
80
+ self.selected_files = files
81
+ self.path_edit.setText(parent_dir)
82
+ else:
83
+ QMessageBox.warning(
84
+ self, "Invalid Selection",
85
+ "All files must be in the same directory."
86
+ )
87
+
88
+ def get_selection_mode(self):
89
+ return self.selection_mode
90
+
91
+ def get_selected_files(self):
92
+ return self.selected_files
93
+
94
+ def get_selected_filenames(self):
95
+ return [os.path.basename(file_path) for file_path in self.selected_files]
96
+
97
+ def get_path(self):
98
+ return self.path_edit.text()
99
+
100
+ def text_changed_connect(self, callback):
101
+ self.path_edit.textChanged.connect(callback)
@@ -18,6 +18,7 @@ from .colors import (
18
18
  ACTION_STOPPED_COLOR, ACTION_FAILED_COLOR)
19
19
  from .time_progress_bar import TimerProgressBar
20
20
  from .flow_layout import FlowLayout
21
+ from .sys_mon import StatusBarSystemMonitor
21
22
 
22
23
 
23
24
  class ColorButton(QPushButton):
@@ -96,7 +97,8 @@ class RunWindow(QTextEditLogger):
96
97
  self.right_area.setMaximumWidth(0)
97
98
  self.image_area_widget.setFixedWidth(0)
98
99
  layout.addLayout(output_layout)
99
-
100
+ self.system_monitor = StatusBarSystemMonitor(self)
101
+ self.status_bar.addPermanentWidget(self.system_monitor)
100
102
  n_paths = len(self.retouch_paths) if self.retouch_paths else 0
101
103
  if n_paths == 1:
102
104
  self.retouch_widget = QPushButton(f"Retouch {self.retouch_paths[0][0]}")
@@ -284,15 +286,15 @@ class RunWorker(LogWorker):
284
286
  self.id_str = id_str
285
287
  self.status = constants.STATUS_RUNNING
286
288
  self.callbacks = {
287
- 'before_action': self.before_action,
288
- 'after_action': self.after_action,
289
- 'step_counts': self.step_counts,
290
- 'begin_steps': self.begin_steps,
291
- 'end_steps': self.end_steps,
292
- 'after_step': self.after_step,
293
- 'save_plot': self.save_plot,
294
- 'check_running': self.check_running,
295
- 'open_app': self.open_app
289
+ constants.CALLBACK_BEFORE_ACTION: self.before_action,
290
+ constants.CALLBACK_AFTER_ACTION: self.after_action,
291
+ constants.CALLBACK_STEP_COUNTS: self.step_counts,
292
+ constants.CALLBACK_BEGIN_STEPS: self.begin_steps,
293
+ constants.CALLBACK_END_STEPS: self.end_steps,
294
+ constants.CALLBACK_AFTER_STEP: self.after_step,
295
+ constants.CALLBACK_CHECK_RUNNING: self.check_running,
296
+ constants.CALLBACK_SAVE_PLOT: self.save_plot,
297
+ constants.CALLBACK_OPEN_APP: self.open_app
296
298
  }
297
299
  self.tag = ""
298
300
 
@@ -43,7 +43,10 @@ class ProjectLogWorker(RunWorker):
43
43
 
44
44
  LIST_STYLE_SHEET = f"""
45
45
  QListWidget::item:selected {{
46
- background-color: #{ColorPalette.LIGHT_BLUE.hex()};;
46
+ background-color: #{ColorPalette.LIGHT_BLUE.hex()};
47
+ }}
48
+ QListWidget::item:hover {{
49
+ background-color: #F0F0F0;
47
50
  }}
48
51
  """
49
52
 
@@ -421,6 +424,8 @@ class MainWindow(QMainWindow, LogManager):
421
424
  for worker in self._workers:
422
425
  worker.stop()
423
426
  self.close()
427
+ return True
428
+ return False
424
429
 
425
430
  def toggle_expert_options(self):
426
431
  self.expert_options = self.menu_manager.expert_options_action.isChecked()