shinestacker 1.6.1__py3-none-any.whl → 1.8.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 (45) hide show
  1. shinestacker/_version.py +1 -1
  2. shinestacker/algorithms/corrections.py +26 -0
  3. shinestacker/algorithms/stack.py +9 -0
  4. shinestacker/algorithms/stack_framework.py +35 -16
  5. shinestacker/algorithms/utils.py +5 -1
  6. shinestacker/app/args_parser_opts.py +39 -0
  7. shinestacker/app/gui_utils.py +19 -2
  8. shinestacker/app/main.py +16 -27
  9. shinestacker/app/project.py +12 -23
  10. shinestacker/app/retouch.py +12 -25
  11. shinestacker/app/settings_dialog.py +46 -3
  12. shinestacker/config/settings.py +4 -1
  13. shinestacker/core/core_utils.py +2 -2
  14. shinestacker/core/framework.py +7 -2
  15. shinestacker/core/logging.py +2 -2
  16. shinestacker/gui/action_config_dialog.py +72 -45
  17. shinestacker/gui/gui_run.py +1 -2
  18. shinestacker/gui/ico/shinestacker.icns +0 -0
  19. shinestacker/gui/img/dark/close-round-line-icon.png +0 -0
  20. shinestacker/gui/img/dark/forward-button-icon.png +0 -0
  21. shinestacker/gui/img/dark/play-button-round-icon.png +0 -0
  22. shinestacker/gui/img/dark/plus-round-line-icon.png +0 -0
  23. shinestacker/gui/img/dark/shinestacker_bkg.png +0 -0
  24. shinestacker/gui/img/light/shinestacker_bkg.png +0 -0
  25. shinestacker/gui/main_window.py +20 -7
  26. shinestacker/gui/menu_manager.py +18 -7
  27. shinestacker/gui/new_project.py +0 -2
  28. shinestacker/gui/tab_widget.py +16 -10
  29. shinestacker/retouch/adjustments.py +98 -0
  30. shinestacker/retouch/base_filter.py +62 -7
  31. shinestacker/retouch/denoise_filter.py +1 -1
  32. shinestacker/retouch/image_editor_ui.py +26 -4
  33. shinestacker/retouch/unsharp_mask_filter.py +13 -28
  34. shinestacker/retouch/vignetting_filter.py +1 -1
  35. {shinestacker-1.6.1.dist-info → shinestacker-1.8.0.dist-info}/METADATA +4 -4
  36. {shinestacker-1.6.1.dist-info → shinestacker-1.8.0.dist-info}/RECORD +44 -37
  37. shinestacker/gui/ico/focus_stack_bkg.png +0 -0
  38. /shinestacker/gui/img/{close-round-line-icon.png → light/close-round-line-icon.png} +0 -0
  39. /shinestacker/gui/img/{forward-button-icon.png → light/forward-button-icon.png} +0 -0
  40. /shinestacker/gui/img/{play-button-round-icon.png → light/play-button-round-icon.png} +0 -0
  41. /shinestacker/gui/img/{plus-round-line-icon.png → light/plus-round-line-icon.png} +0 -0
  42. {shinestacker-1.6.1.dist-info → shinestacker-1.8.0.dist-info}/WHEEL +0 -0
  43. {shinestacker-1.6.1.dist-info → shinestacker-1.8.0.dist-info}/entry_points.txt +0 -0
  44. {shinestacker-1.6.1.dist-info → shinestacker-1.8.0.dist-info}/licenses/LICENSE +0 -0
  45. {shinestacker-1.6.1.dist-info → shinestacker-1.8.0.dist-info}/top_level.txt +0 -0
@@ -1,5 +1,5 @@
1
1
  # pylint: disable=C0114, C0115, C0116, E0611, R0913, R0917, R0915, R0912
2
- # pylint: disable=E0606, W0718, R1702, W0102, W0221, R0914, C0302
2
+ # pylint: disable=E0606, W0718, R1702, W0102, W0221, R0914, C0302, R0903
3
3
  import os
4
4
  import traceback
5
5
  from PySide6.QtCore import QTimer
@@ -214,7 +214,7 @@ class FocusStackBaseConfigurator(DefaultActionConfigurator):
214
214
  expert=True,
215
215
  placeholder='relative to working path')
216
216
  self.add_field_to_layout(
217
- layout, 'scratch_output_dir', FIELD_BOOL, 'Scratch output dir.',
217
+ layout, 'scratch_output_dir', FIELD_BOOL, 'Scratch output folder before run',
218
218
  required=False, default=True)
219
219
 
220
220
  def create_algorithm_tab(self, layout):
@@ -366,6 +366,14 @@ class FocusStackBunchConfigurator(FocusStackBaseConfigurator):
366
366
  self.add_field_to_layout(
367
367
  self.general_tab_layout, 'overlap', FIELD_INT, 'Overlapping frames', required=False,
368
368
  default=constants.DEFAULT_OVERLAP, min_val=0, max_val=100)
369
+ self.add_field_to_layout(
370
+ self.general_tab_layout, 'scratch_output_dir', FIELD_BOOL,
371
+ 'Scratch output folder before run',
372
+ required=False, default=True)
373
+ self.add_field_to_layout(
374
+ self.general_tab_layout, 'delete_output_at_end', FIELD_BOOL,
375
+ 'Delete output at end of job',
376
+ required=False, default=False)
369
377
  self.add_field_to_layout(
370
378
  self.general_tab_layout, 'plot_stack', FIELD_BOOL, 'Plot stack', required=False,
371
379
  default=constants.DEFAULT_PLOT_STACK_BUNCH)
@@ -391,7 +399,7 @@ class MultiLayerConfigurator(DefaultActionConfigurator):
391
399
  expert=True,
392
400
  placeholder='relative to working path')
393
401
  self.add_field(
394
- 'scratch_output_dir', FIELD_BOOL, 'Scratch output dir.',
402
+ 'scratch_output_dir', FIELD_BOOL, 'Scratch output folder before run',
395
403
  required=False, default=True)
396
404
  self.add_field(
397
405
  'reverse_order', FIELD_BOOL, 'Reverse file order', required=False,
@@ -413,8 +421,11 @@ class CombinedActionsConfigurator(DefaultActionConfigurator):
413
421
  expert=True,
414
422
  placeholder='relative to working path')
415
423
  self.add_field(
416
- 'scratch_output_dir', FIELD_BOOL, 'Scratch output dir.',
424
+ 'scratch_output_dir', FIELD_BOOL, 'Scratch output folder before run',
417
425
  required=False, default=True)
426
+ self.add_field(
427
+ 'delete_output_at_end', FIELD_BOOL, 'Delete output at end of job',
428
+ required=False, default=False)
418
429
  self.add_field(
419
430
  'plot_path', FIELD_REL_PATH, 'Plots path', required=False,
420
431
  expert=True,
@@ -490,22 +501,22 @@ class SubsampleActionConfigurator(DefaultActionConfigurator):
490
501
  self.subsample_field.currentText() not in constants.FIELD_SUBSAMPLE_OPTIONS[:2])
491
502
 
492
503
 
493
- class AlignFramesConfigurator(SubsampleActionConfigurator):
494
- BORDER_MODE_OPTIONS = ['Constant', 'Replicate', 'Replicate and blur']
495
- TRANSFORM_OPTIONS = ['Rigid', 'Homography']
496
- METHOD_OPTIONS = ['Random Sample Consensus (RANSAC)', 'Least Median (LMEDS)']
504
+ class AlignFramesConfigBase:
497
505
  MATCHING_METHOD_OPTIONS = ['K-nearest neighbors', 'Hamming distance']
498
- MODE_OPTIONS = ['Auto', 'Sequential', 'Parallel']
506
+ DETECTOR_DESCRIPTOR_TOOLTIPS = {
507
+ 'detector':
508
+ "SIFT: Requires SIFT descriptor and K-NN matching\n"
509
+ "ORB/AKAZE: Work best with Hamming distance",
510
+ 'descriptor':
511
+ "SIFT: Requires K-NN matching\n"
512
+ "ORB/AKAZE: Require Hamming distance with ORB/AKAZE detectors",
513
+ 'match_method':
514
+ "Automatically selected based on detector/descriptor combination"
499
515
 
500
- def __init__(self, expert, current_wd):
501
- super().__init__(expert, current_wd)
502
- self.matching_method_field = None
516
+ }
517
+
518
+ def __init__(self):
503
519
  self.info_label = None
504
- self.detector_field = None
505
- self.descriptor_field = None
506
- self.matching_method_field = None
507
- self.tab_widget = None
508
- self.current_tab_layout = None
509
520
 
510
521
  def show_info(self, message, timeout=3000):
511
522
  self.info_label.setText(message)
@@ -514,32 +525,50 @@ class AlignFramesConfigurator(SubsampleActionConfigurator):
514
525
  timer.timeout.connect(lambda: self.info_label.setText(''))
515
526
  timer.start(timeout)
516
527
 
517
- def change_match_config(self):
518
- detector = self.detector_field.currentText()
519
- descriptor = self.descriptor_field.currentText()
528
+ def change_match_config(
529
+ self, detector_field, descriptor_field, matching_method_field, show_info):
530
+ detector = detector_field.currentText()
531
+ descriptor = descriptor_field.currentText()
520
532
  match_method = dict(
521
533
  zip(self.MATCHING_METHOD_OPTIONS,
522
- constants.VALID_MATCHING_METHODS))[self.matching_method_field.currentText()]
534
+ constants.VALID_MATCHING_METHODS))[matching_method_field.currentText()]
523
535
  try:
524
536
  validate_align_config(detector, descriptor, match_method)
525
537
  except Exception as e:
526
- self.show_info(str(e))
538
+ show_info(str(e))
527
539
  if descriptor == constants.DETECTOR_SIFT and \
528
540
  match_method == constants.MATCHING_NORM_HAMMING:
529
- self.matching_method_field.setCurrentText(self.MATCHING_METHOD_OPTIONS[0])
541
+ matching_method_field.setCurrentText(self.MATCHING_METHOD_OPTIONS[0])
530
542
  if detector == constants.DETECTOR_ORB and descriptor == constants.DESCRIPTOR_AKAZE and \
531
543
  match_method == constants.MATCHING_NORM_HAMMING:
532
- self.matching_method_field.setCurrentText(constants.MATCHING_NORM_HAMMING)
544
+ matching_method_field.setCurrentText(constants.MATCHING_NORM_HAMMING)
533
545
  if detector == constants.DETECTOR_BRISK and descriptor == constants.DESCRIPTOR_AKAZE:
534
- self.descriptor_field.setCurrentText('BRISK')
546
+ descriptor_field.setCurrentText('BRISK')
535
547
  if detector == constants.DETECTOR_SURF and descriptor == constants.DESCRIPTOR_AKAZE:
536
- self.descriptor_field.setCurrentText('SIFT')
548
+ descriptor_field.setCurrentText('SIFT')
537
549
  if detector == constants.DETECTOR_SIFT and descriptor != constants.DESCRIPTOR_SIFT:
538
- self.descriptor_field.setCurrentText('SIFT')
550
+ descriptor_field.setCurrentText('SIFT')
539
551
  if detector in constants.NOKNN_METHODS['detectors'] and \
540
552
  descriptor in constants.NOKNN_METHODS['descriptors']:
541
553
  if match_method == constants.MATCHING_KNN:
542
- self.matching_method_field.setCurrentText(self.MATCHING_METHOD_OPTIONS[1])
554
+ matching_method_field.setCurrentText(self.MATCHING_METHOD_OPTIONS[1])
555
+
556
+
557
+ class AlignFramesConfigurator(SubsampleActionConfigurator, AlignFramesConfigBase):
558
+ BORDER_MODE_OPTIONS = ['Constant', 'Replicate', 'Replicate and blur']
559
+ TRANSFORM_OPTIONS = ['Rigid', 'Homography']
560
+ METHOD_OPTIONS = ['Random Sample Consensus (RANSAC)', 'Least Median (LMEDS)']
561
+ MODE_OPTIONS = ['Auto', 'Sequential', 'Parallel']
562
+
563
+ def __init__(self, expert, current_wd):
564
+ SubsampleActionConfigurator.__init__(self, expert, current_wd)
565
+ AlignFramesConfigBase.__init__(self)
566
+ self.matching_method_field = None
567
+ self.detector_field = None
568
+ self.descriptor_field = None
569
+ self.matching_method_field = None
570
+ self.tab_widget = None
571
+ self.current_tab_layout = None
543
572
 
544
573
  def create_form(self, layout, action):
545
574
  super().create_form(layout, action)
@@ -557,23 +586,23 @@ class AlignFramesConfigurator(SubsampleActionConfigurator):
557
586
  self.create_miscellanea_tab(misc_layout)
558
587
 
559
588
  def create_feature_tab(self, layout):
589
+
590
+ def change_match_config():
591
+ self.change_match_config(
592
+ self.detector_field, self.descriptor_field,
593
+ self. matching_method_field, self.show_info)
594
+
560
595
  self.add_bold_label_to_layout(layout, "Feature identification:")
561
596
  self.detector_field = self.add_field_to_layout(
562
597
  layout, 'detector', FIELD_COMBO, 'Detector', required=False,
563
- options=constants.VALID_DETECTORS, default=constants.DEFAULT_DETECTOR)
598
+ options=constants.VALID_DETECTORS, default=AppConfig.get('detector'))
564
599
  self.descriptor_field = self.add_field_to_layout(
565
600
  layout, 'descriptor', FIELD_COMBO, 'Descriptor', required=False,
566
- options=constants.VALID_DESCRIPTORS, default=constants.DEFAULT_DESCRIPTOR)
567
- self.detector_field.setToolTip(
568
- "SIFT: Requires SIFT descriptor and K-NN matching\n"
569
- "ORB/AKAZE: Work best with Hamming distance"
570
- )
571
- self.descriptor_field.setToolTip(
572
- "SIFT: Requires K-NN matching\n"
573
- "ORB/AKAZE: Require Hamming distance with ORB/AKAZE detectors"
574
- )
575
- self.detector_field.currentIndexChanged.connect(self.change_match_config)
576
- self.descriptor_field.currentIndexChanged.connect(self.change_match_config)
601
+ options=constants.VALID_DESCRIPTORS, default=AppConfig.get('descriptor'))
602
+ self.detector_field.setToolTip(self.DETECTOR_DESCRIPTOR_TOOLTIPS['detector'])
603
+ self.descriptor_field.setToolTip(self.DETECTOR_DESCRIPTOR_TOOLTIPS['descriptor'])
604
+ self.detector_field.currentIndexChanged.connect(change_match_config)
605
+ self.descriptor_field.currentIndexChanged.connect(change_match_config)
577
606
  self.info_label = QLabel()
578
607
  self.info_label.setStyleSheet("color: orange; font-style: italic;")
579
608
  layout.addRow(self.info_label)
@@ -581,11 +610,9 @@ class AlignFramesConfigurator(SubsampleActionConfigurator):
581
610
  self.matching_method_field = self.add_field_to_layout(
582
611
  layout, 'match_method', FIELD_COMBO, 'Match method', required=False,
583
612
  options=self.MATCHING_METHOD_OPTIONS, values=constants.VALID_MATCHING_METHODS,
584
- default=constants.DEFAULT_MATCHING_METHOD)
585
- self.matching_method_field.setToolTip(
586
- "Automatically selected based on detector/descriptor combination"
587
- )
588
- self.matching_method_field.currentIndexChanged.connect(self.change_match_config)
613
+ default=AppConfig.get('match_method'))
614
+ self.matching_method_field.setToolTip(self.DETECTOR_DESCRIPTOR_TOOLTIPS['match_method'])
615
+ self.matching_method_field.currentIndexChanged.connect(change_match_config)
589
616
  self.add_field_to_layout(
590
617
  layout, 'flann_idx_kdtree', FIELD_INT, 'Flann idx kdtree', required=False,
591
618
  expert=True,
@@ -215,8 +215,7 @@ class RunWindow(QTextEditLogger):
215
215
  raise RuntimeError(f"Can't visualize file type {os.path.splitext(path)[1]}.")
216
216
  self.image_views.append(image_view)
217
217
  self.image_layout.addWidget(image_view)
218
- max_width = max(pv.size().width() for pv in self.image_views) if self.image_views else 0
219
- needed_width = max_width + 20
218
+ needed_width = gui_constants.GUI_IMG_WIDTH + 20
220
219
  self.right_area.setFixedWidth(needed_width)
221
220
  self.image_area_widget.setFixedWidth(needed_width)
222
221
  self.right_area.updateGeometry()
Binary file
@@ -3,9 +3,9 @@
3
3
  import os
4
4
  import subprocess
5
5
  from PySide6.QtCore import Qt
6
- from PySide6.QtGui import QGuiApplication, QAction, QIcon
6
+ from PySide6.QtGui import QGuiApplication, QAction, QPalette
7
7
  from PySide6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QMessageBox,
8
- QSplitter, QToolBar, QMenu, QMainWindow)
8
+ QSplitter, QToolBar, QMenu, QMainWindow, QApplication)
9
9
  from .. config.constants import constants
10
10
  from .. config.app_config import AppConfig
11
11
  from .. core.core_utils import running_under_windows, running_under_macos
@@ -82,7 +82,9 @@ class MainWindow(QMainWindow, LogManager):
82
82
  "Run Job": self.run_job,
83
83
  "Run All Jobs": self.run_all_jobs,
84
84
  }
85
- self.menu_manager = MenuManager(self.menuBar(), actions, self.project_editor, self)
85
+ dark_theme = self.is_dark_theme()
86
+ self.menu_manager = MenuManager(
87
+ self.menuBar(), actions, self.project_editor, dark_theme, self)
86
88
  self.script_dir = os.path.dirname(__file__)
87
89
  self._windows = []
88
90
  self._workers = []
@@ -107,7 +109,7 @@ class MainWindow(QMainWindow, LogManager):
107
109
  top_widget = QWidget()
108
110
  top_widget.setLayout(h_layout)
109
111
  h_splitter.addWidget(top_widget)
110
- self.tab_widget = TabWidgetWithPlaceholder()
112
+ self.tab_widget = TabWidgetWithPlaceholder(dark_theme)
111
113
  self.tab_widget.resize(1000, 500)
112
114
  h_splitter.addWidget(self.tab_widget)
113
115
  self.job_list().currentRowChanged.connect(self.project_editor.on_job_selected)
@@ -128,6 +130,7 @@ class MainWindow(QMainWindow, LogManager):
128
130
  layout.addWidget(h_splitter)
129
131
  self.central_widget.setLayout(layout)
130
132
  self.update_title()
133
+ QApplication.instance().paletteChanged.connect(self.on_theme_changed)
131
134
 
132
135
  def handle_modified(modified):
133
136
  self.save_actions_set_enabled(modified)
@@ -367,9 +370,6 @@ class MainWindow(QMainWindow, LogManager):
367
370
  menu.exec(event.globalPos())
368
371
  # pylint: enable=C0103
369
372
 
370
- def get_icon(self, icon):
371
- return QIcon(os.path.join(self.script_dir, f"img/{icon}.png"))
372
-
373
373
  def get_retouch_path(self, job):
374
374
  frames_path = [get_action_output_path(action)[0]
375
375
  for action in job.sub_actions
@@ -581,3 +581,16 @@ class MainWindow(QMainWindow, LogManager):
581
581
  for action in self.findChildren(QAction):
582
582
  if action.property("requires_file"):
583
583
  action.setEnabled(enabled)
584
+
585
+ def is_dark_theme(self):
586
+ palette = QApplication.palette()
587
+ window_color = palette.color(QPalette.Window)
588
+ brightness = (window_color.red() * 0.299 +
589
+ window_color.green() * 0.587 +
590
+ window_color.blue() * 0.114)
591
+ return brightness < 128
592
+
593
+ def on_theme_changed(self):
594
+ dark_theme = self.is_dark_theme()
595
+ self.menu_manager.change_theme(dark_theme)
596
+ self.tab_widget.change_theme(dark_theme)
@@ -1,4 +1,4 @@
1
- # pylint: disable=C0114, C0115, C0116, R0904, E0611, R0902, W0201
1
+ # pylint: disable=C0114, C0115, C0116, R0904, E0611, R0902, W0201, R0913, R0917
2
2
  import os
3
3
  from functools import partial
4
4
  from PySide6.QtCore import Signal, QObject
@@ -12,11 +12,12 @@ from .recent_file_manager import RecentFileManager
12
12
  class MenuManager(QObject):
13
13
  open_file_requested = Signal(str)
14
14
 
15
- def __init__(self, menubar, actions, project_editor, parent):
15
+ def __init__(self, menubar, actions, project_editor, dark_theme, parent):
16
16
  super().__init__(parent)
17
17
  self.script_dir = os.path.dirname(__file__)
18
18
  self._recent_file_manager = RecentFileManager("shinestacker-recent-project-files.txt")
19
19
  self.project_editor = project_editor
20
+ self.dark_theme = dark_theme
20
21
  self.parent = parent
21
22
  self.menubar = menubar
22
23
  self.actions = actions
@@ -58,8 +59,9 @@ class MenuManager(QObject):
58
59
  "Run All Jobs": "Run all jobs",
59
60
  }
60
61
 
61
- def get_icon(self, icon):
62
- return QIcon(os.path.join(self.script_dir, f"img/{icon}.png"))
62
+ def get_icon(self, icon_name):
63
+ icon_dir = 'dark' if self.dark_theme else 'light'
64
+ return QIcon(os.path.join(self.script_dir, f"img/{icon_dir}/{icon_name}.png"))
63
65
 
64
66
  def action(self, name, requires_file=False):
65
67
  action = QAction(name, self.parent)
@@ -68,9 +70,11 @@ class MenuManager(QObject):
68
70
  shortcut = self.shortcuts.get(name, '')
69
71
  if shortcut:
70
72
  action.setShortcut(shortcut)
71
- icon = self.icons.get(name, '')
72
- if icon:
73
- action.setIcon(self.get_icon(icon))
73
+ icon_name = self.icons.get(name, '')
74
+ if icon_name:
75
+ action.setIcon(self.get_icon(icon_name))
76
+ action.setProperty('theme_dependent', True)
77
+ action.setProperty('base_icon_name', icon_name)
74
78
  tooltip = self.tooltips.get(name, '')
75
79
  if tooltip:
76
80
  action.setToolTip(tooltip)
@@ -79,6 +83,13 @@ class MenuManager(QObject):
79
83
  action.triggered.connect(action_fun)
80
84
  return action
81
85
 
86
+ def change_theme(self, dark_theme):
87
+ self.dark_theme = dark_theme
88
+ for action in self.parent.findChildren(QAction):
89
+ if action.property("theme_dependent"):
90
+ base_name = action.property("base_icon_name")
91
+ action.setIcon(self.get_icon(base_name))
92
+
82
93
  def update_recent_files(self):
83
94
  self.recent_files_menu.clear()
84
95
  recent_files = self._recent_file_manager.get_files_with_display_names()
@@ -169,13 +169,11 @@ class NewProjectDialog(BaseFormDialog):
169
169
  border-radius: 5px;
170
170
  margin-top: 10px;
171
171
  padding-top: 15px;
172
- background-color: #f8f8f8;
173
172
  }
174
173
  QGroupBox::title {
175
174
  subcontrol-origin: margin;
176
175
  left: 10px;
177
176
  padding: 0 5px 0 5px;
178
- background-color: #f8f8f8;
179
177
  }
180
178
  """
181
179
  for group in [step1_group, step2_group, step3_group, step4_group]:
@@ -3,15 +3,16 @@ import os
3
3
  from PySide6.QtCore import Qt, Signal
4
4
  from PySide6.QtGui import QPixmap
5
5
  from PySide6.QtWidgets import QWidget, QVBoxLayout, QTabWidget, QLabel, QStackedWidget
6
- from .. core.core_utils import get_app_base_path
7
6
 
8
7
 
9
8
  class TabWidgetWithPlaceholder(QWidget):
10
9
  currentChanged = Signal(int)
11
10
  tabCloseRequested = Signal(int)
12
11
 
13
- def __init__(self, parent=None):
12
+ def __init__(self, dark_theme, parent=None):
14
13
  super().__init__(parent)
14
+ self.script_dir = os.path.dirname(__file__)
15
+ self.dark_theme = dark_theme
15
16
  self.main_layout = QVBoxLayout(self)
16
17
  self.main_layout.setContentsMargins(0, 0, 0, 0)
17
18
  self.stacked_widget = QStackedWidget()
@@ -20,10 +21,15 @@ class TabWidgetWithPlaceholder(QWidget):
20
21
  self.stacked_widget.addWidget(self.tab_widget)
21
22
  self.placeholder = QLabel()
22
23
  self.placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
23
- rel_path = 'ico/focus_stack_bkg.png'
24
- icon_path = f'{get_app_base_path()}/{rel_path}'
25
- if not os.path.exists(icon_path):
26
- icon_path = f'{get_app_base_path()}/../{rel_path}'
24
+ self.set_bkg_icon()
25
+ self.stacked_widget.addWidget(self.placeholder)
26
+ self.tab_widget.currentChanged.connect(self._on_current_changed)
27
+ self.tab_widget.tabCloseRequested.connect(self._on_tab_close_requested)
28
+ self.update_placeholder_visibility()
29
+
30
+ def set_bkg_icon(self):
31
+ icon_dir = 'dark' if self.dark_theme else 'light'
32
+ icon_path = os.path.join(self.script_dir, f"img/{icon_dir}/shinestacker_bkg.png")
27
33
  if os.path.exists(icon_path):
28
34
  pixmap = QPixmap(icon_path)
29
35
  pixmap = pixmap.scaled(250, 250, Qt.AspectRatioMode.KeepAspectRatio,
@@ -31,10 +37,10 @@ class TabWidgetWithPlaceholder(QWidget):
31
37
  self.placeholder.setPixmap(pixmap)
32
38
  else:
33
39
  self.placeholder.setText("Run logs will appear here.")
34
- self.stacked_widget.addWidget(self.placeholder)
35
- self.tab_widget.currentChanged.connect(self._on_current_changed)
36
- self.tab_widget.tabCloseRequested.connect(self._on_tab_close_requested)
37
- self.update_placeholder_visibility()
40
+
41
+ def change_theme(self, dark_theme):
42
+ self.dark_theme = dark_theme
43
+ self.set_bkg_icon()
38
44
 
39
45
  def _on_current_changed(self, index):
40
46
  self.currentChanged.emit(index)
@@ -0,0 +1,98 @@
1
+ # pylint: disable=C0114, C0115, C0116, E0611, W0221, R0913, R0917, R0902, R0914, E1101
2
+ from abc import abstractmethod
3
+ import math
4
+ import cv2
5
+ from .base_filter import BaseFilter
6
+ from .. algorithms.utils import bgr_to_hls, hls_to_bgr
7
+ from .. algorithms.corrections import gamma_correction, contrast_correction
8
+
9
+
10
+ class GammaSCurveFilter(BaseFilter):
11
+ def __init__(
12
+ self, name, parent, image_viewer, layer_collection, undo_manager,
13
+ window_title, gamma_label, scurve_label):
14
+ super().__init__(name, parent, image_viewer, layer_collection, undo_manager,
15
+ preview_at_startup=True)
16
+ self.window_title = window_title
17
+ self.gamma_label = gamma_label
18
+ self.scurve_label = scurve_label
19
+ self.min_gamma = -1
20
+ self.max_gamma = +1
21
+ self.initial_gamma = 0
22
+ self.min_scurve = -1
23
+ self.max_scurve = 1
24
+ self.initial_scurve = 0
25
+ self.lumi_slider = None
26
+ self.contrast_slider = None
27
+
28
+ @abstractmethod
29
+ def apply(self, image, *params):
30
+ pass
31
+
32
+ def setup_ui(self, dlg, layout, do_preview, restore_original, **kwargs):
33
+ dlg.setWindowTitle(self.window_title)
34
+ dlg.setMinimumWidth(600)
35
+ params = {
36
+ self.gamma_label: (self.min_gamma, self.max_gamma, self.initial_gamma, "{:.1%}"),
37
+ self.scurve_label: (self.min_scurve, self.max_scurve, self.initial_scurve, "{:.1%}"),
38
+ }
39
+
40
+ def set_slider(name, slider):
41
+ if name == self.gamma_label:
42
+ self.lumi_slider = slider
43
+ elif name == self.scurve_label:
44
+ self.contrast_slider = slider
45
+
46
+ value_labels = self.create_sliders(params, dlg, layout, set_slider)
47
+
48
+ def update_value(name, slider_value, min_val, max_val, fmt):
49
+ value = self.value_from_slider(slider_value, min_val, max_val)
50
+ value_labels[name].setText(fmt.format(value))
51
+ if self.preview_check.isChecked():
52
+ self.preview_timer.start()
53
+
54
+ self.lumi_slider.valueChanged.connect(
55
+ lambda v: update_value(
56
+ self.gamma_label, v, self.min_gamma,
57
+ self.max_gamma, params[self.gamma_label][3]))
58
+ self.contrast_slider.valueChanged.connect(
59
+ lambda v: update_value(
60
+ self.scurve_label, v, self.min_scurve,
61
+ self.max_scurve, params[self.scurve_label][3]))
62
+ self.set_timer(do_preview, restore_original, dlg)
63
+
64
+ def get_params(self):
65
+ return (
66
+ self.value_from_slider(
67
+ self.lumi_slider.value(), self.min_gamma, self.max_gamma),
68
+ self.value_from_slider(
69
+ self.contrast_slider.value(), self.min_scurve, self.max_scurve)
70
+ )
71
+
72
+
73
+ class LumiContrastFilter(GammaSCurveFilter):
74
+ def __init__(self, name, parent, image_viewer, layer_collection, undo_manager):
75
+ super().__init__(
76
+ name, parent, image_viewer, layer_collection, undo_manager,
77
+ "Luminosity, Contrast", "Luminosity", "Constrat")
78
+
79
+ def apply(self, image, luminosity, contrast):
80
+ img_corr = contrast_correction(image, 0.5 * contrast)
81
+ img_corr = gamma_correction(img_corr, math.exp(0.5 * luminosity))
82
+ return img_corr
83
+
84
+
85
+ class SaturationVibranceFilter(GammaSCurveFilter):
86
+ def __init__(self, name, parent, image_viewer, layer_collection, undo_manager):
87
+ super().__init__(
88
+ name, parent, image_viewer, layer_collection, undo_manager,
89
+ "Saturation, Vibrance", "Saturation", "Vibrance")
90
+
91
+ def apply(self, image, stauration, vibrance):
92
+ img_corr = bgr_to_hls(image)
93
+ h, l, s = cv2.split(img_corr)
94
+ s_corr = contrast_correction(s, - vibrance)
95
+ s_corr = gamma_correction(s_corr, math.exp(0.5 * stauration))
96
+ img_corr = cv2.merge([h, l, s_corr])
97
+ img_corr = hls_to_bgr(img_corr)
98
+ return img_corr
@@ -3,6 +3,7 @@ import traceback
3
3
  from abc import abstractmethod
4
4
  import numpy as np
5
5
  from PySide6.QtCore import Qt, QThread, QTimer, QObject, Signal
6
+ from PySide6.QtGui import QFontMetrics
6
7
  from PySide6.QtWidgets import (
7
8
  QHBoxLayout, QLabel, QSlider, QDialog, QVBoxLayout, QCheckBox, QDialogButtonBox)
8
9
  from .layer_collection import LayerCollectionHandler
@@ -27,6 +28,7 @@ class BaseFilter(QObject, LayerCollectionHandler):
27
28
  self.preview_check = None
28
29
  self.button_box = None
29
30
  self.preview_timer = None
31
+ self.max_range = 500
30
32
 
31
33
  @abstractmethod
32
34
  def setup_ui(self, dlg, layout, do_preview, restore_original, **kwargs):
@@ -40,6 +42,47 @@ class BaseFilter(QObject, LayerCollectionHandler):
40
42
  def apply(self, image, *params):
41
43
  pass
42
44
 
45
+ def slider_from_value(self, value, min_val, max_val):
46
+ return (value - min_val) / (max_val - min_val) * self.max_range
47
+
48
+ def value_from_slider(self, slider_value, min_val, max_val):
49
+ return min_val + (max_val - min_val) * float(slider_value) / self.max_range
50
+
51
+ def create_sliders(self, params, dlg, layout, set_slider):
52
+ value_labels = {}
53
+ font_metrics = QFontMetrics(dlg.font())
54
+ max_name_width = 0
55
+ max_value_width = 0
56
+ for name, (min_val, max_val, init_val, fmt) in params.items():
57
+ name_width = font_metrics.horizontalAdvance(f"{name}:")
58
+ max_name_width = max(max_name_width, name_width)
59
+ sample_values = [min_val, max_val, init_val]
60
+ for val in sample_values:
61
+ value_width = font_metrics.horizontalAdvance(fmt.format(val))
62
+ max_value_width = max(max_value_width, value_width)
63
+ max_name_width += 10
64
+ max_value_width += 10
65
+ for name, (min_val, max_val, init_val, fmt) in params.items():
66
+ param_layout = QHBoxLayout()
67
+ name_label = QLabel(f"{name}:")
68
+ name_label.setFixedWidth(max_name_width)
69
+ name_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
70
+ param_layout.addWidget(name_label)
71
+ slider = QSlider(Qt.Horizontal)
72
+ slider.setRange(0, self.max_range)
73
+ slider.setValue(self.slider_from_value(init_val, min_val, max_val))
74
+ param_layout.addWidget(slider, 1)
75
+ value_label = QLabel(fmt.format(init_val))
76
+ value_label.setFixedWidth(max_value_width)
77
+ value_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
78
+ param_layout.addWidget(value_label)
79
+ layout.addLayout(param_layout)
80
+ set_slider(name, slider)
81
+ value_labels[name] = value_label
82
+ self.create_base_widgets(
83
+ layout, QDialogButtonBox.Ok | QDialogButtonBox.Cancel, 200, dlg)
84
+ return value_labels
85
+
43
86
  def connect_signals(self, update_master_thumbnail, mark_as_modified, filter_gui_set_enabled):
44
87
  self.update_master_thumbnail_requested.connect(update_master_thumbnail)
45
88
  self.mark_as_modified_requested.connect(mark_as_modified)
@@ -191,6 +234,13 @@ class BaseFilter(QObject, LayerCollectionHandler):
191
234
  self.preview_timer.setSingleShot(True)
192
235
  self.preview_timer.setInterval(preview_latency)
193
236
 
237
+ def set_timer(self, do_preview, restore_original, dlg):
238
+ self.preview_timer.timeout.connect(do_preview)
239
+ self.connect_preview_toggle(self.preview_check, do_preview, restore_original)
240
+ self.button_box.accepted.connect(dlg.accept)
241
+ self.button_box.rejected.connect(dlg.reject)
242
+ QTimer.singleShot(0, do_preview)
243
+
194
244
  class PreviewWorker(QThread):
195
245
  finished = Signal(np.ndarray, int, tuple)
196
246
 
@@ -222,14 +272,14 @@ class BaseFilter(QObject, LayerCollectionHandler):
222
272
 
223
273
  class OneSliderBaseFilter(BaseFilter):
224
274
  def __init__(self, name, parent, image_viewer, layer_collection, undo_manager,
225
- max_value, initial_value, title,
275
+ min_value, max_value, initial_value, title,
226
276
  allow_partial_preview=True, partial_preview_threshold=0.5,
227
277
  preview_at_startup=True):
228
278
  super().__init__(name, parent, image_viewer, layer_collection, undo_manager,
229
279
  allow_partial_preview,
230
280
  partial_preview_threshold, preview_at_startup)
231
- self.max_range = 500
232
281
  self.max_value = max_value
282
+ self.min_value = min_value
233
283
  self.initial_value = initial_value
234
284
  self.slider = None
235
285
  self.value_label = None
@@ -239,6 +289,13 @@ class OneSliderBaseFilter(BaseFilter):
239
289
  def add_widgets(self, layout, dlg):
240
290
  pass
241
291
 
292
+ def slider_from_value_1(self, value):
293
+ return int((value - self.min_value) / (self.max_value - self.min_value) * self.max_range)
294
+
295
+ def value_from_slider_1(self, slider_value):
296
+ return self.min_value + \
297
+ (self.max_value - self.min_value) * float(slider_value) / self.max_range
298
+
242
299
  def setup_ui(self, dlg, layout, do_preview, restore_original, **kwargs):
243
300
  dlg.setWindowTitle(self.title)
244
301
  dlg.setMinimumWidth(600)
@@ -246,7 +303,7 @@ class OneSliderBaseFilter(BaseFilter):
246
303
  slider_layout.addWidget(QLabel("Amount:"))
247
304
  slider_local = QSlider(Qt.Horizontal)
248
305
  slider_local.setRange(0, self.max_range)
249
- slider_local.setValue(int(self.initial_value / self.max_value * self.max_range))
306
+ slider_local.setValue(self.slider_from_value_1(self.initial_value))
250
307
  slider_layout.addWidget(slider_local)
251
308
  self.value_label = QLabel(self.format.format(self.initial_value))
252
309
  slider_layout.addWidget(self.value_label)
@@ -254,9 +311,7 @@ class OneSliderBaseFilter(BaseFilter):
254
311
  self.add_widgets(layout, dlg)
255
312
  self.create_base_widgets(
256
313
  layout, QDialogButtonBox.Ok | QDialogButtonBox.Cancel, 200, dlg)
257
-
258
314
  self.preview_timer.timeout.connect(do_preview)
259
-
260
315
  slider_local.valueChanged.connect(self.config_changed)
261
316
  self.connect_preview_toggle(
262
317
  self.preview_check, self.do_preview_delayed, restore_original)
@@ -269,7 +324,7 @@ class OneSliderBaseFilter(BaseFilter):
269
324
  self.do_preview_delayed()
270
325
 
271
326
  def config_changed(self, val):
272
- float_val = self.max_value * float(val) / self.max_range
327
+ float_val = self.value_from_slider_1(val)
273
328
  self.value_label.setText(self.format.format(float_val))
274
329
  self.param_changed(val)
275
330
 
@@ -277,7 +332,7 @@ class OneSliderBaseFilter(BaseFilter):
277
332
  self.preview_timer.start()
278
333
 
279
334
  def get_params(self):
280
- return (self.max_value * self.slider.value() / self.max_range,)
335
+ return (self.value_from_slider_1(self.slider.value()),)
281
336
 
282
337
  def apply(self, image, *params):
283
338
  assert False