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,57 @@
1
+ import numpy as np
2
+ from .. config.constants import constants
3
+ from .brush_preview import create_brush_mask
4
+
5
+
6
+ class BrushController:
7
+ def __init__(self, brush):
8
+ self._brush_mask_cache = {}
9
+ self.brush = brush
10
+
11
+ def apply_brush_operation(self, master_layer, source_layer, dest_layer, mask_layer, view_pos, image_viewer):
12
+ if master_layer is None or source_layer is None:
13
+ return False
14
+ if dest_layer is None:
15
+ dest_layer = master_layer
16
+ scene_pos = image_viewer.mapToScene(view_pos)
17
+ x_center = int(round(scene_pos.x()))
18
+ y_center = int(round(scene_pos.y()))
19
+ radius = int(round(self.brush.size // 2))
20
+ h, w = master_layer.shape[:2]
21
+ x_start, x_end = max(0, x_center - radius), min(w, x_center + radius + 1)
22
+ y_start, y_end = max(0, y_center - radius), min(h, y_center + radius + 1)
23
+ if x_start >= x_end or y_start >= y_end:
24
+ return 0, 0, 0, 0
25
+ mask = self._get_brush_mask(radius)
26
+ if mask is None:
27
+ return 0, 0, 0, 0
28
+ master_area = master_layer[y_start:y_end, x_start:x_end]
29
+ source_area = source_layer[y_start:y_end, x_start:x_end]
30
+ dest_area = dest_layer[y_start:y_end, x_start:x_end]
31
+ mask_layer_area = mask_layer[y_start:y_end, x_start:x_end]
32
+ mask_area = mask[y_start - (y_center - radius):y_end - (y_center - radius), x_start - (x_center - radius):x_end - (x_center - radius)]
33
+ 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)
34
+ self._apply_mask(master_area, source_area, mask_layer_area, dest_area)
35
+ return x_start, y_start, x_end, y_end
36
+
37
+ def _get_brush_mask(self, radius):
38
+ mask_key = (radius, self.brush.hardness)
39
+ if mask_key not in self._brush_mask_cache.keys():
40
+ full_mask = create_brush_mask(size=radius * 2 + 1, hardness_percent=self.brush.hardness,
41
+ opacity_percent=self.brush.opacity)
42
+ self._brush_mask_cache[mask_key] = full_mask
43
+ return self._brush_mask_cache[mask_key]
44
+
45
+ def _apply_mask(self, master_area, source_area, mask_area, dest_area):
46
+ opacity_factor = float(self.brush.opacity) / 100.0
47
+ effective_mask = np.clip(mask_area * opacity_factor, 0, 1)
48
+ dtype = master_area.dtype
49
+ max_px_value = constants.MAX_UINT16 if dtype == np.uint16 else constants.MAX_UINT8
50
+ if master_area.ndim == 3:
51
+ dest_area[:] = np.clip(master_area * (1 - effective_mask[..., np.newaxis]) + source_area * # noqa
52
+ effective_mask[..., np.newaxis], 0, max_px_value).astype(dtype)
53
+ else:
54
+ dest_area[:] = np.clip(master_area * (1 - effective_mask) + source_area * effective_mask, 0, max_px_value).astype(dtype)
55
+
56
+ def clear_cache(self):
57
+ self._brush_mask_cache.clear()
@@ -0,0 +1,126 @@
1
+ import numpy as np
2
+ from PySide6.QtWidgets import QGraphicsPixmapItem
3
+ from PySide6.QtCore import Qt, QPointF
4
+ from PySide6.QtGui import QPixmap, QPainter, QImage
5
+
6
+
7
+ def brush_profile_lower_limited(r, hardness):
8
+ if hardness >= 1.0:
9
+ result = np.where(r < 1.0, 1.0, 0.0)
10
+ else:
11
+ r_lim = np.where(r < 1.0, r, 1.0)
12
+ k = 1.0 / (1.0 - hardness)
13
+ result = 0.5 * (np.cos(np.pi * np.power(r_lim, k)) + 1.0)
14
+ return result
15
+
16
+
17
+ def brush_profile(r, hardness):
18
+ h = 2.0 * hardness - 1.0
19
+ if h >= 1.0:
20
+ result = np.where(r < 1.0, 1.0, 0.0)
21
+ elif h >= 0:
22
+ k = 1.0 / (1.0 - hardness)
23
+ result = 0.5 * (np.cos(np.pi * np.power(np.where(r < 1.0, r, 1.0), k)) + 1.0)
24
+ elif h < 0:
25
+ k = 1.0 / (1.0 + hardness)
26
+ result = np.where(r < 1.0, 0.5 * (1.0 - np.cos(np.pi * np.power(1.0 - np.where(r < 1.0, r, 1.0), k))), 0.0)
27
+ else:
28
+ result = np.zeros_like(r)
29
+ return result
30
+
31
+
32
+ def create_brush_mask(size, hardness_percent, opacity_percent):
33
+ radius = size / 2.0
34
+ center = (size - 1) / 2.0
35
+ h, o = hardness_percent / 100.0, opacity_percent / 100.0
36
+ y, x = np.ogrid[:size, :size]
37
+ r = np.sqrt((x - center)**2 + (y - center)**2) / radius
38
+ mask = np.clip(brush_profile(r, h), 0.0, 1.0) * o
39
+ return mask
40
+
41
+
42
+ class BrushPreviewItem(QGraphicsPixmapItem):
43
+ def __init__(self):
44
+ super().__init__()
45
+ self.setVisible(False)
46
+ self.setZValue(500)
47
+ self.setTransformationMode(Qt.SmoothTransformation)
48
+
49
+ def get_layer_area(self, layer, x, y, w, h):
50
+ if not isinstance(layer, np.ndarray):
51
+ self.hide()
52
+ return None
53
+ height, width = layer.shape[:2]
54
+ x_start, y_start = max(0, x), max(0, y)
55
+ x_end, y_end = min(width, x + w), min(height, y + h)
56
+ if x_end <= x_start or y_end <= y_start:
57
+ self.hide()
58
+ return None
59
+ area = np.ascontiguousarray(layer[y_start:y_end, x_start:x_end])
60
+ if area.ndim == 2: # grayscale
61
+ area = np.ascontiguousarray(np.stack([area] * 3, axis=-1))
62
+ elif area.shape[2] == 4: # RGBA
63
+ area = np.ascontiguousarray(area[..., :3]) # RGB
64
+ if area.dtype == np.uint8:
65
+ return area.astype(np.float32) / 256.0
66
+ elif area.dtype == np.uint16:
67
+ return area.astype(np.float32) / 65536.0
68
+ else:
69
+ raise Exception("Bitmas is neither 8 bit nor 16, but of type " + area.dtype)
70
+
71
+ def update(self, editor, pos, size):
72
+ try:
73
+ if editor.current_stack is None or not hasattr(editor, 'image_viewer') or size <= 0:
74
+ self.hide()
75
+ return
76
+ radius = size // 2
77
+ if isinstance(pos, QPointF):
78
+ scene_pos = pos
79
+ else:
80
+ cursor_pos = editor.image_viewer.mapFromGlobal(pos)
81
+ scene_pos = editor.image_viewer.mapToScene(cursor_pos)
82
+ x = int(scene_pos.x() - radius + 0.5)
83
+ y = int(scene_pos.y() - radius)
84
+ w = h = size
85
+ if editor.current_layer < 0 or editor.current_layer >= len(editor.current_stack):
86
+ self.hide()
87
+ return
88
+ layer_area = self.get_layer_area(editor.current_stack[editor.current_layer], x, y, w, h)
89
+ master_area = self.get_layer_area(editor.master_layer, x, y, w, h)
90
+ if layer_area is None or master_area is None:
91
+ self.hide()
92
+ return
93
+ height, width = editor.current_stack[editor.current_layer].shape[:2]
94
+ full_mask = create_brush_mask(size=size, hardness_percent=editor.brush.hardness,
95
+ opacity_percent=editor.brush.opacity)[:, :, np.newaxis]
96
+ mask_x_start = max(0, -x) if x < 0 else 0
97
+ mask_y_start = max(0, -y) if y < 0 else 0
98
+ mask_x_end = size - (max(0, (x + w) - width)) if (x + w) > width else size
99
+ mask_y_end = size - (max(0, (y + h) - height)) if (y + h) > height else size
100
+ mask_area = full_mask[mask_y_start:mask_y_end, mask_x_start:mask_x_end]
101
+ area = (layer_area * mask_area + master_area * (1 - mask_area)) * 255.0
102
+ area = area.astype(np.uint8)
103
+ qimage = QImage(area.data, area.shape[1], area.shape[0], area.strides[0], QImage.Format_RGB888)
104
+ mask = QPixmap(w, h)
105
+ mask.fill(Qt.transparent)
106
+ painter = QPainter(mask)
107
+ painter.setPen(Qt.NoPen)
108
+ painter.setBrush(Qt.black)
109
+ painter.drawEllipse(0, 0, w, h)
110
+ painter.end()
111
+ pixmap = QPixmap.fromImage(qimage)
112
+ final_pixmap = QPixmap(w, h)
113
+ final_pixmap.fill(Qt.transparent)
114
+ painter = QPainter(final_pixmap)
115
+ painter.drawPixmap(0, 0, pixmap)
116
+ painter.setCompositionMode(QPainter.CompositionMode_DestinationIn)
117
+ painter.drawPixmap(0, 0, mask)
118
+ painter.end()
119
+ self.setPixmap(final_pixmap)
120
+ x_start, y_start = max(0, x), max(0, y)
121
+ self.setPos(x_start, y_start)
122
+ self.show()
123
+ except Exception:
124
+ import traceback
125
+ traceback.print_exc()
126
+ self.hide()
@@ -0,0 +1,65 @@
1
+ import os
2
+ from PIL.TiffImagePlugin import IFDRational
3
+ from PySide6.QtWidgets import QFormLayout, QHBoxLayout, QPushButton, QDialog, QLabel
4
+ from PySide6.QtGui import QIcon
5
+ from PySide6.QtCore import Qt
6
+ from .. core.core_utils import get_app_base_path
7
+ from .. algorithms.exif import exif_dict
8
+
9
+
10
+ class ExifData(QDialog):
11
+ def __init__(self, exif, parent=None):
12
+ super().__init__(parent)
13
+ self.exif = exif
14
+ self.setWindowTitle("EXIF data")
15
+ self.resize(500, self.height())
16
+ self.layout = QFormLayout(self)
17
+ self.layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
18
+ self.layout.setRowWrapPolicy(QFormLayout.DontWrapRows)
19
+ self.layout.setFormAlignment(Qt.AlignLeft | Qt.AlignTop)
20
+ self.layout.setLabelAlignment(Qt.AlignLeft)
21
+ self.create_form()
22
+ button_box = QHBoxLayout()
23
+ ok_button = QPushButton("OK")
24
+ ok_button.setFocus()
25
+ button_box.addWidget(ok_button)
26
+ self.layout.addRow(button_box)
27
+ ok_button.clicked.connect(self.accept)
28
+
29
+ def add_bold_label(self, label):
30
+ label = QLabel(label)
31
+ label.setStyleSheet("font-weight: bold")
32
+ self.layout.addRow(label)
33
+
34
+ def create_form(self):
35
+ icon_path = f'{get_app_base_path()}'
36
+ if os.path.exists(f'{icon_path}/ico'):
37
+ icon_path = f'{icon_path}/ico'
38
+ else:
39
+ icon_path = f'{icon_path}/../ico'
40
+ icon_path = f'{icon_path}/shinestacker.png'
41
+ app_icon = QIcon(icon_path)
42
+ icon_pixmap = app_icon.pixmap(128, 128)
43
+ icon_label = QLabel()
44
+ icon_label.setPixmap(icon_pixmap)
45
+ icon_label.setAlignment(Qt.AlignCenter)
46
+ self.layout.addRow(icon_label)
47
+ spacer = QLabel("")
48
+ spacer.setFixedHeight(10)
49
+ self.layout.addRow(spacer)
50
+ self.add_bold_label("EXIF data")
51
+ shortcuts = {}
52
+ if self.exif is None:
53
+ shortcuts['Warning:'] = 'no EXIF data found'
54
+ else:
55
+ data = exif_dict(self.exif)
56
+ if len(data) > 0:
57
+ for k, (t, d) in data.items():
58
+ if isinstance(d, IFDRational):
59
+ d = f"{d.numerator}/{d.denominator}"
60
+ else:
61
+ d = f"{d}"
62
+ if "<<<" not in d and k != 'IPTCNAA':
63
+ self.layout.addRow(f"<b>{k}:</b>", QLabel(d))
64
+ else:
65
+ self.layout.addRow("-", QLabel("Empty EXIF dictionary"))
@@ -0,0 +1,104 @@
1
+ import numpy as np
2
+ import traceback
3
+ import cv2
4
+ import os
5
+ from psdtags import PsdChannelId
6
+ from PySide6.QtCore import QThread, Signal
7
+ from .. algorithms.utils import read_img
8
+ from .. algorithms.multilayer import read_multilayer_tiff
9
+
10
+
11
+ class FileLoader(QThread):
12
+ finished = Signal(object, object, object)
13
+ error = Signal(str)
14
+
15
+ def __init__(self, path):
16
+ super().__init__()
17
+ self.path = path
18
+
19
+ def run(self):
20
+ try:
21
+ current_stack, current_labels = self.load_stack(self.path)
22
+ if current_stack is None or len(current_stack) == 0:
23
+ self.error.emit("Empty or invalid stack")
24
+ return
25
+ if current_labels:
26
+ master_indices = [i for i, label in enumerate(current_labels) if label.lower() == "master"]
27
+ else:
28
+ master_indices = []
29
+ master_index = -1 if len(master_indices) == 0 else master_indices[0]
30
+ if master_index == -1:
31
+ master_layer = current_stack[0].copy()
32
+ else:
33
+ current_labels.pop(master_index)
34
+ master_layer = current_stack[master_index].copy()
35
+ indices = list(range(len(current_stack)))
36
+ indices.remove(master_index)
37
+ current_stack = current_stack[indices]
38
+ master_layer.setflags(write=True)
39
+ if current_labels is None:
40
+ current_labels = [f"Layer {i + 1}" for i in range(len(current_stack))]
41
+ self.finished.emit(current_stack, current_labels, master_layer)
42
+ except Exception as e:
43
+ traceback.print_tb(e.__traceback__)
44
+ self.error.emit(f"Error loading file:\n{str(e)}")
45
+
46
+ def load_stack(self, path):
47
+ if not os.path.exists(path):
48
+ raise RuntimeError(f"Path {path} does not exist.")
49
+ if not os.path.isfile(path):
50
+ raise RuntimeError(f"Path {path} is not a file.")
51
+ extension = path.split('.')[-1]
52
+ if extension in ['jpg', 'jpeg']:
53
+ try:
54
+ stack = np.array([cv2.cvtColor(read_img(path), cv2.COLOR_BGR2RGB)])
55
+ return stack, [path.split('/')[-1].split('.')[0]]
56
+ except Exception as e:
57
+ traceback.print_tb(e.__traceback__)
58
+ return None, None
59
+ elif extension in ['tif', 'tiff']:
60
+ try:
61
+ psd_data = read_multilayer_tiff(path)
62
+ layers = []
63
+ labels = []
64
+ for layer in reversed(psd_data.layers.layers):
65
+ channels = {}
66
+ for channel in layer.channels:
67
+ channels[channel.channelid] = channel.data
68
+ if PsdChannelId.CHANNEL0 in channels:
69
+ img = np.stack([
70
+ channels[PsdChannelId.CHANNEL0],
71
+ channels[PsdChannelId.CHANNEL1],
72
+ channels[PsdChannelId.CHANNEL2]
73
+ ], axis=-1)
74
+ layers.append(img)
75
+ labels.append(layer.name)
76
+ if layers:
77
+ stack = np.array(layers)
78
+ if labels:
79
+ master_indices = [i for i, label in enumerate(labels) if label.lower() == "master"]
80
+ if master_indices:
81
+ master_index = master_indices[0]
82
+ master_label = labels.pop(master_index)
83
+ master_layer = stack[master_index]
84
+ stack = np.delete(stack, master_index, axis=0)
85
+ labels.insert(0, master_label)
86
+ stack = np.insert(stack, 0, master_layer, axis=0)
87
+ return stack, labels
88
+ return stack, labels
89
+ except ValueError as e:
90
+ if str(e) == "TIFF file contains no ImageSourceData tag":
91
+ try:
92
+ stack = np.array([cv2.cvtColor(read_img(path), cv2.COLOR_BGR2RGB)])
93
+ return stack, [path.split('/')[-1].split('.')[0]]
94
+ except Exception as e:
95
+ traceback.print_tb(e.__traceback__)
96
+ return None, None
97
+ else:
98
+ traceback.print_tb(e.__traceback__)
99
+ raise e
100
+ except Exception as e:
101
+ traceback.print_tb(e.__traceback__)
102
+ return None, None
103
+ else:
104
+ return None, None