shinestacker 0.2.2__py3-none-any.whl → 0.3.1__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.
- shinestacker/_version.py +1 -1
- shinestacker/algorithms/denoise.py +9 -0
- shinestacker/algorithms/sharpen.py +22 -0
- shinestacker/algorithms/stack.py +2 -2
- shinestacker/algorithms/utils.py +4 -0
- shinestacker/algorithms/white_balance.py +13 -0
- shinestacker/gui/new_project.py +1 -0
- shinestacker/retouch/brush_controller.py +4 -4
- shinestacker/retouch/brush_gradient.py +20 -0
- shinestacker/retouch/brush_preview.py +11 -14
- shinestacker/retouch/image_editor.py +114 -202
- shinestacker/retouch/image_editor_ui.py +42 -13
- shinestacker/retouch/image_filters.py +391 -0
- shinestacker/retouch/image_viewer.py +13 -21
- shinestacker/retouch/io_manager.py +57 -0
- shinestacker/retouch/layer_collection.py +54 -0
- shinestacker/retouch/undo_manager.py +49 -11
- {shinestacker-0.2.2.dist-info → shinestacker-0.3.1.dist-info}/METADATA +27 -3
- {shinestacker-0.2.2.dist-info → shinestacker-0.3.1.dist-info}/RECORD +23 -16
- {shinestacker-0.2.2.dist-info → shinestacker-0.3.1.dist-info}/WHEEL +0 -0
- {shinestacker-0.2.2.dist-info → shinestacker-0.3.1.dist-info}/entry_points.txt +0 -0
- {shinestacker-0.2.2.dist-info → shinestacker-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {shinestacker-0.2.2.dist-info → shinestacker-0.3.1.dist-info}/top_level.txt +0 -0
shinestacker/_version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = '0.
|
|
1
|
+
__version__ = '0.3.1'
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def denoise(image, h_luminance, template_window_size=7, search_window_size=21):
|
|
6
|
+
norm_type = cv2.NORM_L2 if image.dtype == np.uint8 else cv2.NORM_L1
|
|
7
|
+
if image.dtype == np.uint16:
|
|
8
|
+
h_luminance = h_luminance * 256
|
|
9
|
+
return cv2.fastNlMeansDenoising(image, [h_luminance], None, template_window_size, search_window_size, norm_type)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def unsharp_mask(image, radius=1.0, amount=1.0, threshold=0.0):
|
|
6
|
+
if image.dtype == np.uint16:
|
|
7
|
+
threshold = threshold * 256
|
|
8
|
+
blurred = cv2.GaussianBlur(image, (0, 0), radius)
|
|
9
|
+
if threshold == 0:
|
|
10
|
+
sharpened = cv2.addWeighted(image, 1.0 + amount, blurred, -amount, 0)
|
|
11
|
+
else:
|
|
12
|
+
image_float = image.astype(np.float32)
|
|
13
|
+
blurred_float = blurred.astype(np.float32)
|
|
14
|
+
diff = image_float - blurred_float
|
|
15
|
+
mask = np.abs(diff) > threshold
|
|
16
|
+
sharpened_float = np.where(mask, image_float + amount * diff, image_float)
|
|
17
|
+
if np.issubdtype(image.dtype, np.integer):
|
|
18
|
+
min_val, max_val = np.iinfo(image.dtype).min, np.iinfo(image.dtype).max
|
|
19
|
+
sharpened = np.clip(sharpened_float, min_val, max_val).astype(image.dtype)
|
|
20
|
+
else:
|
|
21
|
+
sharpened = sharpened_float.astype(image.dtype)
|
|
22
|
+
return sharpened
|
shinestacker/algorithms/stack.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import numpy as np
|
|
2
|
-
import cv2
|
|
3
2
|
import os
|
|
4
3
|
from .. config.constants import constants
|
|
5
4
|
from .. core.colors import color_str
|
|
@@ -8,6 +7,7 @@ from .. core.exceptions import InvalidOptionError
|
|
|
8
7
|
from .utils import write_img
|
|
9
8
|
from .stack_framework import FrameDirectory, ActionList
|
|
10
9
|
from .exif import copy_exif_from_file_to_file
|
|
10
|
+
from .denoise import denoise
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class FocusStackBase:
|
|
@@ -30,7 +30,7 @@ class FocusStackBase:
|
|
|
30
30
|
out_filename = f"{self.output_dir}/{self.prefix}{in_filename[0]}." + '.'.join(in_filename[1:])
|
|
31
31
|
if self.denoise > 0:
|
|
32
32
|
self.sub_message_r(': denoise image')
|
|
33
|
-
stacked_img =
|
|
33
|
+
stacked_img = denoise(stacked_img, self.denoise, self.denoise)
|
|
34
34
|
write_img(out_filename, stacked_img)
|
|
35
35
|
if self.exif_path != '' and stacked_img.dtype == np.uint8:
|
|
36
36
|
self.sub_message_r(': copy exif data')
|
shinestacker/algorithms/utils.py
CHANGED
|
@@ -47,10 +47,14 @@ def img_bw(img):
|
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
def get_img_metadata(img):
|
|
50
|
+
if img is None:
|
|
51
|
+
return None, None
|
|
50
52
|
return img.shape[:2], img.dtype
|
|
51
53
|
|
|
52
54
|
|
|
53
55
|
def validate_image(img, expected_shape=None, expected_dtype=None):
|
|
56
|
+
if img is None:
|
|
57
|
+
raise RuntimeError("Image is None")
|
|
54
58
|
shape, dtype = get_img_metadata(img)
|
|
55
59
|
if expected_shape and shape[:2] != expected_shape[:2]:
|
|
56
60
|
raise ShapeError(expected_shape, shape)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def white_balance_from_rgb(img, target_rgb):
|
|
5
|
+
img_float = img.astype(np.float64)
|
|
6
|
+
target_bgr = (target_rgb[2], target_rgb[1], target_rgb[0])
|
|
7
|
+
target_gray = sum(target_bgr) / 3.0
|
|
8
|
+
scales = [target_gray / val if val != 0 else 1.0 for val in target_bgr]
|
|
9
|
+
for c in range(3):
|
|
10
|
+
img_float[..., c] *= scales[c]
|
|
11
|
+
max_val = np.iinfo(img.dtype).max
|
|
12
|
+
img_float = np.clip(img_float, 0, max_val)
|
|
13
|
+
return img_float.astype(img.dtype)
|
shinestacker/gui/new_project.py
CHANGED
|
@@ -85,6 +85,7 @@ class NewProjectDialog(QDialog):
|
|
|
85
85
|
bunch_overlap_range = gui_constants.NEW_PROJECT_BUNCH_OVERLAP
|
|
86
86
|
self.bunch_overlap.setRange(bunch_overlap_range['min'], bunch_overlap_range['max'])
|
|
87
87
|
self.bunch_overlap.setValue(constants.DEFAULT_OVERLAP)
|
|
88
|
+
self.update_bunch_options(gui_constants.NEW_PROJECT_BUNCH_STACK)
|
|
88
89
|
self.focus_stack_pyramid = QCheckBox()
|
|
89
90
|
self.focus_stack_pyramid.setChecked(gui_constants.NEW_PROJECT_FOCUS_STACK_PYRAMID)
|
|
90
91
|
self.focus_stack_depth_map = QCheckBox()
|
|
@@ -22,7 +22,7 @@ class BrushController:
|
|
|
22
22
|
y_start, y_end = max(0, y_center - radius), min(h, y_center + radius + 1)
|
|
23
23
|
if x_start >= x_end or y_start >= y_end:
|
|
24
24
|
return 0, 0, 0, 0
|
|
25
|
-
mask = self.
|
|
25
|
+
mask = self.get_brush_mask(radius)
|
|
26
26
|
if mask is None:
|
|
27
27
|
return 0, 0, 0, 0
|
|
28
28
|
master_area = master_layer[y_start:y_end, x_start:x_end]
|
|
@@ -31,10 +31,10 @@ class BrushController:
|
|
|
31
31
|
mask_layer_area = mask_layer[y_start:y_end, x_start:x_end]
|
|
32
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
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.
|
|
34
|
+
self.apply_mask(master_area, source_area, mask_layer_area, dest_area)
|
|
35
35
|
return x_start, y_start, x_end, y_end
|
|
36
36
|
|
|
37
|
-
def
|
|
37
|
+
def get_brush_mask(self, radius):
|
|
38
38
|
mask_key = (radius, self.brush.hardness)
|
|
39
39
|
if mask_key not in self._brush_mask_cache.keys():
|
|
40
40
|
full_mask = create_brush_mask(size=radius * 2 + 1, hardness_percent=self.brush.hardness,
|
|
@@ -42,7 +42,7 @@ class BrushController:
|
|
|
42
42
|
self._brush_mask_cache[mask_key] = full_mask
|
|
43
43
|
return self._brush_mask_cache[mask_key]
|
|
44
44
|
|
|
45
|
-
def
|
|
45
|
+
def apply_mask(self, master_area, source_area, mask_area, dest_area):
|
|
46
46
|
opacity_factor = float(self.brush.opacity) / 100.0
|
|
47
47
|
effective_mask = np.clip(mask_area * opacity_factor, 0, 1)
|
|
48
48
|
dtype = master_area.dtype
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from PySide6.QtGui import QRadialGradient
|
|
2
|
+
from PySide6.QtGui import QColor
|
|
3
|
+
from .. config.gui_constants import gui_constants
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def create_brush_gradient(center_x, center_y, radius, hardness, inner_color=None, outer_color=None, opacity=100):
|
|
7
|
+
gradient = QRadialGradient(center_x, center_y, float(radius))
|
|
8
|
+
inner = inner_color if inner_color is not None else QColor(*gui_constants.BRUSH_COLORS['inner'])
|
|
9
|
+
outer = outer_color if outer_color is not None else QColor(*gui_constants.BRUSH_COLORS['gradient_end'])
|
|
10
|
+
inner_with_opacity = QColor(inner)
|
|
11
|
+
inner_with_opacity.setAlpha(int(float(inner.alpha()) * float(opacity) / 100.0))
|
|
12
|
+
if hardness < 100:
|
|
13
|
+
hardness_normalized = float(hardness) / 100.0
|
|
14
|
+
gradient.setColorAt(0.0, inner_with_opacity)
|
|
15
|
+
gradient.setColorAt(hardness_normalized, inner_with_opacity)
|
|
16
|
+
gradient.setColorAt(1.0, outer)
|
|
17
|
+
else:
|
|
18
|
+
gradient.setColorAt(0.0, inner_with_opacity)
|
|
19
|
+
gradient.setColorAt(1.0, inner_with_opacity)
|
|
20
|
+
return gradient
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import numpy as np
|
|
2
2
|
from PySide6.QtWidgets import QGraphicsPixmapItem
|
|
3
|
-
from PySide6.QtCore import Qt
|
|
3
|
+
from PySide6.QtCore import Qt
|
|
4
4
|
from PySide6.QtGui import QPixmap, QPainter, QImage
|
|
5
5
|
|
|
6
6
|
|
|
@@ -42,6 +42,8 @@ def create_brush_mask(size, hardness_percent, opacity_percent):
|
|
|
42
42
|
class BrushPreviewItem(QGraphicsPixmapItem):
|
|
43
43
|
def __init__(self):
|
|
44
44
|
super().__init__()
|
|
45
|
+
self.layer_collection = None
|
|
46
|
+
self.brush = None
|
|
45
47
|
self.setVisible(False)
|
|
46
48
|
self.setZValue(500)
|
|
47
49
|
self.setTransformationMode(Qt.SmoothTransformation)
|
|
@@ -68,31 +70,26 @@ class BrushPreviewItem(QGraphicsPixmapItem):
|
|
|
68
70
|
else:
|
|
69
71
|
raise Exception("Bitmas is neither 8 bit nor 16, but of type " + area.dtype)
|
|
70
72
|
|
|
71
|
-
def update(self,
|
|
73
|
+
def update(self, scene_pos, size):
|
|
72
74
|
try:
|
|
73
|
-
if
|
|
75
|
+
if self.layer_collection.layer_stack is None or size <= 0:
|
|
74
76
|
self.hide()
|
|
75
77
|
return
|
|
76
78
|
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
79
|
x = int(scene_pos.x() - radius + 0.5)
|
|
83
80
|
y = int(scene_pos.y() - radius)
|
|
84
81
|
w = h = size
|
|
85
|
-
if
|
|
82
|
+
if not self.layer_collection.valid_current_layer_idx():
|
|
86
83
|
self.hide()
|
|
87
84
|
return
|
|
88
|
-
layer_area = self.get_layer_area(
|
|
89
|
-
master_area = self.get_layer_area(
|
|
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)
|
|
90
87
|
if layer_area is None or master_area is None:
|
|
91
88
|
self.hide()
|
|
92
89
|
return
|
|
93
|
-
height, width =
|
|
94
|
-
full_mask = create_brush_mask(size=size, hardness_percent=
|
|
95
|
-
opacity_percent=
|
|
90
|
+
height, width = self.layer_collection.current_layer().shape[:2]
|
|
91
|
+
full_mask = create_brush_mask(size=size, hardness_percent=self.brush.hardness,
|
|
92
|
+
opacity_percent=self.brush.opacity)[:, :, np.newaxis]
|
|
96
93
|
mask_x_start = max(0, -x) if x < 0 else 0
|
|
97
94
|
mask_y_start = max(0, -y) if y < 0 else 0
|
|
98
95
|
mask_x_end = size - (max(0, (x + w) - width)) if (x + w) > width else size
|