shinestacker 0.3.1__py3-none-any.whl → 0.3.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of shinestacker might be problematic. Click here for more details.

Files changed (39) hide show
  1. shinestacker/__init__.py +6 -6
  2. shinestacker/_version.py +1 -1
  3. shinestacker/algorithms/balance.py +6 -7
  4. shinestacker/algorithms/noise_detection.py +2 -0
  5. shinestacker/app/open_frames.py +6 -4
  6. shinestacker/config/__init__.py +2 -1
  7. shinestacker/config/config.py +1 -0
  8. shinestacker/config/constants.py +1 -0
  9. shinestacker/config/gui_constants.py +1 -0
  10. shinestacker/core/__init__.py +4 -3
  11. shinestacker/core/colors.py +1 -0
  12. shinestacker/core/core_utils.py +6 -6
  13. shinestacker/core/exceptions.py +1 -0
  14. shinestacker/core/framework.py +2 -1
  15. shinestacker/gui/action_config.py +47 -42
  16. shinestacker/gui/actions_window.py +8 -5
  17. shinestacker/retouch/brush_preview.py +5 -6
  18. shinestacker/retouch/brush_tool.py +164 -0
  19. shinestacker/retouch/denoise_filter.py +56 -0
  20. shinestacker/retouch/display_manager.py +177 -0
  21. shinestacker/retouch/exif_data.py +2 -1
  22. shinestacker/retouch/filter_base.py +114 -0
  23. shinestacker/retouch/filter_manager.py +14 -0
  24. shinestacker/retouch/image_editor.py +104 -430
  25. shinestacker/retouch/image_editor_ui.py +32 -72
  26. shinestacker/retouch/image_filters.py +25 -349
  27. shinestacker/retouch/image_viewer.py +22 -14
  28. shinestacker/retouch/io_gui_handler.py +208 -0
  29. shinestacker/retouch/io_manager.py +9 -13
  30. shinestacker/retouch/layer_collection.py +65 -1
  31. shinestacker/retouch/unsharp_mask_filter.py +84 -0
  32. shinestacker/retouch/white_balance_filter.py +111 -0
  33. {shinestacker-0.3.1.dist-info → shinestacker-0.3.3.dist-info}/METADATA +3 -2
  34. {shinestacker-0.3.1.dist-info → shinestacker-0.3.3.dist-info}/RECORD +38 -31
  35. shinestacker/retouch/brush_controller.py +0 -57
  36. {shinestacker-0.3.1.dist-info → shinestacker-0.3.3.dist-info}/WHEEL +0 -0
  37. {shinestacker-0.3.1.dist-info → shinestacker-0.3.3.dist-info}/entry_points.txt +0 -0
  38. {shinestacker-0.3.1.dist-info → shinestacker-0.3.3.dist-info}/licenses/LICENSE +0 -0
  39. {shinestacker-0.3.1.dist-info → shinestacker-0.3.3.dist-info}/top_level.txt +0 -0
shinestacker/__init__.py CHANGED
@@ -3,14 +3,14 @@ from ._version import __version__
3
3
  from . import config
4
4
  from . import core
5
5
  from . import algorithms
6
- from .config import __all__
7
- from .core import __all__
8
- from .algorithms import __all__
6
+ from .config import __all__ as config_all
7
+ from .core import __all__ as core_all
8
+ from .algorithms import __all__ as algorithms_all
9
9
  from .config import *
10
10
  from .core import *
11
11
  from .algorithms import *
12
12
 
13
13
  __all__ = ['__version__']
14
- #__all__ += config.__all__
15
- #__all__ += core.__all__
16
- #__all__ += algorithms.__all__
14
+ __all__ += config_all
15
+ __all__ += core_all
16
+ __all__ += algorithms_all
shinestacker/_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = '0.3.1'
1
+ __version__ = '0.3.3'
@@ -133,17 +133,16 @@ class Correction:
133
133
  self.corrections = np.ones((size, self.channels))
134
134
 
135
135
  def calc_hist_1ch(self, image):
136
+ img_subsample = image if self.subsample == 1 else image[::self.subsample, ::self.subsample]
136
137
  if self.mask_size == 0:
137
- image_sel = image
138
+ image_sel = img_subsample
138
139
  else:
139
- height, width = image.shape[:2]
140
+ height, width = img_subsample.shape[:2]
140
141
  xv, yv = np.meshgrid(np.linspace(0, width - 1, width), np.linspace(0, height - 1, height))
141
142
  mask_radius = (min(width, height) * self.mask_size / 2)
142
- image_sel = image[(xv - width / 2) ** 2 + (yv - height / 2) ** 2 <= mask_radius ** 2]
143
- hist, bins = np.histogram((image_sel if self.subsample == 1
144
- else image_sel[::self.subsample, ::self.subsample]),
145
- bins=np.linspace(-0.5, self.num_pixel_values - 0.5,
146
- self.num_pixel_values + 1))
143
+ image_sel = img_subsample[(xv - width / 2) ** 2 + (yv - height / 2) ** 2 <= mask_radius ** 2]
144
+ hist, bins = np.histogram(image_sel, bins=np.linspace(-0.5, self.num_pixel_values - 0.5,
145
+ self.num_pixel_values + 1))
147
146
  return hist
148
147
 
149
148
  def balance(self, image, idx):
@@ -86,6 +86,8 @@ class NoiseDetection(FrameMultiDirectory, JobBase):
86
86
  progress_callback=progress_callback)
87
87
  if not config.DISABLE_TQDM:
88
88
  self.bar.close()
89
+ if mean_img is None:
90
+ raise RuntimeError("Mean image is None")
89
91
  blurred = cv2.GaussianBlur(mean_img, (self.blur_size, self.blur_size), 0)
90
92
  diff = cv2.absdiff(mean_img, blurred)
91
93
  channels = cv2.split(diff)
@@ -5,15 +5,17 @@ from PySide6.QtCore import QTimer
5
5
 
6
6
  def open_files(editor, filenames):
7
7
  if len(filenames) == 1:
8
- QTimer.singleShot(100, lambda: editor.open_file(filenames[0]))
8
+ QTimer.singleShot(100, lambda: editor.io_gui_handler.open_file(filenames[0]))
9
9
  else:
10
10
  def check_thread():
11
- if editor.loader_thread is None or editor.loader_thread.isRunning():
11
+ thread = editor.io_gui_handler.loader_thread
12
+ if thread is None or thread.isRunning():
12
13
  QTimer.singleShot(100, check_thread)
13
14
  else:
14
- editor.import_frames_from_files(filenames[1:])
15
+ editor.io_gui_handler.import_frames_from_files(filenames[1:])
16
+
15
17
  QTimer.singleShot(100, lambda: (
16
- editor.open_file(filenames[0]),
18
+ editor.io_gui_handler.open_file(filenames[0]),
17
19
  QTimer.singleShot(100, check_thread)
18
20
  ))
19
21
 
@@ -1,6 +1,7 @@
1
1
  # flake8: noqa F401
2
+ # pylint: disable=C0114
2
3
  from .config import config
3
4
  from .constants import constants
4
5
  from .gui_constants import gui_constants
5
6
 
6
- __all__ = ['config', 'constants', 'gui_constants']
7
+ __all__ = ['config', 'constants', 'gui_constants']
@@ -1,3 +1,4 @@
1
+ # pylint: disable=C0114, C0115, C0116, C0103, R0903, W0718, W0104, W0201, E0602
1
2
  class _Config:
2
3
  _initialized = False
3
4
  _instance = None
@@ -1,3 +1,4 @@
1
+ # pylint: disable=C0114, C0115, C0116, C0103, R0903
1
2
  import sys
2
3
  import re
3
4
 
@@ -1,3 +1,4 @@
1
+ # pylint: disable=C0114, C0115, C0116, C0103, R0903
1
2
  import math
2
3
 
3
4
 
@@ -1,11 +1,12 @@
1
1
  # flake8: noqa F401
2
+ # pylint: disable=C0114
2
3
  from .logging import setup_logging
3
- from .exceptions import (FocusStackError, InvalidOptionError, ImageLoadError, ImageSaveError, AlignmentError,
4
- BitDepthError, ShapeError, RunStopException)
4
+ from .exceptions import (FocusStackError, InvalidOptionError, ImageLoadError, ImageSaveError,
5
+ AlignmentError, BitDepthError, ShapeError, RunStopException)
5
6
  from .framework import Job
6
7
 
7
8
  __all__ = [
8
9
  'setup_logging',
9
10
  'FocusStackError', 'InvalidOptionError', 'ImageLoadError', 'ImageSaveError',
10
11
  'AlignmentError','BitDepthError', 'ShapeError', 'RunStopException',
11
- 'Job']
12
+ 'Job']
@@ -1,3 +1,4 @@
1
+ # pylint: disable=C0114, C0116, C0201
1
2
  COLORS = {
2
3
  "black": 30,
3
4
  "red": 31,
@@ -1,3 +1,4 @@
1
+ # pylint: disable=C0114, C0116, C0201
1
2
  import os
2
3
  import sys
3
4
  import platform
@@ -10,18 +11,17 @@ if not config.DISABLE_TQDM:
10
11
 
11
12
  def check_path_exists(path):
12
13
  if not os.path.exists(path):
13
- raise Exception('Path does not exist: ' + path)
14
+ raise RuntimeError('Path does not exist: ' + path)
14
15
 
15
16
 
16
17
  def make_tqdm_bar(name, size, ncols=80):
17
18
  if not config.DISABLE_TQDM:
18
19
  if config.JUPYTER_NOTEBOOK:
19
- bar = tqdm_notebook(desc=name, total=size)
20
+ tbar = tqdm_notebook(desc=name, total=size)
20
21
  else:
21
- bar = tqdm(desc=name, total=size, ncols=ncols)
22
- return bar
23
- else:
24
- return None
22
+ tbar = tqdm(desc=name, total=size, ncols=ncols)
23
+ return tbar
24
+ return None
25
25
 
26
26
 
27
27
  def get_app_base_path():
@@ -1,3 +1,4 @@
1
+ # pylint: disable=C0114, C0115, C0301
1
2
  class FocusStackError(Exception):
2
3
  pass
3
4
 
@@ -1,3 +1,4 @@
1
+ # pylint: disable=C0114, C0115, C0116
1
2
  import time
2
3
  import logging
3
4
  from .. config.config import config
@@ -57,7 +58,7 @@ def elapsed_time_str(start):
57
58
  ss = dt - mm * 60
58
59
  hh = mm // 60
59
60
  mm -= hh * 60
60
- return ("{:02d}:{:02d}:{:05.2f}s".format(hh, mm, ss))
61
+ return "{:02d}:{:02d}:{:05.2f}s".format(hh, mm, ss)
61
62
 
62
63
 
63
64
  class JobBase:
@@ -22,8 +22,9 @@ FIELD_TYPES = [FIELD_TEXT, FIELD_ABS_PATH, FIELD_REL_PATH, FIELD_FLOAT,
22
22
 
23
23
 
24
24
  class ActionConfigurator(ABC):
25
- def __init__(self, expert=False):
25
+ def __init__(self, expert, current_wd):
26
26
  self.expert = expert
27
+ self.current_wd = current_wd
27
28
 
28
29
  @abstractmethod
29
30
  def create_form(self, layout: QFormLayout, params: Dict[str, Any]):
@@ -35,9 +36,10 @@ class ActionConfigurator(ABC):
35
36
 
36
37
 
37
38
  class FieldBuilder:
38
- def __init__(self, layout, action):
39
+ def __init__(self, layout, action, current_wd):
39
40
  self.layout = layout
40
41
  self.action = action
42
+ self.current_wd = current_wd
41
43
  self.fields = {}
42
44
 
43
45
  def add_field(self, tag: str, field_type: str, label: str,
@@ -159,6 +161,9 @@ class FieldBuilder:
159
161
  if field['type'] == FIELD_REL_PATH and 'working_path' in params:
160
162
  try:
161
163
  working_path = self.get_working_path()
164
+ working_path_abs = os.path.isabs(working_path)
165
+ if not working_path_abs:
166
+ working_path = os.path.join(self.current_wd, working_path)
162
167
  abs_path = os.path.normpath(os.path.join(working_path, params[tag]))
163
168
  if not abs_path.startswith(os.path.normpath(working_path)):
164
169
  QMessageBox.warning(None, "Invalid Path",
@@ -370,8 +375,9 @@ class FieldBuilder:
370
375
 
371
376
 
372
377
  class ActionConfigDialog(QDialog):
373
- def __init__(self, action: ActionConfig, parent=None):
378
+ def __init__(self, action: ActionConfig, current_wd, parent=None):
374
379
  super().__init__(parent)
380
+ self.current_wd = current_wd
375
381
  self.action = action
376
382
  self.setWindowTitle(f"Configure {action.type_name}")
377
383
  self.resize(500, self.height())
@@ -397,18 +403,18 @@ class ActionConfigDialog(QDialog):
397
403
 
398
404
  def get_configurator(self, action_type: str) -> ActionConfigurator:
399
405
  configurators = {
400
- constants.ACTION_JOB: JobConfigurator(self.expert()),
401
- constants.ACTION_COMBO: CombinedActionsConfigurator(self.expert()),
402
- constants.ACTION_NOISEDETECTION: NoiseDetectionConfigurator(self.expert()),
403
- constants.ACTION_FOCUSSTACK: FocusStackConfigurator(self.expert()),
404
- constants.ACTION_FOCUSSTACKBUNCH: FocusStackBunchConfigurator(self.expert()),
405
- constants.ACTION_MULTILAYER: MultiLayerConfigurator(self.expert()),
406
- constants.ACTION_MASKNOISE: MaskNoiseConfigurator(self.expert()),
407
- constants.ACTION_VIGNETTING: VignettingConfigurator(self.expert()),
408
- constants.ACTION_ALIGNFRAMES: AlignFramesConfigurator(self.expert()),
409
- constants.ACTION_BALANCEFRAMES: BalanceFramesConfigurator(self.expert()),
406
+ constants.ACTION_JOB: JobConfigurator(self.expert(), self.current_wd),
407
+ constants.ACTION_COMBO: CombinedActionsConfigurator(self.expert(), self.current_wd),
408
+ constants.ACTION_NOISEDETECTION: NoiseDetectionConfigurator(self.expert(), self.current_wd),
409
+ constants.ACTION_FOCUSSTACK: FocusStackConfigurator(self.expert(), self.current_wd),
410
+ constants.ACTION_FOCUSSTACKBUNCH: FocusStackBunchConfigurator(self.expert(), self.current_wd),
411
+ constants.ACTION_MULTILAYER: MultiLayerConfigurator(self.expert(), self.current_wd),
412
+ constants.ACTION_MASKNOISE: MaskNoiseConfigurator(self.expert(), self.current_wd),
413
+ constants.ACTION_VIGNETTING: VignettingConfigurator(self.expert(), self.current_wd),
414
+ constants.ACTION_ALIGNFRAMES: AlignFramesConfigurator(self.expert(), self.current_wd),
415
+ constants.ACTION_BALANCEFRAMES: BalanceFramesConfigurator(self.expert(), self.current_wd),
410
416
  }
411
- return configurators.get(action_type, DefaultActionConfigurator(self.expert()))
417
+ return configurators.get(action_type, DefaultActionConfigurator(self.expert(), self.current_wd))
412
418
 
413
419
  def accept(self):
414
420
  self.parent()._project_buffer.append(self.parent().project.clone())
@@ -428,8 +434,8 @@ class ActionConfigDialog(QDialog):
428
434
 
429
435
 
430
436
  class NoNameActionConfigurator(ActionConfigurator):
431
- def __init__(self, expert=False):
432
- super().__init__(expert)
437
+ def __init__(self, expert, current_wd):
438
+ super().__init__(expert, current_wd)
433
439
 
434
440
  def get_builder(self):
435
441
  return self.builder
@@ -444,17 +450,17 @@ class NoNameActionConfigurator(ActionConfigurator):
444
450
 
445
451
 
446
452
  class DefaultActionConfigurator(NoNameActionConfigurator):
447
- def __init__(self, expert=False):
448
- super().__init__(expert)
453
+ def __init__(self, expert, current_wd):
454
+ super().__init__(expert, current_wd)
449
455
 
450
456
  def create_form(self, layout, action, tag='Action'):
451
- self.builder = FieldBuilder(layout, action)
457
+ self.builder = FieldBuilder(layout, action, self.current_wd)
452
458
  self.builder.add_field('name', FIELD_TEXT, f'{tag} name', required=True)
453
459
 
454
460
 
455
461
  class JobConfigurator(DefaultActionConfigurator):
456
- def __init__(self, expert=False):
457
- super().__init__(expert)
462
+ def __init__(self, expert, current_wd):
463
+ super().__init__(expert, current_wd)
458
464
 
459
465
  def create_form(self, layout, action):
460
466
  super().create_form(layout, action, "Job")
@@ -464,8 +470,8 @@ class JobConfigurator(DefaultActionConfigurator):
464
470
 
465
471
 
466
472
  class NoiseDetectionConfigurator(DefaultActionConfigurator):
467
- def __init__(self, expert=False):
468
- super().__init__(expert)
473
+ def __init__(self, expert, current_wd):
474
+ super().__init__(expert, current_wd)
469
475
 
470
476
  def create_form(self, layout, action):
471
477
  super().create_form(layout, action)
@@ -492,8 +498,8 @@ class NoiseDetectionConfigurator(DefaultActionConfigurator):
492
498
 
493
499
 
494
500
  class FocusStackBaseConfigurator(DefaultActionConfigurator):
495
- def __init__(self, expert=False):
496
- super().__init__(expert)
501
+ def __init__(self, expert, current_wd):
502
+ super().__init__(expert, current_wd)
497
503
 
498
504
  ENERGY_OPTIONS = ['Laplacian', 'Sobel']
499
505
  MAP_TYPE_OPTIONS = ['Average', 'Maximum']
@@ -583,8 +589,8 @@ class FocusStackBaseConfigurator(DefaultActionConfigurator):
583
589
 
584
590
 
585
591
  class FocusStackConfigurator(FocusStackBaseConfigurator):
586
- def __init__(self, expert=False):
587
- super().__init__(expert)
592
+ def __init__(self, expert, current_wd):
593
+ super().__init__(expert, current_wd)
588
594
 
589
595
  def create_form(self, layout, action):
590
596
  super().create_form(layout, action)
@@ -599,8 +605,8 @@ class FocusStackConfigurator(FocusStackBaseConfigurator):
599
605
 
600
606
 
601
607
  class FocusStackBunchConfigurator(FocusStackBaseConfigurator):
602
- def __init__(self, expert=False):
603
- super().__init__(expert)
608
+ def __init__(self, expert, current_wd):
609
+ super().__init__(expert, current_wd)
604
610
 
605
611
  def create_form(self, layout, action):
606
612
  super().create_form(layout, action)
@@ -614,8 +620,8 @@ class FocusStackBunchConfigurator(FocusStackBaseConfigurator):
614
620
 
615
621
 
616
622
  class MultiLayerConfigurator(DefaultActionConfigurator):
617
- def __init__(self, expert=False):
618
- super().__init__(expert)
623
+ def __init__(self, expert, current_wd):
624
+ super().__init__(expert, current_wd)
619
625
 
620
626
  def create_form(self, layout, action):
621
627
  super().create_form(layout, action)
@@ -635,8 +641,8 @@ class MultiLayerConfigurator(DefaultActionConfigurator):
635
641
 
636
642
 
637
643
  class CombinedActionsConfigurator(DefaultActionConfigurator):
638
- def __init__(self, expert=False):
639
- super().__init__(expert)
644
+ def __init__(self, expert, current_wd):
645
+ super().__init__(expert, current_wd)
640
646
 
641
647
  def create_form(self, layout, action):
642
648
  DefaultActionConfigurator.create_form(self, layout, action)
@@ -659,8 +665,8 @@ class CombinedActionsConfigurator(DefaultActionConfigurator):
659
665
 
660
666
 
661
667
  class MaskNoiseConfigurator(NoNameActionConfigurator):
662
- def __init__(self, expert=False):
663
- super().__init__(expert)
668
+ def __init__(self, expert, current_wd):
669
+ super().__init__(expert, current_wd)
664
670
 
665
671
  def create_form(self, layout, action):
666
672
  DefaultActionConfigurator.create_form(self, layout, action)
@@ -675,8 +681,8 @@ class MaskNoiseConfigurator(NoNameActionConfigurator):
675
681
 
676
682
 
677
683
  class VignettingConfigurator(NoNameActionConfigurator):
678
- def __init__(self, expert=False):
679
- super().__init__(expert)
684
+ def __init__(self, expert, current_wd):
685
+ super().__init__(expert, current_wd)
680
686
 
681
687
  def create_form(self, layout, action):
682
688
  DefaultActionConfigurator.create_form(self, layout, action)
@@ -699,8 +705,8 @@ class AlignFramesConfigurator(NoNameActionConfigurator):
699
705
  METHOD_OPTIONS = ['Random Sample Consensus (RANSAC)', 'Least Median (LMEDS)']
700
706
  MATCHING_METHOD_OPTIONS = ['K-nearest neighbors', 'Hamming distance']
701
707
 
702
- def __init__(self, expert=False):
703
- super().__init__(expert)
708
+ def __init__(self, expert, current_wd):
709
+ super().__init__(expert, current_wd)
704
710
 
705
711
  def show_info(self, message, timeout=3000):
706
712
  self.info_label.setText(message)
@@ -713,7 +719,6 @@ class AlignFramesConfigurator(NoNameActionConfigurator):
713
719
  match_method = {k: v for k, v in zip(self.MATCHING_METHOD_OPTIONS,
714
720
  constants.VALID_MATCHING_METHODS)}[self.matching_method_field.currentText()]
715
721
  try:
716
- print(detector, descriptor, match_method)
717
722
  validate_align_config(detector, descriptor, match_method)
718
723
  except Exception as e:
719
724
  self.show_info(str(e))
@@ -855,8 +860,8 @@ class BalanceFramesConfigurator(NoNameActionConfigurator):
855
860
  CORRECTION_MAP_OPTIONS = ['Linear', 'Gamma', 'Match histograms']
856
861
  CHANNEL_OPTIONS = ['Luminosity', 'RGB', 'HSV', 'HLS']
857
862
 
858
- def __init__(self, expert=False):
859
- super().__init__(expert)
863
+ def __init__(self, expert, current_wd):
864
+ super().__init__(expert, current_wd)
860
865
 
861
866
  def create_form(self, layout, action):
862
867
  DefaultActionConfigurator.create_form(self, layout, action)
@@ -126,7 +126,11 @@ class ActionsWindow(ProjectEditor):
126
126
  file_path, _ = QFileDialog.getOpenFileName(self, "Open Project", "", "Project Files (*.fsp);;All Files (*)")
127
127
  if file_path:
128
128
  try:
129
- self._current_file_wd = '' if os.path.isabs(file_path) else os.getcwd()
129
+ self._current_file = file_path
130
+ self._current_file_wd = '' if os.path.isabs(file_path) else os.path.dirname(file_path)
131
+ if not os.path.isabs(self._current_file_wd):
132
+ self._current_file_wd = os.path.abspath(self._current_file_wd)
133
+ self._current_file = os.path.basename(self._current_file)
130
134
  file = open(file_path, 'r')
131
135
  pp = file_path.split('/')
132
136
  if len(pp) > 1:
@@ -137,7 +141,6 @@ class ActionsWindow(ProjectEditor):
137
141
  raise RuntimeError(f"Project from file {file_path} produced a null project.")
138
142
  self.set_project(project)
139
143
  self._modified_project = False
140
- self._current_file = file_path
141
144
  self.update_title()
142
145
  self.refresh_ui(0, -1)
143
146
  if self.job_list.count() > 0:
@@ -223,7 +226,7 @@ class ActionsWindow(ProjectEditor):
223
226
  index = self.job_list.row(item)
224
227
  if 0 <= index < len(self.project.jobs):
225
228
  job = self.project.jobs[index]
226
- dialog = ActionConfigDialog(job, self)
229
+ dialog = ActionConfigDialog(job, self._current_file_wd, self)
227
230
  if dialog.exec() == QDialog.Accepted:
228
231
  current_row = self.job_list.currentRow()
229
232
  if current_row >= 0:
@@ -255,7 +258,7 @@ class ActionsWindow(ProjectEditor):
255
258
  if current_action:
256
259
  if not is_sub_action:
257
260
  self.set_enabled_sub_actions_gui(current_action.type_name == constants.ACTION_COMBO)
258
- dialog = ActionConfigDialog(current_action, self)
261
+ dialog = ActionConfigDialog(current_action, self._current_file_wd, self)
259
262
  if dialog.exec() == QDialog.Accepted:
260
263
  self.on_job_selected(job_index)
261
264
  self.refresh_ui()
@@ -277,7 +280,7 @@ class ActionsWindow(ProjectEditor):
277
280
  self.edit_action(current_action)
278
281
 
279
282
  def edit_action(self, action):
280
- dialog = ActionConfigDialog(action, self)
283
+ dialog = ActionConfigDialog(action, self._current_file_wd, self)
281
284
  if dialog.exec() == QDialog.Accepted:
282
285
  self.on_job_selected(self.job_list.currentRow())
283
286
  self.mark_as_modified()
@@ -43,7 +43,6 @@ class BrushPreviewItem(QGraphicsPixmapItem):
43
43
  def __init__(self):
44
44
  super().__init__()
45
45
  self.layer_collection = None
46
- self.brush = None
47
46
  self.setVisible(False)
48
47
  self.setZValue(500)
49
48
  self.setTransformationMode(Qt.SmoothTransformation)
@@ -72,22 +71,22 @@ class BrushPreviewItem(QGraphicsPixmapItem):
72
71
 
73
72
  def update(self, scene_pos, size):
74
73
  try:
75
- if self.layer_collection.layer_stack is None or size <= 0:
74
+ if self.layer_collection is None or self.number_of_layers() == 0 or size <= 0:
76
75
  self.hide()
77
76
  return
78
77
  radius = size // 2
79
78
  x = int(scene_pos.x() - radius + 0.5)
80
79
  y = int(scene_pos.y() - radius)
81
80
  w = h = size
82
- if not self.layer_collection.valid_current_layer_idx():
81
+ if not self.valid_current_layer_idx():
83
82
  self.hide()
84
83
  return
85
- layer_area = self.get_layer_area(self.layer_collection.current_layer(), x, y, w, h)
86
- master_area = self.get_layer_area(self.layer_collection.master_layer, x, y, w, h)
84
+ layer_area = self.get_layer_area(self.current_layer(), x, y, w, h)
85
+ master_area = self.get_layer_area(self.master_layer(), x, y, w, h)
87
86
  if layer_area is None or master_area is None:
88
87
  self.hide()
89
88
  return
90
- height, width = self.layer_collection.current_layer().shape[:2]
89
+ height, width = self.current_layer().shape[:2]
91
90
  full_mask = create_brush_mask(size=size, hardness_percent=self.brush.hardness,
92
91
  opacity_percent=self.brush.opacity)[:, :, np.newaxis]
93
92
  mask_x_start = max(0, -x) if x < 0 else 0
@@ -0,0 +1,164 @@
1
+ import numpy as np
2
+ from PySide6.QtGui import QPixmap, QPainter, QColor, QPen, QBrush
3
+ from PySide6.QtCore import Qt, QPoint
4
+ from .brush_gradient import create_brush_gradient
5
+ from .. config.gui_constants import gui_constants
6
+ from .. config.constants import constants
7
+ from .brush_preview import create_brush_mask
8
+
9
+
10
+ class BrushTool:
11
+ def __init__(self):
12
+ self.brush = None
13
+ self.brush_preview = None
14
+ self.image_viewer = None
15
+ self.size_slider = None
16
+ self.hardness_slider = None
17
+ self.opacity_slider = None
18
+ self.flow_slider = None
19
+ self._brush_mask_cache = {}
20
+
21
+ def setup_ui(self, brush, brush_preview, image_viewer, size_slider, hardness_slider,
22
+ opacity_slider, flow_slider):
23
+ self.brush = brush
24
+ self.brush_preview = brush_preview
25
+ self.image_viewer = image_viewer
26
+ self.size_slider = size_slider
27
+ self.hardness_slider = hardness_slider
28
+ self.opacity_slider = opacity_slider
29
+ self.flow_slider = flow_slider
30
+ self.size_slider.valueChanged.connect(self.update_brush_size)
31
+ self.hardness_slider.valueChanged.connect(self.update_brush_hardness)
32
+ self.opacity_slider.valueChanged.connect(self.update_brush_opacity)
33
+ self.flow_slider.valueChanged.connect(self.update_brush_flow)
34
+ self.update_brush_size(self.size_slider.value())
35
+ self.update_brush_hardness(self.hardness_slider.value())
36
+ self.update_brush_opacity(self.opacity_slider.value())
37
+ self.update_brush_flow(self.flow_slider.value())
38
+
39
+ def update_brush_size(self, slider_val):
40
+
41
+ def slider_to_brush_size(slider_val):
42
+ normalized = slider_val / gui_constants.BRUSH_SIZE_SLIDER_MAX
43
+ size = gui_constants.BRUSH_SIZES['min'] + \
44
+ gui_constants.BRUSH_SIZES['max'] * (normalized ** gui_constants.BRUSH_GAMMA)
45
+ return max(gui_constants.BRUSH_SIZES['min'], min(gui_constants.BRUSH_SIZES['max'], size))
46
+
47
+ self.brush.size = slider_to_brush_size(slider_val)
48
+ self.update_brush_thumb()
49
+
50
+ def increase_brush_size(self, amount=5):
51
+ val = min(self.size_slider.value() + amount, self.size_slider.maximum())
52
+ self.size_slider.setValue(val)
53
+ self.update_brush_size(val)
54
+
55
+ def decrease_brush_size(self, amount=5):
56
+ val = max(self.size_slider.value() - amount, self.size_slider.minimum())
57
+ self.size_slider.setValue(val)
58
+ self.update_brush_size(val)
59
+
60
+ def increase_brush_hardness(self, amount=2):
61
+ val = min(self.hardness_slider.value() + amount, self.hardness_slider.maximum())
62
+ self.hardness_slider.setValue(val)
63
+ self.update_brush_hardness(val)
64
+
65
+ def decrease_brush_hardness(self, amount=2):
66
+ val = max(self.hardness_slider.value() - amount, self.hardness_slider.minimum())
67
+ self.hardness_slider.setValue(val)
68
+ self.update_brush_hardness(val)
69
+
70
+ def update_brush_hardness(self, hardness):
71
+ self.brush.hardness = hardness
72
+ self.update_brush_thumb()
73
+
74
+ def update_brush_opacity(self, opacity):
75
+ self.brush.opacity = opacity
76
+ self.update_brush_thumb()
77
+
78
+ def update_brush_flow(self, flow):
79
+ self.brush.flow = flow
80
+ self.update_brush_thumb()
81
+
82
+ def update_brush_thumb(self):
83
+ width, height = gui_constants.UI_SIZES['brush_preview']
84
+ pixmap = QPixmap(width, height)
85
+ pixmap.fill(Qt.transparent)
86
+ painter = QPainter(pixmap)
87
+ painter.setRenderHint(QPainter.Antialiasing)
88
+ preview_size = min(self.brush.size, width + 30, height + 30)
89
+ center_x, center_y = width // 2, height // 2
90
+ radius = preview_size // 2
91
+ if self.image_viewer.cursor_style == 'preview':
92
+ gradient = create_brush_gradient(
93
+ center_x, center_y, radius,
94
+ self.brush.hardness,
95
+ inner_color=QColor(*gui_constants.BRUSH_COLORS['inner']),
96
+ outer_color=QColor(*gui_constants.BRUSH_COLORS['gradient_end']),
97
+ opacity=self.brush.opacity
98
+ )
99
+ painter.setBrush(QBrush(gradient))
100
+ painter.setPen(QPen(QColor(*gui_constants.BRUSH_COLORS['outer']), gui_constants.BRUSH_PREVIEW_LINE_WIDTH))
101
+ elif self.image_viewer.cursor_style == 'outline':
102
+ painter.setBrush(Qt.NoBrush)
103
+ painter.setPen(QPen(QColor(*gui_constants.BRUSH_COLORS['outer']), gui_constants.BRUSH_PREVIEW_LINE_WIDTH))
104
+ else:
105
+ painter.setBrush(QBrush(QColor(*gui_constants.BRUSH_COLORS['cursor_inner'])))
106
+ painter.setPen(QPen(QColor(*gui_constants.BRUSH_COLORS['pen']), gui_constants.BRUSH_PREVIEW_LINE_WIDTH))
107
+ painter.drawEllipse(QPoint(center_x, center_y), radius, radius)
108
+ if self.image_viewer.cursor_style == 'preview':
109
+ painter.setPen(QPen(QColor(0, 0, 160)))
110
+ painter.drawText(0, 10, f"Size: {int(self.brush.size)}px")
111
+ painter.drawText(0, 25, f"Hardness: {self.brush.hardness}%")
112
+ painter.drawText(0, 40, f"Opacity: {self.brush.opacity}%")
113
+ painter.drawText(0, 55, f"Flow: {self.brush.flow}%")
114
+ painter.end()
115
+ self.brush_preview.setPixmap(pixmap)
116
+ self.image_viewer.update_brush_cursor()
117
+
118
+ def apply_brush_operation(self, master_layer, source_layer, dest_layer, mask_layer, view_pos, image_viewer):
119
+ if master_layer is None or source_layer is None:
120
+ return False
121
+ if dest_layer is None:
122
+ dest_layer = master_layer
123
+ scene_pos = image_viewer.mapToScene(view_pos)
124
+ x_center = int(round(scene_pos.x()))
125
+ y_center = int(round(scene_pos.y()))
126
+ radius = int(round(self.brush.size // 2))
127
+ h, w = master_layer.shape[:2]
128
+ x_start, x_end = max(0, x_center - radius), min(w, x_center + radius + 1)
129
+ y_start, y_end = max(0, y_center - radius), min(h, y_center + radius + 1)
130
+ if x_start >= x_end or y_start >= y_end:
131
+ return 0, 0, 0, 0
132
+ mask = self.get_brush_mask(radius)
133
+ if mask is None:
134
+ return 0, 0, 0, 0
135
+ master_area = master_layer[y_start:y_end, x_start:x_end]
136
+ source_area = source_layer[y_start:y_end, x_start:x_end]
137
+ dest_area = dest_layer[y_start:y_end, x_start:x_end]
138
+ mask_layer_area = mask_layer[y_start:y_end, x_start:x_end]
139
+ mask_area = mask[y_start - (y_center - radius):y_end - (y_center - radius), x_start - (x_center - radius):x_end - (x_center - radius)]
140
+ mask_layer_area[:] = np.clip(mask_layer_area + mask_area * self.brush.flow / 100.0, 0.0, 1.0) # np.maximum(mask_layer_area, mask_area)
141
+ self.apply_mask(master_area, source_area, mask_layer_area, dest_area)
142
+ return x_start, y_start, x_end, y_end
143
+
144
+ def get_brush_mask(self, radius):
145
+ mask_key = (radius, self.brush.hardness)
146
+ if mask_key not in self._brush_mask_cache.keys():
147
+ full_mask = create_brush_mask(size=radius * 2 + 1, hardness_percent=self.brush.hardness,
148
+ opacity_percent=self.brush.opacity)
149
+ self._brush_mask_cache[mask_key] = full_mask
150
+ return self._brush_mask_cache[mask_key]
151
+
152
+ def apply_mask(self, master_area, source_area, mask_area, dest_area):
153
+ opacity_factor = float(self.brush.opacity) / 100.0
154
+ effective_mask = np.clip(mask_area * opacity_factor, 0, 1)
155
+ dtype = master_area.dtype
156
+ max_px_value = constants.MAX_UINT16 if dtype == np.uint16 else constants.MAX_UINT8
157
+ if master_area.ndim == 3:
158
+ dest_area[:] = np.clip(master_area * (1 - effective_mask[..., np.newaxis]) + source_area * # noqa
159
+ effective_mask[..., np.newaxis], 0, max_px_value).astype(dtype)
160
+ else:
161
+ dest_area[:] = np.clip(master_area * (1 - effective_mask) + source_area * effective_mask, 0, max_px_value).astype(dtype)
162
+
163
+ def clear_cache(self):
164
+ self._brush_mask_cache.clear()