setiastrosuitepro 1.6.4__py3-none-any.whl → 1.7.1.post2__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 setiastrosuitepro might be problematic. Click here for more details.
- setiastro/images/TextureClarity.svg +56 -0
- setiastro/images/abeicon.svg +16 -0
- setiastro/images/acv_icon.png +0 -0
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/cosmic.svg +40 -0
- setiastro/images/cosmicsat.svg +24 -0
- setiastro/images/first_quarter.png +0 -0
- setiastro/images/full_moon.png +0 -0
- setiastro/images/graxpert.svg +19 -0
- setiastro/images/last_quarter.png +0 -0
- setiastro/images/linearfit.svg +32 -0
- setiastro/images/narrowbandnormalization.png +0 -0
- setiastro/images/new_moon.png +0 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/images/waning_crescent_1.png +0 -0
- setiastro/images/waning_crescent_2.png +0 -0
- setiastro/images/waning_crescent_3.png +0 -0
- setiastro/images/waning_crescent_4.png +0 -0
- setiastro/images/waning_crescent_5.png +0 -0
- setiastro/images/waning_gibbous_1.png +0 -0
- setiastro/images/waning_gibbous_2.png +0 -0
- setiastro/images/waning_gibbous_3.png +0 -0
- setiastro/images/waning_gibbous_4.png +0 -0
- setiastro/images/waning_gibbous_5.png +0 -0
- setiastro/images/waxing_crescent_1.png +0 -0
- setiastro/images/waxing_crescent_2.png +0 -0
- setiastro/images/waxing_crescent_3.png +0 -0
- setiastro/images/waxing_crescent_4.png +0 -0
- setiastro/images/waxing_crescent_5.png +0 -0
- setiastro/images/waxing_gibbous_1.png +0 -0
- setiastro/images/waxing_gibbous_2.png +0 -0
- setiastro/images/waxing_gibbous_3.png +0 -0
- setiastro/images/waxing_gibbous_4.png +0 -0
- setiastro/images/waxing_gibbous_5.png +0 -0
- setiastro/qml/ResourceMonitor.qml +84 -82
- setiastro/saspro/__main__.py +20 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/abe.py +37 -4
- setiastro/saspro/aberration_ai.py +364 -33
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/acv_exporter.py +379 -0
- setiastro/saspro/add_stars.py +33 -6
- setiastro/saspro/astrospike_python.py +45 -3
- setiastro/saspro/backgroundneutral.py +108 -40
- setiastro/saspro/blemish_blaster.py +4 -1
- setiastro/saspro/blink_comparator_pro.py +150 -55
- setiastro/saspro/clahe.py +4 -1
- setiastro/saspro/continuum_subtract.py +4 -1
- setiastro/saspro/convo.py +13 -7
- setiastro/saspro/cosmicclarity.py +129 -18
- setiastro/saspro/crop_dialog_pro.py +123 -7
- setiastro/saspro/curve_editor_pro.py +181 -64
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +245 -15
- setiastro/saspro/exoplanet_detector.py +120 -28
- setiastro/saspro/frequency_separation.py +1158 -204
- setiastro/saspro/ghs_dialog_pro.py +81 -16
- setiastro/saspro/graxpert.py +1 -0
- setiastro/saspro/gui/main_window.py +706 -264
- setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
- setiastro/saspro/gui/mixins/file_mixin.py +35 -16
- setiastro/saspro/gui/mixins/menu_mixin.py +35 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +499 -24
- setiastro/saspro/gui/mixins/update_mixin.py +138 -36
- setiastro/saspro/gui/mixins/view_mixin.py +42 -0
- setiastro/saspro/halobgon.py +4 -0
- setiastro/saspro/histogram.py +184 -8
- setiastro/saspro/image_combine.py +4 -0
- setiastro/saspro/image_peeker_pro.py +4 -0
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +1345 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +582 -62
- setiastro/saspro/isophote.py +4 -0
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/image_manager.py +154 -20
- setiastro/saspro/legacy/numba_utils.py +68 -48
- setiastro/saspro/legacy/xisf.py +240 -98
- setiastro/saspro/live_stacking.py +203 -82
- setiastro/saspro/luminancerecombine.py +228 -27
- setiastro/saspro/mask_creation.py +174 -15
- setiastro/saspro/mfdeconv.py +113 -35
- setiastro/saspro/mfdeconvcudnn.py +119 -70
- setiastro/saspro/mfdeconvsport.py +112 -35
- setiastro/saspro/morphology.py +4 -0
- setiastro/saspro/multiscale_decomp.py +81 -29
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/numba_utils.py +72 -57
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/ops/script_editor.py +10 -2
- setiastro/saspro/ops/scripts.py +122 -0
- setiastro/saspro/perfect_palette_picker.py +37 -3
- setiastro/saspro/plate_solver.py +84 -49
- setiastro/saspro/psf_viewer.py +119 -37
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +73 -0
- setiastro/saspro/rgbalign.py +460 -12
- setiastro/saspro/selective_color.py +4 -1
- setiastro/saspro/ser_stack_config.py +82 -0
- setiastro/saspro/ser_stacker.py +2321 -0
- setiastro/saspro/ser_stacker_dialog.py +1838 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1625 -0
- setiastro/saspro/sfcc.py +662 -216
- setiastro/saspro/shortcuts.py +171 -33
- setiastro/saspro/signature_insert.py +692 -33
- setiastro/saspro/stacking_suite.py +1347 -485
- setiastro/saspro/star_alignment.py +247 -123
- setiastro/saspro/star_spikes.py +4 -0
- setiastro/saspro/star_stretch.py +38 -3
- setiastro/saspro/stat_stretch.py +892 -129
- setiastro/saspro/subwindow.py +787 -363
- setiastro/saspro/supernovaasteroidhunter.py +1 -1
- setiastro/saspro/texture_clarity.py +593 -0
- setiastro/saspro/wavescale_hdr.py +4 -1
- setiastro/saspro/wavescalede.py +4 -1
- setiastro/saspro/whitebalance.py +84 -12
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/resource_monitor.py +209 -111
- setiastro/saspro/widgets/spinboxes.py +10 -13
- setiastro/saspro/wimi.py +27 -656
- setiastro/saspro/wims.py +13 -3
- setiastro/saspro/xisf.py +101 -11
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +4 -2
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +132 -87
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/license.txt +0 -0
|
@@ -24,7 +24,7 @@ from setiastro.saspro.legacy.image_manager import load_image, save_image
|
|
|
24
24
|
from setiastro.saspro.legacy.numba_utils import bulk_cosmetic_correction_numba
|
|
25
25
|
from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
|
|
26
26
|
from setiastro.saspro.star_alignment import PolyGradientRemoval
|
|
27
|
-
from
|
|
27
|
+
from setiastro.saspro import minorbodycatalog as mbc
|
|
28
28
|
from setiastro.saspro.plate_solver import PlateSolverDialog as PlateSolver
|
|
29
29
|
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
30
30
|
|
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
# pro/texture_clarity.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import numpy as np
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QPointF, QEvent
|
|
7
|
+
from PyQt6.QtWidgets import (
|
|
8
|
+
QDialog, QVBoxLayout, QLabel, QSlider, QHBoxLayout,
|
|
9
|
+
QPushButton, QMessageBox, QCheckBox, QScrollArea, QWidget
|
|
10
|
+
)
|
|
11
|
+
from PyQt6.QtGui import QPixmap, QImage, QMovie
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import cv2
|
|
15
|
+
except Exception:
|
|
16
|
+
cv2 = None
|
|
17
|
+
|
|
18
|
+
# ---------- utils ----------
|
|
19
|
+
from setiastro.saspro.widgets.image_utils import (
|
|
20
|
+
to_float01 as _to_float01,
|
|
21
|
+
extract_mask_from_document as _active_mask_array_from_doc
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def _as_qimage_rgb8(float01: np.ndarray) -> QImage:
|
|
25
|
+
f = np.asarray(float01, dtype=np.float32)
|
|
26
|
+
|
|
27
|
+
# Ensure 3-channel RGB for preview
|
|
28
|
+
if f.ndim == 2:
|
|
29
|
+
f = np.stack([f]*3, axis=-1)
|
|
30
|
+
elif f.ndim == 3 and f.shape[2] == 1:
|
|
31
|
+
f = np.repeat(f, 3, axis=2)
|
|
32
|
+
|
|
33
|
+
# [0,1] -> uint8 and force C-contiguous
|
|
34
|
+
buf8 = (np.clip(f, 0.0, 1.0) * 255.0).astype(np.uint8, copy=False)
|
|
35
|
+
buf8 = np.ascontiguousarray(buf8)
|
|
36
|
+
h, w, _ = buf8.shape
|
|
37
|
+
bpl = int(buf8.strides[0])
|
|
38
|
+
|
|
39
|
+
# Detach
|
|
40
|
+
data = buf8.tobytes()
|
|
41
|
+
qimg = QImage(data, w, h, bpl, QImage.Format.Format_RGB888)
|
|
42
|
+
return qimg.copy()
|
|
43
|
+
|
|
44
|
+
def _ensure_rgb(arr: np.ndarray) -> np.ndarray | None:
|
|
45
|
+
a = _to_float01(arr)
|
|
46
|
+
if a is None: return None
|
|
47
|
+
if a.ndim == 2: return a
|
|
48
|
+
if a.ndim == 3 and a.shape[2] == 1: return a
|
|
49
|
+
if a.ndim == 3 and a.shape[2] >= 3:
|
|
50
|
+
return a[..., :3].astype(np.float32, copy=False)
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
def _midtone_mask(image: np.ndarray) -> np.ndarray:
|
|
54
|
+
"""
|
|
55
|
+
Generate a midtone mask where midtones (0.5) are 1.0 and shadows/highlights are 0.0.
|
|
56
|
+
"""
|
|
57
|
+
return np.clip(1.0 - 4.0 * (image - 0.5)**2, 0.0, 1.0)
|
|
58
|
+
|
|
59
|
+
def _apply_texture(image: np.ndarray, amount: float, radius: float) -> np.ndarray:
|
|
60
|
+
"""
|
|
61
|
+
TEXTURE: Enhances 'Texture' frequency band (Difference of Gaussians).
|
|
62
|
+
- Method: DoG (Band-pass). Isolate frequencies between Radius and 2*Radius.
|
|
63
|
+
"""
|
|
64
|
+
if abs(amount) < 0.001: return image
|
|
65
|
+
|
|
66
|
+
# Ensure input is valid float32 and contiguous
|
|
67
|
+
img = np.ascontiguousarray(image, dtype=np.float32)
|
|
68
|
+
if np.any(np.isnan(img)):
|
|
69
|
+
img = np.nan_to_num(img)
|
|
70
|
+
|
|
71
|
+
sigma1 = radius
|
|
72
|
+
sigma2 = radius * 2.0
|
|
73
|
+
|
|
74
|
+
ksize1 = int(2 * round(3 * sigma1) + 1); ksize1 += 1 if ksize1 % 2 == 0 else 0
|
|
75
|
+
ksize2 = int(2 * round(3 * sigma2) + 1); ksize2 += 1 if ksize2 % 2 == 0 else 0
|
|
76
|
+
|
|
77
|
+
if cv2 is not None:
|
|
78
|
+
try:
|
|
79
|
+
b1 = cv2.GaussianBlur(img, (ksize1, ksize1), sigma1)
|
|
80
|
+
b2 = cv2.GaussianBlur(img, (ksize2, ksize2), sigma2)
|
|
81
|
+
except Exception:
|
|
82
|
+
# Fallback if CV2 fails
|
|
83
|
+
return image
|
|
84
|
+
else:
|
|
85
|
+
return image
|
|
86
|
+
|
|
87
|
+
texture_band = b1 - b2
|
|
88
|
+
boost = 2.0 * amount
|
|
89
|
+
enhanced = img + texture_band * boost
|
|
90
|
+
return np.clip(enhanced, 0.0, 1.0)
|
|
91
|
+
|
|
92
|
+
def _apply_clarity(image: np.ndarray, amount: float, radius: float) -> np.ndarray:
|
|
93
|
+
"""
|
|
94
|
+
CLARITY: Local Contrast with Edge Preservation (Bilateral).
|
|
95
|
+
- Method: Original + Amount * (Original - Bilateral_Base).
|
|
96
|
+
- Optimization: Uses Downscale-Process-Upscale for large radii.
|
|
97
|
+
This allows effective large-radius filtering without using massive kernels that crash OpenCV.
|
|
98
|
+
- Safety: Kernel diameter 'd' is kept small relative to the processed image.
|
|
99
|
+
"""
|
|
100
|
+
if abs(amount) < 0.001: return image
|
|
101
|
+
|
|
102
|
+
# Target Sigma Space
|
|
103
|
+
sigma_space_target = radius * 10.0
|
|
104
|
+
sigma_color = 0.1
|
|
105
|
+
|
|
106
|
+
img_f32 = np.ascontiguousarray(image, dtype=np.float32)
|
|
107
|
+
if np.any(np.isnan(img_f32)):
|
|
108
|
+
img_f32 = np.nan_to_num(img_f32)
|
|
109
|
+
|
|
110
|
+
base = img_f32
|
|
111
|
+
|
|
112
|
+
if cv2 is not None:
|
|
113
|
+
try:
|
|
114
|
+
# Multi-scale Logic:
|
|
115
|
+
# If sigma_space is large (e.g. > 10.0), downscale the image.
|
|
116
|
+
# This makes the "pixels" larger, so a small kernel covers more area.
|
|
117
|
+
|
|
118
|
+
scale = 1.0
|
|
119
|
+
if sigma_space_target > 10.0:
|
|
120
|
+
# Calculate scale factor
|
|
121
|
+
# We want the effective sigma on the downscaled image to be manageable, say ~5-10.
|
|
122
|
+
# scaled_sigma = sigma_target * scale
|
|
123
|
+
# scale = 5.0 / sigma_target
|
|
124
|
+
scale = 5.0 / sigma_space_target
|
|
125
|
+
scale = max(0.1, min(scale, 1.0)) # Limit minimum scale to 10%
|
|
126
|
+
|
|
127
|
+
# If downscaling is significant
|
|
128
|
+
if scale < 0.95:
|
|
129
|
+
h, w = img_f32.shape[:2]
|
|
130
|
+
small_w = int(w * scale)
|
|
131
|
+
small_h = int(h * scale)
|
|
132
|
+
|
|
133
|
+
# Resize down
|
|
134
|
+
small_img = cv2.resize(img_f32, (small_w, small_h), interpolation=cv2.INTER_AREA)
|
|
135
|
+
|
|
136
|
+
# Adjust sigma for the small scale
|
|
137
|
+
sigma_small = sigma_space_target * scale
|
|
138
|
+
|
|
139
|
+
# A safe 'd' for the small image.
|
|
140
|
+
# Since we successfully shrunk the problem, d=9 is now effectively d=9/scale in original pixels.
|
|
141
|
+
# e.g with scale 0.2, d=9 covers 45 original pixels.
|
|
142
|
+
d_safe = 9
|
|
143
|
+
|
|
144
|
+
small_base = cv2.bilateralFilter(small_img, d=d_safe, sigmaColor=sigma_color, sigmaSpace=sigma_small)
|
|
145
|
+
|
|
146
|
+
# Resize up (using linear/cubic to smooth)
|
|
147
|
+
base = cv2.resize(small_base, (w, h), interpolation=cv2.INTER_LINEAR)
|
|
148
|
+
else:
|
|
149
|
+
# Standard processing for small radii
|
|
150
|
+
d_safe = 9
|
|
151
|
+
base = cv2.bilateralFilter(img_f32, d=d_safe, sigmaColor=sigma_color, sigmaSpace=sigma_space_target)
|
|
152
|
+
|
|
153
|
+
except Exception as e:
|
|
154
|
+
print(f"Bilateral Filter failed: {e}")
|
|
155
|
+
try:
|
|
156
|
+
base = cv2.GaussianBlur(img_f32, (0, 0), sigma_space_target)
|
|
157
|
+
except:
|
|
158
|
+
return image
|
|
159
|
+
else:
|
|
160
|
+
return image
|
|
161
|
+
|
|
162
|
+
detail = img_f32 - base
|
|
163
|
+
mask = _midtone_mask(img_f32)
|
|
164
|
+
enhanced = img_f32 + amount * detail * mask
|
|
165
|
+
|
|
166
|
+
return np.clip(enhanced, 0.0, 1.0)
|
|
167
|
+
|
|
168
|
+
def _compute_texture_clarity(image: np.ndarray, texture_amt: float, texture_rad: float, clarity_amt: float, clarity_rad: float) -> np.ndarray:
|
|
169
|
+
# 1. Texture (DoG Band)
|
|
170
|
+
out = _apply_texture(image, texture_amt, texture_rad)
|
|
171
|
+
|
|
172
|
+
# 2. Clarity (Bilateral Base)
|
|
173
|
+
out = _apply_clarity(out, clarity_amt, clarity_rad)
|
|
174
|
+
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
# ---------- headless core ----------
|
|
178
|
+
def texture_clarity_headless(
|
|
179
|
+
doc,
|
|
180
|
+
texture_amount: float = 0.0,
|
|
181
|
+
texture_radius: float = 1.0,
|
|
182
|
+
clarity_amount: float = 0.0,
|
|
183
|
+
clarity_radius: float = 1.0,
|
|
184
|
+
):
|
|
185
|
+
if doc is None or getattr(doc, "image", None) is None:
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
src = np.asarray(doc.image)
|
|
189
|
+
f_src = _to_float01(src)
|
|
190
|
+
if f_src is None:
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
is_rgb = (f_src.ndim == 3 and f_src.shape[2] >= 3)
|
|
194
|
+
|
|
195
|
+
if is_rgb:
|
|
196
|
+
R, G, B = f_src[..., 0], f_src[..., 1], f_src[..., 2]
|
|
197
|
+
L = 0.2126 * R + 0.7152 * G + 0.0722 * B
|
|
198
|
+
|
|
199
|
+
L_new = _compute_texture_clarity(L, texture_amount, texture_radius, clarity_amount, clarity_radius)
|
|
200
|
+
|
|
201
|
+
eps = 1e-7
|
|
202
|
+
ratio = L_new / (L + eps)
|
|
203
|
+
out = f_src[..., :3] * ratio[..., None]
|
|
204
|
+
out = np.clip(out, 0.0, 1.0)
|
|
205
|
+
else:
|
|
206
|
+
if f_src.ndim == 3: f_src = f_src.squeeze()
|
|
207
|
+
out = _compute_texture_clarity(f_src, texture_amount, texture_radius, clarity_amount, clarity_radius)
|
|
208
|
+
if src.ndim == 3: out = out[..., None]
|
|
209
|
+
|
|
210
|
+
# mask-aware blend
|
|
211
|
+
m = _active_mask_array_from_doc(doc)
|
|
212
|
+
if m is not None:
|
|
213
|
+
h, w = out.shape[:2]
|
|
214
|
+
if m.shape != (h, w):
|
|
215
|
+
if cv2 is not None:
|
|
216
|
+
m = cv2.resize(m, (w, h), interpolation=cv2.INTER_NEAREST)
|
|
217
|
+
else:
|
|
218
|
+
yi = (np.linspace(0, m.shape[0]-1, h)).astype(np.int32)
|
|
219
|
+
xi = (np.linspace(0, m.shape[1]-1, w)).astype(np.int32)
|
|
220
|
+
m = m[yi][:, xi]
|
|
221
|
+
|
|
222
|
+
if out.ndim == 3 and m.ndim == 2:
|
|
223
|
+
m = np.repeat(m[:, :, None], out.shape[2], axis=2)
|
|
224
|
+
|
|
225
|
+
src_f = _to_float01(src)
|
|
226
|
+
out = np.clip(src_f * (1.0 - m) + out * m, 0.0, 1.0)
|
|
227
|
+
|
|
228
|
+
meta = {
|
|
229
|
+
"step_name": "Texture and Clarity",
|
|
230
|
+
"texture_clarity": {
|
|
231
|
+
"texture_amount": texture_amount,
|
|
232
|
+
"texture_radius": texture_radius,
|
|
233
|
+
"clarity_amount": clarity_amount,
|
|
234
|
+
"clarity_radius": clarity_radius
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
doc.apply_edit(out.astype(np.float32, copy=False), metadata=meta, step_name="Texture and Clarity")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------- Worker ----------
|
|
241
|
+
|
|
242
|
+
class TextureClarityWorker(QThread):
|
|
243
|
+
preview_ready = pyqtSignal(object) # np.ndarray [0..1]
|
|
244
|
+
|
|
245
|
+
def __init__(self, image: np.ndarray, params: dict, mask01: np.ndarray | None = None):
|
|
246
|
+
super().__init__()
|
|
247
|
+
self.image = image
|
|
248
|
+
self.params = params
|
|
249
|
+
self.mask01 = mask01 # (H,W) float [0..1] or None
|
|
250
|
+
|
|
251
|
+
def run(self):
|
|
252
|
+
src = _to_float01(self.image)
|
|
253
|
+
if src is None:
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
texture_amt = float(self.params.get("t_amt", 0.0))
|
|
257
|
+
texture_rad = float(self.params.get("t_rad", 1.0))
|
|
258
|
+
clarity_amt = float(self.params.get("c_amt", 0.0))
|
|
259
|
+
clarity_rad = float(self.params.get("c_rad", 1.0))
|
|
260
|
+
|
|
261
|
+
is_rgb = (src.ndim == 3 and src.shape[2] >= 3)
|
|
262
|
+
|
|
263
|
+
if is_rgb:
|
|
264
|
+
R, G, B = src[..., 0], src[..., 1], src[..., 2]
|
|
265
|
+
L = 0.2126 * R + 0.7152 * G + 0.0722 * B
|
|
266
|
+
L_new = _compute_texture_clarity(L, texture_amt, texture_rad, clarity_amt, clarity_rad)
|
|
267
|
+
|
|
268
|
+
eps = 1e-7
|
|
269
|
+
ratio = L_new / (L + eps)
|
|
270
|
+
out = src[..., :3] * ratio[..., None]
|
|
271
|
+
else:
|
|
272
|
+
s = src.squeeze() if src.ndim == 3 else src
|
|
273
|
+
out = _compute_texture_clarity(s, texture_amt, texture_rad, clarity_amt, clarity_rad)
|
|
274
|
+
if src.ndim == 3: # preserve HxWx1 if that’s what caller had
|
|
275
|
+
out = out[..., None]
|
|
276
|
+
|
|
277
|
+
out = np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
|
|
278
|
+
|
|
279
|
+
# ✅ mask-aware blend (same behavior as headless)
|
|
280
|
+
m = self.mask01
|
|
281
|
+
if m is not None:
|
|
282
|
+
h, w = out.shape[:2]
|
|
283
|
+
if m.shape != (h, w):
|
|
284
|
+
if cv2 is not None:
|
|
285
|
+
m = cv2.resize(m, (w, h), interpolation=cv2.INTER_NEAREST)
|
|
286
|
+
else:
|
|
287
|
+
yi = (np.linspace(0, m.shape[0] - 1, h)).astype(np.int32)
|
|
288
|
+
xi = (np.linspace(0, m.shape[1] - 1, w)).astype(np.int32)
|
|
289
|
+
m = m[yi][:, xi]
|
|
290
|
+
|
|
291
|
+
# expand mask to channels
|
|
292
|
+
if out.ndim == 3 and m.ndim == 2:
|
|
293
|
+
m = m[:, :, None]
|
|
294
|
+
|
|
295
|
+
# make src match out shape for blending
|
|
296
|
+
src_f = src
|
|
297
|
+
if out.ndim == 2 and src_f.ndim == 3:
|
|
298
|
+
src_f = src_f.squeeze()
|
|
299
|
+
if out.ndim == 3 and src_f.ndim == 2:
|
|
300
|
+
src_f = np.repeat(src_f[:, :, None], out.shape[2], axis=2)
|
|
301
|
+
if out.ndim == 3 and src_f.ndim == 3 and src_f.shape[2] > out.shape[2]:
|
|
302
|
+
src_f = src_f[..., :out.shape[2]]
|
|
303
|
+
|
|
304
|
+
out = np.clip(src_f * (1.0 - m) + out * m, 0.0, 1.0).astype(np.float32, copy=False)
|
|
305
|
+
|
|
306
|
+
self.preview_ready.emit(out)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ---------- Dialog ----------
|
|
310
|
+
|
|
311
|
+
class TextureClarityDialog(QDialog):
|
|
312
|
+
def __init__(self, main, doc, parent=None):
|
|
313
|
+
super().__init__(parent)
|
|
314
|
+
self.main = main
|
|
315
|
+
self.doc = doc
|
|
316
|
+
self.setWindowTitle("Texture and Clarity")
|
|
317
|
+
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
318
|
+
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
319
|
+
self.setModal(False)
|
|
320
|
+
self._preview = None
|
|
321
|
+
self._pix = None
|
|
322
|
+
self._zoom = 0.25
|
|
323
|
+
self._panning = False
|
|
324
|
+
self._pan_start = QPointF()
|
|
325
|
+
|
|
326
|
+
# Watch for active document changes
|
|
327
|
+
self._connected_doc_change = False
|
|
328
|
+
if hasattr(self.main, "currentDocumentChanged"):
|
|
329
|
+
try:
|
|
330
|
+
self.main.currentDocumentChanged.connect(self._on_active_doc_changed)
|
|
331
|
+
self._connected_doc_change = True
|
|
332
|
+
except Exception:
|
|
333
|
+
pass
|
|
334
|
+
|
|
335
|
+
# Debounce timer for preview
|
|
336
|
+
self._preview_timer = QTimer(self)
|
|
337
|
+
self._preview_timer.setSingleShot(True)
|
|
338
|
+
self._preview_timer.setInterval(150) # 150ms debounce
|
|
339
|
+
self._preview_timer.timeout.connect(self._trigger_preview)
|
|
340
|
+
|
|
341
|
+
self._build_ui()
|
|
342
|
+
# Initial preview
|
|
343
|
+
self._trigger_preview()
|
|
344
|
+
|
|
345
|
+
def _build_ui(self):
|
|
346
|
+
container = QHBoxLayout(self)
|
|
347
|
+
|
|
348
|
+
# Left Column: Controls
|
|
349
|
+
left_widget = QWidget()
|
|
350
|
+
left_widget.setMinimumWidth(350)
|
|
351
|
+
left = QVBoxLayout(left_widget)
|
|
352
|
+
|
|
353
|
+
# Texture
|
|
354
|
+
left.addWidget(QLabel("Texture"))
|
|
355
|
+
self.sl_tex_amt = QSlider(Qt.Orientation.Horizontal)
|
|
356
|
+
self.sl_tex_amt.setRange(-100, 100); self.sl_tex_amt.setValue(0)
|
|
357
|
+
self.lbl_tex_amt = QLabel("Amount: 0.00")
|
|
358
|
+
self.sl_tex_amt.valueChanged.connect(lambda v: self._on_param_change(self.lbl_tex_amt, f"Amount: {v/100.0:.2f}"))
|
|
359
|
+
|
|
360
|
+
self.sl_tex_rad = QSlider(Qt.Orientation.Horizontal)
|
|
361
|
+
self.sl_tex_rad.setRange(1, 20); self.sl_tex_rad.setValue(10)
|
|
362
|
+
self.lbl_tex_rad = QLabel("Radius: 1.0")
|
|
363
|
+
self.sl_tex_rad.valueChanged.connect(lambda v: self._on_param_change(self.lbl_tex_rad, f"Radius: {v/10.0:.1f}"))
|
|
364
|
+
|
|
365
|
+
left.addWidget(self.lbl_tex_amt); left.addWidget(self.sl_tex_amt)
|
|
366
|
+
left.addWidget(self.lbl_tex_rad); left.addWidget(self.sl_tex_rad)
|
|
367
|
+
|
|
368
|
+
left.addSpacing(20)
|
|
369
|
+
|
|
370
|
+
# Clarity
|
|
371
|
+
left.addWidget(QLabel("Clarity"))
|
|
372
|
+
self.sl_clar_amt = QSlider(Qt.Orientation.Horizontal)
|
|
373
|
+
self.sl_clar_amt.setRange(-100, 100); self.sl_clar_amt.setValue(0)
|
|
374
|
+
self.lbl_clar_amt = QLabel("Amount: 0.00")
|
|
375
|
+
self.sl_clar_amt.valueChanged.connect(lambda v: self._on_param_change(self.lbl_clar_amt, f"Amount: {v/100.0:.2f}"))
|
|
376
|
+
|
|
377
|
+
self.sl_clar_rad = QSlider(Qt.Orientation.Horizontal)
|
|
378
|
+
self.sl_clar_rad.setRange(1, 100); self.sl_clar_rad.setValue(30)
|
|
379
|
+
self.lbl_clar_rad = QLabel("Radius: 3.0")
|
|
380
|
+
self.sl_clar_rad.valueChanged.connect(lambda v: self._on_param_change(self.lbl_clar_rad, f"Radius: {v/10.0:.1f}"))
|
|
381
|
+
|
|
382
|
+
left.addWidget(self.lbl_clar_amt); left.addWidget(self.sl_clar_amt)
|
|
383
|
+
left.addWidget(self.lbl_clar_rad); left.addWidget(self.sl_clar_rad)
|
|
384
|
+
|
|
385
|
+
left.addSpacing(10)
|
|
386
|
+
|
|
387
|
+
# Toggle for Real-time Preview (Requested: below sliders)
|
|
388
|
+
self.chk_realtime = QCheckBox("Real-time Preview")
|
|
389
|
+
self.chk_realtime.setChecked(True)
|
|
390
|
+
self.chk_realtime.toggled.connect(self._trigger_preview)
|
|
391
|
+
left.addWidget(self.chk_realtime)
|
|
392
|
+
|
|
393
|
+
left.addStretch(1)
|
|
394
|
+
|
|
395
|
+
# Buttons
|
|
396
|
+
row = QHBoxLayout()
|
|
397
|
+
self.btn_apply = QPushButton("Apply"); self.btn_apply.clicked.connect(self._apply)
|
|
398
|
+
|
|
399
|
+
# Reset Button (Requested)
|
|
400
|
+
self.btn_reset = QPushButton("Reset"); self.btn_reset.clicked.connect(self._reset_sliders)
|
|
401
|
+
|
|
402
|
+
btn_cancel= QPushButton("Cancel"); btn_cancel.clicked.connect(self.close)
|
|
403
|
+
|
|
404
|
+
row.addWidget(self.btn_apply)
|
|
405
|
+
row.addWidget(self.btn_reset)
|
|
406
|
+
row.addWidget(btn_cancel)
|
|
407
|
+
left.addLayout(row)
|
|
408
|
+
|
|
409
|
+
container.addWidget(left_widget, 0) # stretch 0
|
|
410
|
+
|
|
411
|
+
# Right Column: Preview
|
|
412
|
+
right = QVBoxLayout()
|
|
413
|
+
|
|
414
|
+
# Zoom controls
|
|
415
|
+
zoombar = QHBoxLayout()
|
|
416
|
+
b_out = QPushButton("Zoom -"); b_out.clicked.connect(self._zoom_out)
|
|
417
|
+
b_in = QPushButton("Zoom +"); b_in.clicked.connect(self._zoom_in)
|
|
418
|
+
b_fit = QPushButton("Fit"); b_fit.clicked.connect(self._fit)
|
|
419
|
+
|
|
420
|
+
zoombar.addWidget(b_out); zoombar.addWidget(b_in); zoombar.addWidget(b_fit)
|
|
421
|
+
right.addLayout(zoombar)
|
|
422
|
+
|
|
423
|
+
self.scroll = QScrollArea()
|
|
424
|
+
self.scroll.setWidgetResizable(True)
|
|
425
|
+
self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
426
|
+
self.scroll.viewport().installEventFilter(self)
|
|
427
|
+
|
|
428
|
+
self.preview_lbl = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
|
|
429
|
+
self.scroll.setWidget(self.preview_lbl)
|
|
430
|
+
|
|
431
|
+
right.addWidget(self.scroll, 1) # stretch 1 (expands)
|
|
432
|
+
|
|
433
|
+
container.addLayout(right, 1)
|
|
434
|
+
self.resize(1000, 600) # Increased width slightly for wider controls
|
|
435
|
+
|
|
436
|
+
def _on_param_change(self, lbl, text):
|
|
437
|
+
lbl.setText(text)
|
|
438
|
+
if self.chk_realtime.isChecked():
|
|
439
|
+
self._preview_timer.start()
|
|
440
|
+
|
|
441
|
+
def _reset_sliders(self):
|
|
442
|
+
# Block signals to avoid 4 separate preview triggers
|
|
443
|
+
self.sl_tex_amt.blockSignals(True)
|
|
444
|
+
self.sl_tex_rad.blockSignals(True)
|
|
445
|
+
self.sl_clar_amt.blockSignals(True)
|
|
446
|
+
self.sl_clar_rad.blockSignals(True)
|
|
447
|
+
|
|
448
|
+
self.sl_tex_amt.setValue(0)
|
|
449
|
+
self.sl_tex_rad.setValue(10)
|
|
450
|
+
self.sl_clar_amt.setValue(0)
|
|
451
|
+
self.sl_clar_rad.setValue(30)
|
|
452
|
+
|
|
453
|
+
self.sl_tex_amt.blockSignals(False)
|
|
454
|
+
self.sl_tex_rad.blockSignals(False)
|
|
455
|
+
self.sl_clar_amt.blockSignals(False)
|
|
456
|
+
self.sl_clar_rad.blockSignals(False)
|
|
457
|
+
|
|
458
|
+
# Update labels manually
|
|
459
|
+
self.lbl_tex_amt.setText("Amount: 0.00")
|
|
460
|
+
self.lbl_tex_rad.setText("Radius: 1.0")
|
|
461
|
+
self.lbl_clar_amt.setText("Amount: 0.00")
|
|
462
|
+
self.lbl_clar_rad.setText("Radius: 3.0")
|
|
463
|
+
|
|
464
|
+
# Trigger one preview update
|
|
465
|
+
if self.chk_realtime.isChecked():
|
|
466
|
+
self._preview_timer.start()
|
|
467
|
+
|
|
468
|
+
def _trigger_preview(self):
|
|
469
|
+
if self.doc is None or getattr(self.doc, "image", None) is None:
|
|
470
|
+
return
|
|
471
|
+
|
|
472
|
+
# Preview Toggle: If unchecked, show Original Image (Before)
|
|
473
|
+
if not self.chk_realtime.isChecked():
|
|
474
|
+
# Show original
|
|
475
|
+
qimg = _as_qimage_rgb8(_to_float01(np.asarray(self.doc.image)))
|
|
476
|
+
self._pix = QPixmap.fromImage(qimg)
|
|
477
|
+
self._apply_zoom()
|
|
478
|
+
return
|
|
479
|
+
|
|
480
|
+
params = {
|
|
481
|
+
"t_amt": self.sl_tex_amt.value() / 100.0,
|
|
482
|
+
"t_rad": self.sl_tex_rad.value() / 10.0,
|
|
483
|
+
"c_amt": self.sl_clar_amt.value() / 100.0,
|
|
484
|
+
"c_rad": self.sl_clar_rad.value() / 10.0
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
# Kill old worker if running
|
|
488
|
+
if hasattr(self, "_worker") and self._worker.isRunning():
|
|
489
|
+
self._worker.terminate()
|
|
490
|
+
self._worker.wait()
|
|
491
|
+
|
|
492
|
+
# ✅ grab active mask at trigger time
|
|
493
|
+
mask01 = _active_mask_array_from_doc(self.doc)
|
|
494
|
+
|
|
495
|
+
self._worker = TextureClarityWorker(self.doc.image, params, mask01=mask01)
|
|
496
|
+
self._worker.preview_ready.connect(self._on_preview_ready)
|
|
497
|
+
self._worker.start()
|
|
498
|
+
|
|
499
|
+
def _on_preview_ready(self, out_img):
|
|
500
|
+
self._preview = out_img
|
|
501
|
+
qimg = _as_qimage_rgb8(out_img)
|
|
502
|
+
self._pix = QPixmap.fromImage(qimg)
|
|
503
|
+
self._apply_zoom()
|
|
504
|
+
|
|
505
|
+
def _apply(self):
|
|
506
|
+
if self.doc is None: return
|
|
507
|
+
t_amt = self.sl_tex_amt.value() / 100.0
|
|
508
|
+
t_rad = self.sl_tex_rad.value() / 10.0
|
|
509
|
+
c_amt = self.sl_clar_amt.value() / 100.0
|
|
510
|
+
c_rad = self.sl_clar_rad.value() / 10.0
|
|
511
|
+
|
|
512
|
+
texture_clarity_headless(
|
|
513
|
+
self.doc,
|
|
514
|
+
texture_amount=t_amt,
|
|
515
|
+
texture_radius=t_rad,
|
|
516
|
+
clarity_amount=c_amt,
|
|
517
|
+
clarity_radius=c_rad
|
|
518
|
+
)
|
|
519
|
+
self.close()
|
|
520
|
+
|
|
521
|
+
def _on_active_doc_changed(self, doc):
|
|
522
|
+
if doc is None or getattr(doc, "image", None) is None:
|
|
523
|
+
return
|
|
524
|
+
if doc is not self.doc:
|
|
525
|
+
self.doc = doc
|
|
526
|
+
self.setWindowTitle(f"Texture and Clarity - {doc.display_name() if hasattr(doc,'display_name') else 'Image'}")
|
|
527
|
+
# Reset preview
|
|
528
|
+
self._trigger_preview()
|
|
529
|
+
|
|
530
|
+
# --- Zoom / Pan ---
|
|
531
|
+
def _apply_zoom(self):
|
|
532
|
+
if self._pix is None: return
|
|
533
|
+
scaled = self._pix.scaled(
|
|
534
|
+
self._pix.size() * self._zoom,
|
|
535
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
536
|
+
Qt.TransformationMode.SmoothTransformation
|
|
537
|
+
)
|
|
538
|
+
self.preview_lbl.setPixmap(scaled)
|
|
539
|
+
self.preview_lbl.resize(scaled.size())
|
|
540
|
+
|
|
541
|
+
def _zoom_in(self): self._set_zoom(self._zoom * 1.25)
|
|
542
|
+
def _zoom_out(self): self._set_zoom(self._zoom / 1.25)
|
|
543
|
+
def _set_zoom(self, z):
|
|
544
|
+
self._zoom = max(0.05, min(z, 5.0))
|
|
545
|
+
self._apply_zoom()
|
|
546
|
+
def _fit(self):
|
|
547
|
+
if self._pix is None: return
|
|
548
|
+
vp = self.scroll.viewport().size()
|
|
549
|
+
s = min(vp.width()/self._pix.width(), vp.height()/self._pix.height())
|
|
550
|
+
self._set_zoom(max(0.05, s))
|
|
551
|
+
|
|
552
|
+
def eventFilter(self, obj, ev):
|
|
553
|
+
if obj is self.scroll.viewport():
|
|
554
|
+
if ev.type() == QEvent.Type.Wheel and (ev.modifiers() & Qt.KeyboardModifier.ControlModifier):
|
|
555
|
+
self._set_zoom(self._zoom * (1.25 if ev.angleDelta().y() > 0 else 0.8))
|
|
556
|
+
ev.accept(); return True
|
|
557
|
+
if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
|
|
558
|
+
self._panning = True; self._pan_start = ev.position()
|
|
559
|
+
self.scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
|
|
560
|
+
ev.accept(); return True
|
|
561
|
+
if ev.type() == QEvent.Type.MouseMove and self._panning:
|
|
562
|
+
d = ev.position() - self._pan_start
|
|
563
|
+
h = self.scroll.horizontalScrollBar(); v = self.scroll.verticalScrollBar()
|
|
564
|
+
h.setValue(h.value() - int(d.x())); v.setValue(v.value() - int(d.y()))
|
|
565
|
+
self._pan_start = ev.position()
|
|
566
|
+
ev.accept(); return True
|
|
567
|
+
if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
|
|
568
|
+
self._panning = False
|
|
569
|
+
self.scroll.viewport().setCursor(Qt.CursorShape.ArrowCursor)
|
|
570
|
+
ev.accept(); return True
|
|
571
|
+
return super().eventFilter(obj, ev)
|
|
572
|
+
|
|
573
|
+
def closeEvent(self, ev):
|
|
574
|
+
if self._connected_doc_change and hasattr(self.main, "currentDocumentChanged"):
|
|
575
|
+
try:
|
|
576
|
+
self.main.currentDocumentChanged.disconnect(self._on_active_doc_changed)
|
|
577
|
+
except Exception:
|
|
578
|
+
pass
|
|
579
|
+
super().closeEvent(ev)
|
|
580
|
+
|
|
581
|
+
def open_texture_clarity_dialog(main, doc=None, preset: dict | None = None):
|
|
582
|
+
if doc is None:
|
|
583
|
+
doc = getattr(main, "_active_doc", None)
|
|
584
|
+
if callable(doc):
|
|
585
|
+
doc = doc()
|
|
586
|
+
|
|
587
|
+
if doc is None or getattr(doc, "image", None) is None:
|
|
588
|
+
QMessageBox.information(main, "Texture & Clarity", "Open an image first.")
|
|
589
|
+
return
|
|
590
|
+
|
|
591
|
+
dlg = TextureClarityDialog(main, doc, parent=main)
|
|
592
|
+
# If preset handling needed, add here (set sliders)
|
|
593
|
+
dlg.show()
|
|
@@ -202,7 +202,10 @@ class WaveScaleHDRDialogPro(QDialog):
|
|
|
202
202
|
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
203
203
|
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
204
204
|
self.setModal(False)
|
|
205
|
-
|
|
205
|
+
try:
|
|
206
|
+
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
207
|
+
except Exception:
|
|
208
|
+
pass # older PyQt6 versions
|
|
206
209
|
self._doc = doc
|
|
207
210
|
base = getattr(doc, "image", None)
|
|
208
211
|
if base is None:
|
setiastro/saspro/wavescalede.py
CHANGED
|
@@ -204,7 +204,10 @@ class WaveScaleDarkEnhancerDialogPro(QDialog):
|
|
|
204
204
|
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
205
205
|
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
206
206
|
self.setModal(False)
|
|
207
|
-
|
|
207
|
+
try:
|
|
208
|
+
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
209
|
+
except Exception:
|
|
210
|
+
pass # older PyQt6 versions
|
|
208
211
|
self._doc = doc
|
|
209
212
|
base = getattr(doc, "image", None)
|
|
210
213
|
if base is None:
|