setiastrosuitepro 1.6.7__py3-none-any.whl → 1.6.10__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/abeicon.svg +16 -0
- setiastro/images/cosmic.svg +40 -0
- setiastro/images/cosmicsat.svg +24 -0
- setiastro/images/graxpert.svg +19 -0
- setiastro/images/linearfit.svg +32 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/add_stars.py +29 -5
- setiastro/saspro/blink_comparator_pro.py +74 -24
- setiastro/saspro/cosmicclarity.py +125 -18
- setiastro/saspro/crop_dialog_pro.py +96 -2
- setiastro/saspro/curve_editor_pro.py +60 -39
- setiastro/saspro/frequency_separation.py +1159 -208
- setiastro/saspro/gui/main_window.py +131 -31
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/update_mixin.py +121 -33
- setiastro/saspro/imageops/stretch.py +531 -62
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/numba_utils.py +43 -0
- setiastro/saspro/live_stacking.py +158 -70
- setiastro/saspro/multiscale_decomp.py +47 -12
- setiastro/saspro/numba_utils.py +72 -2
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/shortcuts.py +122 -12
- setiastro/saspro/signature_insert.py +688 -33
- setiastro/saspro/stacking_suite.py +523 -316
- setiastro/saspro/stat_stretch.py +688 -130
- setiastro/saspro/subwindow.py +302 -71
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/resource_monitor.py +7 -7
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/METADATA +1 -1
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/RECORD +37 -31
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/license.txt +0 -0
|
@@ -5,6 +5,11 @@ import numpy as np
|
|
|
5
5
|
# ---- Try Numba kernels from legacy ----
|
|
6
6
|
try:
|
|
7
7
|
from setiastro.saspro.legacy.numba_utils import (
|
|
8
|
+
numba_mono_from_img,
|
|
9
|
+
numba_color_linked_from_img,
|
|
10
|
+
numba_color_unlinked_from_img,
|
|
11
|
+
|
|
12
|
+
# keep these too if other callers still use them
|
|
8
13
|
numba_mono_final_formula,
|
|
9
14
|
numba_color_final_formula_linked,
|
|
10
15
|
numba_color_final_formula_unlinked,
|
|
@@ -13,32 +18,351 @@ try:
|
|
|
13
18
|
except Exception:
|
|
14
19
|
_HAS_NUMBA = False
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
r = rescaled
|
|
21
|
+
def numba_mono_from_img(img, bp, denom, median_rescaled, target_median):
|
|
22
|
+
r = (img - bp) / denom
|
|
19
23
|
med = float(median_rescaled)
|
|
20
24
|
num = (med - 1.0) * target_median * r
|
|
21
25
|
den = med * (target_median + r - 1.0) - target_median * r
|
|
22
26
|
den = np.where(np.abs(den) < 1e-12, 1e-12, den)
|
|
23
27
|
return num / den
|
|
24
28
|
|
|
25
|
-
def
|
|
26
|
-
r =
|
|
29
|
+
def numba_color_linked_from_img(img, bp, denom, median_rescaled, target_median):
|
|
30
|
+
r = (img - bp) / denom
|
|
27
31
|
med = float(median_rescaled)
|
|
28
32
|
num = (med - 1.0) * target_median * r
|
|
29
33
|
den = med * (target_median + r - 1.0) - target_median * r
|
|
30
34
|
den = np.where(np.abs(den) < 1e-12, 1e-12, den)
|
|
31
35
|
return num / den
|
|
32
36
|
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
def numba_color_unlinked_from_img(img, bp3, denom3, meds_rescaled3, target_median):
|
|
38
|
+
bp3 = np.asarray(bp3, dtype=np.float32).reshape((1, 1, 3))
|
|
39
|
+
denom3 = np.asarray(denom3, dtype=np.float32).reshape((1, 1, 3))
|
|
40
|
+
meds = np.asarray(meds_rescaled3, dtype=np.float32).reshape((1, 1, 3))
|
|
41
|
+
r = (img - bp3) / denom3
|
|
42
|
+
num = (meds - 1.0) * target_median * r
|
|
43
|
+
den = meds * (target_median + r - 1.0) - target_median * r
|
|
38
44
|
den = np.where(np.abs(den) < 1e-12, 1e-12, den)
|
|
39
45
|
return num / den
|
|
40
46
|
|
|
41
47
|
|
|
48
|
+
from setiastro.saspro.luminancerecombine import (
|
|
49
|
+
LUMA_PROFILES,
|
|
50
|
+
resolve_luma_profile_weights,
|
|
51
|
+
compute_luminance,
|
|
52
|
+
recombine_luminance_linear_scale,
|
|
53
|
+
_estimate_noise_sigma_per_channel, # <-- add this
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def _sample_flat(x: np.ndarray, max_n: int = 400_000) -> np.ndarray:
|
|
57
|
+
flat = np.asarray(x, np.float32).reshape(-1)
|
|
58
|
+
n = flat.size
|
|
59
|
+
if n <= max_n:
|
|
60
|
+
return flat
|
|
61
|
+
stride = max(1, n // max_n)
|
|
62
|
+
return flat[::stride]
|
|
63
|
+
|
|
64
|
+
def _robust_sigma_lower_half_fast(x: np.ndarray, max_n: int = 400_000) -> float:
|
|
65
|
+
s = _sample_flat(x, max_n=max_n)
|
|
66
|
+
med = float(np.median(s))
|
|
67
|
+
lo = s[s <= med]
|
|
68
|
+
if lo.size < 16:
|
|
69
|
+
mad = float(np.median(np.abs(s - med)))
|
|
70
|
+
else:
|
|
71
|
+
med_lo = float(np.median(lo))
|
|
72
|
+
mad = float(np.median(np.abs(lo - med_lo)))
|
|
73
|
+
return 1.4826 * mad
|
|
74
|
+
|
|
75
|
+
def _compute_blackpoint_sigma(img: np.ndarray, sigma: float) -> float:
|
|
76
|
+
"""
|
|
77
|
+
Compute blackpoint using robust sigma so the slider actually works.
|
|
78
|
+
Returns bp clamped to [min..0.99].
|
|
79
|
+
"""
|
|
80
|
+
img = np.asarray(img, dtype=np.float32)
|
|
81
|
+
med = float(np.median(img))
|
|
82
|
+
sig = float(sigma)
|
|
83
|
+
|
|
84
|
+
noise = _robust_sigma_lower_half_fast(img)
|
|
85
|
+
bp = med - sig * noise
|
|
86
|
+
|
|
87
|
+
# Clamp to valid range
|
|
88
|
+
mn = float(img.min())
|
|
89
|
+
bp = max(mn, bp)
|
|
90
|
+
bp = min(bp, 0.99)
|
|
91
|
+
return float(bp), med
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _compute_blackpoint_sigma_per_channel(img: np.ndarray, sigma: float) -> np.ndarray:
|
|
95
|
+
"""
|
|
96
|
+
Per-channel version for unlinked color.
|
|
97
|
+
"""
|
|
98
|
+
sig = float(sigma)
|
|
99
|
+
bp = np.zeros(3, dtype=np.float32)
|
|
100
|
+
for c in range(3):
|
|
101
|
+
ch = img[..., c].astype(np.float32, copy=False)
|
|
102
|
+
med = float(np.median(ch))
|
|
103
|
+
noise = _robust_sigma_lower_half_fast(ch)
|
|
104
|
+
b = med - sig * noise
|
|
105
|
+
b = max(float(ch.min()), b)
|
|
106
|
+
b = min(b, 0.99)
|
|
107
|
+
bp[c] = b
|
|
108
|
+
return bp
|
|
109
|
+
|
|
110
|
+
def hdr_compress_highlights(x: np.ndarray, amount: float, knee: float = 0.75) -> np.ndarray:
|
|
111
|
+
"""
|
|
112
|
+
Smooth soft-knee highlight compression with C1 continuity at the knee.
|
|
113
|
+
|
|
114
|
+
IMPORTANT:
|
|
115
|
+
- We want highlights to get *dimmer* as amount increases.
|
|
116
|
+
- For the Hermite curve on t in [0..1], keeping m0=1 and making m1>1
|
|
117
|
+
puts the curve BELOW f(t)=t (compression), while still ending at 1.
|
|
118
|
+
|
|
119
|
+
amount: 0..1 (0=off)
|
|
120
|
+
knee: 0..1 where compression starts
|
|
121
|
+
"""
|
|
122
|
+
a = float(np.clip(amount, 0.0, 1.0))
|
|
123
|
+
if a <= 0.0:
|
|
124
|
+
return x.astype(np.float32, copy=False)
|
|
125
|
+
|
|
126
|
+
k = float(np.clip(knee, 0.0, 0.99))
|
|
127
|
+
y = x.astype(np.float32, copy=False)
|
|
128
|
+
|
|
129
|
+
hi = y > k
|
|
130
|
+
if not np.any(hi):
|
|
131
|
+
return np.clip(y, 0.0, 1.0).astype(np.float32, copy=False)
|
|
132
|
+
|
|
133
|
+
# Normalize region above knee to t in [0..1]
|
|
134
|
+
t = (y[hi] - k) / (1.0 - k)
|
|
135
|
+
t = np.clip(t, 0.0, 1.0)
|
|
136
|
+
|
|
137
|
+
# End slope at t=1:
|
|
138
|
+
# a=0 -> m1=1 (identity)
|
|
139
|
+
# a=1 -> m1=5 (stronger compression but still stable; avoid too-large slopes)
|
|
140
|
+
m1 = 1.0 + 4.0 * a
|
|
141
|
+
m1 = float(np.clip(m1, 1.0, 5.0))
|
|
142
|
+
|
|
143
|
+
# Cubic Hermite: p0=0, p1=1, m0=1 (match slope at knee), m1=m1 (>1 compresses)
|
|
144
|
+
t2 = t * t
|
|
145
|
+
t3 = t2 * t
|
|
146
|
+
|
|
147
|
+
h10 = (t3 - 2.0 * t2 + t) # m0
|
|
148
|
+
h01 = (-2.0 * t3 + 3.0 * t2) # p1
|
|
149
|
+
h11 = (t3 - t2) # m1
|
|
150
|
+
|
|
151
|
+
f = h10 * 1.0 + h01 * 1.0 + h11 * m1
|
|
152
|
+
|
|
153
|
+
y2 = y.copy()
|
|
154
|
+
y2[hi] = k + (1.0 - k) * np.clip(f, 0.0, 1.0)
|
|
155
|
+
|
|
156
|
+
return np.clip(y2, 0.0, 1.0).astype(np.float32, copy=False)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def hdr_compress_highlights_L(L: np.ndarray, amount: float, knee: float = 0.75) -> np.ndarray:
|
|
160
|
+
"""
|
|
161
|
+
Same as hdr_compress_highlights(), but for luminance arrays.
|
|
162
|
+
"""
|
|
163
|
+
a = float(np.clip(amount, 0.0, 1.0))
|
|
164
|
+
if a <= 0.0:
|
|
165
|
+
return L.astype(np.float32, copy=False)
|
|
166
|
+
|
|
167
|
+
k = float(np.clip(knee, 0.0, 0.99))
|
|
168
|
+
y = L.astype(np.float32, copy=False)
|
|
169
|
+
|
|
170
|
+
hi = y > k
|
|
171
|
+
if not np.any(hi):
|
|
172
|
+
return np.clip(y, 0.0, 1.0).astype(np.float32, copy=False)
|
|
173
|
+
|
|
174
|
+
t = (y[hi] - k) / (1.0 - k)
|
|
175
|
+
t = np.clip(t, 0.0, 1.0)
|
|
176
|
+
|
|
177
|
+
m1 = 1.0 + 4.0 * a
|
|
178
|
+
m1 = float(np.clip(m1, 1.0, 5.0))
|
|
179
|
+
|
|
180
|
+
t2 = t * t
|
|
181
|
+
t3 = t2 * t
|
|
182
|
+
|
|
183
|
+
h10 = (t3 - 2.0 * t2 + t)
|
|
184
|
+
h01 = (-2.0 * t3 + 3.0 * t2)
|
|
185
|
+
h11 = (t3 - t2)
|
|
186
|
+
|
|
187
|
+
f = h10 * 1.0 + h01 * 1.0 + h11 * m1
|
|
188
|
+
|
|
189
|
+
y2 = y.copy()
|
|
190
|
+
y2[hi] = k + (1.0 - k) * np.clip(f, 0.0, 1.0)
|
|
191
|
+
|
|
192
|
+
return np.clip(y2, 0.0, 1.0).astype(np.float32, copy=False)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _resolve_rgb_weights_for_luma(method: str, w) -> np.ndarray:
|
|
196
|
+
"""
|
|
197
|
+
Returns normalized RGB weights for recombine_luminance_linear_scale.
|
|
198
|
+
method: rec709/rec601/rec2020 or anything else -> defaults to rec709.
|
|
199
|
+
w: optional weights from resolve_luma_profile_weights
|
|
200
|
+
"""
|
|
201
|
+
if w is not None and np.asarray(w).size == 3:
|
|
202
|
+
rw = np.asarray(w, dtype=np.float32).copy()
|
|
203
|
+
s = float(rw.sum())
|
|
204
|
+
if s > 0:
|
|
205
|
+
rw /= s
|
|
206
|
+
else:
|
|
207
|
+
rw = np.array([0.2126, 0.7152, 0.0722], dtype=np.float32)
|
|
208
|
+
return rw
|
|
209
|
+
|
|
210
|
+
m = str(method).lower()
|
|
211
|
+
if m == "rec601":
|
|
212
|
+
return np.array([0.2990, 0.5870, 0.1140], dtype=np.float32)
|
|
213
|
+
if m == "rec2020":
|
|
214
|
+
return np.array([0.2627, 0.6780, 0.0593], dtype=np.float32)
|
|
215
|
+
return np.array([0.2126, 0.7152, 0.0722], dtype=np.float32)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def hdr_compress_color_luminance(
|
|
219
|
+
rgb: np.ndarray,
|
|
220
|
+
amount: float,
|
|
221
|
+
knee: float,
|
|
222
|
+
luma_mode: str = "rec709",
|
|
223
|
+
) -> np.ndarray:
|
|
224
|
+
"""
|
|
225
|
+
WaveScaleHDR-style: compress highlights in luminance, then recombine by linear scaling.
|
|
226
|
+
rgb: (H,W,3) float32 in [0..1] (or close).
|
|
227
|
+
"""
|
|
228
|
+
a = float(np.clip(amount, 0.0, 1.0))
|
|
229
|
+
if a <= 0.0:
|
|
230
|
+
return rgb.astype(np.float32, copy=False)
|
|
231
|
+
|
|
232
|
+
resolved_method, w, _ = resolve_luma_profile_weights(luma_mode)
|
|
233
|
+
rw = _resolve_rgb_weights_for_luma(resolved_method, w)
|
|
234
|
+
|
|
235
|
+
# Compute luminance from CURRENT rgb, compress luminance, recombine by scale
|
|
236
|
+
if resolved_method == "snr":
|
|
237
|
+
ns = _estimate_noise_sigma_per_channel(rgb)
|
|
238
|
+
Y = compute_luminance(rgb, method="snr", weights=None, noise_sigma=ns)
|
|
239
|
+
else:
|
|
240
|
+
Y = compute_luminance(rgb, method=resolved_method, weights=rw)
|
|
241
|
+
Yc = hdr_compress_highlights(Y, a, knee=float(knee))
|
|
242
|
+
|
|
243
|
+
return recombine_luminance_linear_scale(
|
|
244
|
+
rgb,
|
|
245
|
+
Yc,
|
|
246
|
+
weights=rw,
|
|
247
|
+
blend=1.0,
|
|
248
|
+
highlight_soft_knee=0.25,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def _apply_mtf(data: np.ndarray, m: float) -> np.ndarray:
|
|
252
|
+
"""
|
|
253
|
+
Midtones Transfer Function (PixInsight-style).
|
|
254
|
+
Moves current median toward target without hard clipping.
|
|
255
|
+
"""
|
|
256
|
+
m = float(m)
|
|
257
|
+
x = data.astype(np.float32, copy=False)
|
|
258
|
+
term1 = (m - 1.0) * x
|
|
259
|
+
term2 = (2.0 * m - 1.0) * x - m
|
|
260
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
261
|
+
y = term1 / term2
|
|
262
|
+
return np.nan_to_num(y, nan=0.0, posinf=1.0, neginf=0.0).astype(np.float32, copy=False)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _compute_mtf_m_from_median(current_bg: float, target_bg: float) -> float:
|
|
266
|
+
"""
|
|
267
|
+
Solve for 'm' such that MTF moves current median to target median.
|
|
268
|
+
"""
|
|
269
|
+
cb = float(current_bg)
|
|
270
|
+
tb = float(target_bg)
|
|
271
|
+
cb = float(np.clip(cb, 1e-6, 1.0 - 1e-6))
|
|
272
|
+
tb = float(np.clip(tb, 1e-6, 1.0 - 1e-6))
|
|
273
|
+
|
|
274
|
+
den = cb * (2.0 * tb - 1.0) - tb
|
|
275
|
+
if abs(den) < 1e-12:
|
|
276
|
+
den = 1e-12
|
|
277
|
+
m = (cb * (tb - 1.0)) / den
|
|
278
|
+
return float(np.clip(m, 1e-6, 1.0 - 1e-6))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _high_range_rescale_and_softclip(
|
|
282
|
+
img: np.ndarray,
|
|
283
|
+
target_bg: float,
|
|
284
|
+
pedestal: float = 0.001,
|
|
285
|
+
soft_ceil_pct: float = 99.0,
|
|
286
|
+
hard_ceil_pct: float = 99.99,
|
|
287
|
+
floor_sigma: float = 2.7,
|
|
288
|
+
softclip_threshold: float = 0.98,
|
|
289
|
+
softclip_rolloff: float = 2.0,
|
|
290
|
+
) -> np.ndarray:
|
|
291
|
+
"""
|
|
292
|
+
VeraLux-like "ready-to-use" high range manager:
|
|
293
|
+
- robust floor (median - k*sigma)
|
|
294
|
+
- soft/hard ceilings (percentiles)
|
|
295
|
+
- rescale with safety to avoid clipping
|
|
296
|
+
- MTF median -> target_bg
|
|
297
|
+
- soft clip rolloff near 1.0
|
|
298
|
+
|
|
299
|
+
Expects HWC float32-ish, can be out of [0..1] (we fix it safely).
|
|
300
|
+
"""
|
|
301
|
+
x = img.astype(np.float32, copy=False)
|
|
302
|
+
|
|
303
|
+
# Compute luminance proxy for stats (works for mono too)
|
|
304
|
+
if x.ndim == 2 or (x.ndim == 3 and x.shape[2] == 1):
|
|
305
|
+
L = x.squeeze()
|
|
306
|
+
is_rgb = False
|
|
307
|
+
else:
|
|
308
|
+
is_rgb = True
|
|
309
|
+
# Rec709 luma proxy; we only use it for stats
|
|
310
|
+
L = 0.2126 * x[..., 0] + 0.7152 * x[..., 1] + 0.0722 * x[..., 2]
|
|
311
|
+
|
|
312
|
+
# Robust floor (use your existing robust sigma estimator)
|
|
313
|
+
med = float(np.median(L))
|
|
314
|
+
sig = float(_robust_sigma_lower_half_fast(L))
|
|
315
|
+
global_floor = max(float(np.min(L)), med - float(floor_sigma) * sig)
|
|
316
|
+
|
|
317
|
+
# Percentile ceilings (stride sample for speed)
|
|
318
|
+
flat = L.reshape(-1)
|
|
319
|
+
stride = max(1, flat.size // 500000)
|
|
320
|
+
sample = flat[::stride]
|
|
321
|
+
|
|
322
|
+
soft_ceil = float(np.percentile(sample, float(soft_ceil_pct)))
|
|
323
|
+
hard_ceil = float(np.percentile(sample, float(hard_ceil_pct)))
|
|
324
|
+
|
|
325
|
+
if soft_ceil <= global_floor:
|
|
326
|
+
soft_ceil = global_floor + 1e-6
|
|
327
|
+
if hard_ceil <= soft_ceil:
|
|
328
|
+
hard_ceil = soft_ceil + 1e-6
|
|
329
|
+
|
|
330
|
+
ped = float(np.clip(pedestal, 0.0, 0.05))
|
|
331
|
+
|
|
332
|
+
# Contrast scale aims for 0.98, safety scale aims for 1.0
|
|
333
|
+
scale_contrast = (0.98 - ped) / (soft_ceil - global_floor + 1e-12)
|
|
334
|
+
scale_safety = (1.0 - ped) / (hard_ceil - global_floor + 1e-12)
|
|
335
|
+
s = float(min(scale_contrast, scale_safety))
|
|
336
|
+
|
|
337
|
+
y = (x - global_floor) * s + ped
|
|
338
|
+
|
|
339
|
+
# Clamp to [0..1] before MTF + softclip
|
|
340
|
+
y = np.clip(y, 0.0, 1.0).astype(np.float32, copy=False)
|
|
341
|
+
|
|
342
|
+
# Recompute bg and apply MTF to land median near target
|
|
343
|
+
if target_bg is not None:
|
|
344
|
+
tb = float(target_bg)
|
|
345
|
+
if 0.0 < tb < 1.0:
|
|
346
|
+
if not is_rgb:
|
|
347
|
+
cur = float(np.median(y.squeeze()))
|
|
348
|
+
else:
|
|
349
|
+
Ly = 0.2126 * y[..., 0] + 0.7152 * y[..., 1] + 0.0722 * y[..., 2]
|
|
350
|
+
cur = float(np.median(Ly))
|
|
351
|
+
|
|
352
|
+
if 0.0 < cur < 1.0 and abs(cur - tb) > 1e-3:
|
|
353
|
+
m = _compute_mtf_m_from_median(cur, tb)
|
|
354
|
+
y = _apply_mtf(y, m)
|
|
355
|
+
y = np.clip(y, 0.0, 1.0).astype(np.float32, copy=False)
|
|
356
|
+
|
|
357
|
+
# Final soft clip rolloff near highlights
|
|
358
|
+
if softclip_threshold is not None and softclip_rolloff is not None:
|
|
359
|
+
y = hdr_compress_highlights(y, amount=1.0, knee=float(softclip_threshold))
|
|
360
|
+
# NOTE: hdr_compress_highlights() already does a hermite rolloff;
|
|
361
|
+
# we map rolloff to that by using knee as threshold.
|
|
362
|
+
|
|
363
|
+
return np.clip(y, 0.0, 1.0).astype(np.float32, copy=False)
|
|
364
|
+
|
|
365
|
+
|
|
42
366
|
# ---- Optional curves boost (gentle S-curve) ----
|
|
43
367
|
from functools import lru_cache
|
|
44
368
|
|
|
@@ -103,39 +427,67 @@ def apply_curves_adjustment(image: np.ndarray,
|
|
|
103
427
|
|
|
104
428
|
return np.clip(out, 0.0, 1.0)
|
|
105
429
|
|
|
106
|
-
|
|
107
|
-
|
|
108
430
|
# ---- Public API used by Pro ----
|
|
109
431
|
def stretch_mono_image(image: np.ndarray,
|
|
110
432
|
target_median: float,
|
|
111
433
|
normalize: bool = False,
|
|
112
434
|
apply_curves: bool = False,
|
|
113
|
-
curves_boost: float = 0.0
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
435
|
+
curves_boost: float = 0.0,
|
|
436
|
+
blackpoint_sigma: float = 5.0,
|
|
437
|
+
no_black_clip: bool = False,
|
|
438
|
+
hdr_compress: bool = False,
|
|
439
|
+
hdr_amount: float = 0.0,
|
|
440
|
+
hdr_knee: float = 0.75,
|
|
441
|
+
high_range: bool = False,
|
|
442
|
+
highrange_pedestal: float = 0.001,
|
|
443
|
+
highrange_soft_ceil_pct: float = 99.0,
|
|
444
|
+
highrange_hard_ceil_pct: float = 99.99,
|
|
445
|
+
highrange_softclip_threshold: float = 0.98,
|
|
446
|
+
highrange_softclip_rolloff: float = 2.0) -> np.ndarray:
|
|
117
447
|
img = image.astype(np.float32, copy=False)
|
|
118
448
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
449
|
+
sig = float(blackpoint_sigma)
|
|
450
|
+
|
|
451
|
+
if no_black_clip:
|
|
452
|
+
bp = float(img.min())
|
|
453
|
+
med_img = float(np.median(img)) # only if you still need it
|
|
454
|
+
else:
|
|
455
|
+
bp, med_img = _compute_blackpoint_sigma(img, sig)
|
|
126
456
|
|
|
127
|
-
|
|
128
|
-
med_rescaled =
|
|
457
|
+
denom = max(1.0 - bp, 1e-12)
|
|
458
|
+
med_rescaled = (med_img - bp) / denom
|
|
129
459
|
|
|
130
|
-
|
|
460
|
+
# NO rescaled array needed anymore
|
|
461
|
+
out = numba_mono_from_img(img, bp, denom, float(med_rescaled), float(target_median))
|
|
131
462
|
|
|
132
463
|
if apply_curves:
|
|
133
464
|
out = apply_curves_adjustment(out, float(target_median), float(curves_boost))
|
|
465
|
+
|
|
466
|
+
if hdr_compress and hdr_amount > 0.0:
|
|
467
|
+
out = hdr_compress_highlights(out, float(hdr_amount), knee=float(hdr_knee))
|
|
468
|
+
|
|
134
469
|
if normalize:
|
|
135
470
|
mx = float(out.max())
|
|
136
471
|
if mx > 0:
|
|
137
472
|
out = out / mx
|
|
138
473
|
|
|
474
|
+
if high_range:
|
|
475
|
+
out = _high_range_rescale_and_softclip(
|
|
476
|
+
out,
|
|
477
|
+
target_bg=float(target_median),
|
|
478
|
+
pedestal=float(highrange_pedestal),
|
|
479
|
+
soft_ceil_pct=float(highrange_soft_ceil_pct),
|
|
480
|
+
hard_ceil_pct=float(highrange_hard_ceil_pct),
|
|
481
|
+
floor_sigma=float(blackpoint_sigma),
|
|
482
|
+
softclip_threshold=float(highrange_softclip_threshold),
|
|
483
|
+
softclip_rolloff=float(highrange_softclip_rolloff),
|
|
484
|
+
)
|
|
485
|
+
# After high-range manager, normalize is redundant; but keep behavior if user asked.
|
|
486
|
+
if normalize:
|
|
487
|
+
mx = float(out.max())
|
|
488
|
+
if mx > 0:
|
|
489
|
+
out = out / mx
|
|
490
|
+
|
|
139
491
|
return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
|
|
140
492
|
|
|
141
493
|
|
|
@@ -144,61 +496,178 @@ def stretch_color_image(image: np.ndarray,
|
|
|
144
496
|
linked: bool = True,
|
|
145
497
|
normalize: bool = False,
|
|
146
498
|
apply_curves: bool = False,
|
|
147
|
-
curves_boost: float = 0.0
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
499
|
+
curves_boost: float = 0.0,
|
|
500
|
+
blackpoint_sigma: float = 5.0,
|
|
501
|
+
no_black_clip: bool = False,
|
|
502
|
+
hdr_compress: bool = False,
|
|
503
|
+
hdr_amount: float = 0.0,
|
|
504
|
+
hdr_knee: float = 0.75,
|
|
505
|
+
luma_only: bool = False,
|
|
506
|
+
luma_mode: str = "rec709",
|
|
507
|
+
high_range: bool = False,
|
|
508
|
+
highrange_pedestal: float = 0.001,
|
|
509
|
+
highrange_soft_ceil_pct: float = 99.0,
|
|
510
|
+
highrange_hard_ceil_pct: float = 99.99,
|
|
511
|
+
highrange_softclip_threshold: float = 0.98,
|
|
512
|
+
highrange_softclip_rolloff: float = 2.0) -> np.ndarray:
|
|
151
513
|
img = image.astype(np.float32, copy=False)
|
|
152
514
|
|
|
153
|
-
# Mono/single-channel
|
|
515
|
+
# Mono/single-channel
|
|
154
516
|
if img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1):
|
|
155
517
|
mono = img.squeeze()
|
|
156
|
-
mono_out = stretch_mono_image(
|
|
157
|
-
|
|
518
|
+
mono_out = stretch_mono_image(
|
|
519
|
+
mono,
|
|
520
|
+
target_median,
|
|
521
|
+
normalize=normalize,
|
|
522
|
+
apply_curves=apply_curves,
|
|
523
|
+
curves_boost=curves_boost,
|
|
524
|
+
blackpoint_sigma=blackpoint_sigma,
|
|
525
|
+
hdr_compress=hdr_compress,
|
|
526
|
+
hdr_amount=hdr_amount,
|
|
527
|
+
hdr_knee=hdr_knee,
|
|
528
|
+
high_range=high_range,
|
|
529
|
+
highrange_pedestal=highrange_pedestal,
|
|
530
|
+
highrange_soft_ceil_pct=highrange_soft_ceil_pct,
|
|
531
|
+
highrange_hard_ceil_pct=highrange_hard_ceil_pct,
|
|
532
|
+
highrange_softclip_threshold=highrange_softclip_threshold,
|
|
533
|
+
highrange_softclip_rolloff=highrange_softclip_rolloff,
|
|
534
|
+
)
|
|
158
535
|
return np.stack([mono_out] * 3, axis=-1)
|
|
159
536
|
|
|
160
|
-
|
|
537
|
+
sig = float(blackpoint_sigma)
|
|
538
|
+
|
|
539
|
+
# ----- LUMA ONLY PATH -----
|
|
540
|
+
if luma_only:
|
|
541
|
+
resolved_method, w, _profile_name = resolve_luma_profile_weights(luma_mode)
|
|
542
|
+
|
|
543
|
+
# For snr mode, compute_luminance may require noise_sigma; your module supports that,
|
|
544
|
+
# but for stretch we can keep it simple: treat snr as normal rec709 unless you want
|
|
545
|
+
# to plumb sigma estimation here too.
|
|
546
|
+
# If you DO want snr weights, we can reuse _estimate_noise_sigma_per_channel.
|
|
547
|
+
ns = None
|
|
548
|
+
if resolved_method == "snr":
|
|
549
|
+
ns = _estimate_noise_sigma_per_channel(img) # expects ~[0..1] float
|
|
550
|
+
L = compute_luminance(img, method=resolved_method, weights=w, noise_sigma=ns)
|
|
551
|
+
|
|
552
|
+
Ls = stretch_mono_image(
|
|
553
|
+
L,
|
|
554
|
+
target_median,
|
|
555
|
+
normalize=False,
|
|
556
|
+
apply_curves=apply_curves,
|
|
557
|
+
curves_boost=curves_boost,
|
|
558
|
+
blackpoint_sigma=sig,
|
|
559
|
+
hdr_compress=False,
|
|
560
|
+
hdr_amount=0.0,
|
|
561
|
+
hdr_knee=hdr_knee,
|
|
562
|
+
high_range=False, # do high_range after recombine
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
if hdr_compress and hdr_amount > 0.0:
|
|
566
|
+
Ls = hdr_compress_highlights(Ls, float(hdr_amount), knee=float(hdr_knee))
|
|
567
|
+
|
|
568
|
+
# Choose actual RGB weights for recombine
|
|
569
|
+
if w is not None and np.asarray(w).size == 3:
|
|
570
|
+
rw = np.asarray(w, dtype=np.float32)
|
|
571
|
+
s = float(rw.sum())
|
|
572
|
+
if s > 0:
|
|
573
|
+
rw = rw / s
|
|
574
|
+
else:
|
|
575
|
+
# If resolver returns None for standard modes, fall back
|
|
576
|
+
if resolved_method == "rec601":
|
|
577
|
+
rw = np.array([0.2990, 0.5870, 0.1140], dtype=np.float32)
|
|
578
|
+
elif resolved_method == "rec2020":
|
|
579
|
+
rw = np.array([0.2627, 0.6780, 0.0593], dtype=np.float32)
|
|
580
|
+
else:
|
|
581
|
+
rw = np.array([0.2126, 0.7152, 0.0722], dtype=np.float32)
|
|
582
|
+
|
|
583
|
+
out = recombine_luminance_linear_scale(
|
|
584
|
+
img,
|
|
585
|
+
Ls,
|
|
586
|
+
weights=rw,
|
|
587
|
+
blend=1.0,
|
|
588
|
+
highlight_soft_knee=0.0, # separate from HDR; keep 0 unless you want extra protection
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
if high_range:
|
|
592
|
+
out = _high_range_rescale_and_softclip(
|
|
593
|
+
out,
|
|
594
|
+
target_bg=float(target_median),
|
|
595
|
+
pedestal=float(highrange_pedestal),
|
|
596
|
+
soft_ceil_pct=float(highrange_soft_ceil_pct),
|
|
597
|
+
hard_ceil_pct=float(highrange_hard_ceil_pct),
|
|
598
|
+
floor_sigma=float(blackpoint_sigma),
|
|
599
|
+
softclip_threshold=float(highrange_softclip_threshold),
|
|
600
|
+
softclip_rolloff=float(highrange_softclip_rolloff),
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
if normalize:
|
|
604
|
+
mx = float(out.max())
|
|
605
|
+
if mx > 0:
|
|
606
|
+
out = out / mx
|
|
607
|
+
|
|
608
|
+
return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
|
|
609
|
+
|
|
610
|
+
# ----- NORMAL RGB PATH -----
|
|
161
611
|
if linked:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
out =
|
|
612
|
+
if no_black_clip:
|
|
613
|
+
bp = float(img.min())
|
|
614
|
+
med_img = float(np.median(img))
|
|
615
|
+
else:
|
|
616
|
+
bp, med_img = _compute_blackpoint_sigma(img, sig)
|
|
617
|
+
|
|
618
|
+
denom = max(1.0 - bp, 1e-12)
|
|
619
|
+
med_rescaled = (med_img - bp) / denom
|
|
620
|
+
|
|
621
|
+
out = numba_color_linked_from_img(img, bp, denom, float(med_rescaled), float(target_median))
|
|
172
622
|
else:
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
# Compute rescaled medians
|
|
188
|
-
meds = np.median(rescaled, axis=(0, 1)).astype(np.float32)
|
|
189
|
-
|
|
190
|
-
out = numba_color_final_formula_unlinked(rescaled, meds, float(target_median))
|
|
623
|
+
if no_black_clip:
|
|
624
|
+
bp3 = np.array([float(img[...,0].min()),
|
|
625
|
+
float(img[...,1].min()),
|
|
626
|
+
float(img[...,2].min())], dtype=np.float32)
|
|
627
|
+
med_img3 = np.median(img, axis=(0, 1)).astype(np.float32)
|
|
628
|
+
else:
|
|
629
|
+
bp3 = _compute_blackpoint_sigma_per_channel(img, sig).astype(np.float32, copy=False)
|
|
630
|
+
med_img3 = np.median(img, axis=(0, 1)).astype(np.float32)
|
|
631
|
+
|
|
632
|
+
denom3 = np.maximum(1.0 - bp3, 1e-12).astype(np.float32)
|
|
633
|
+
meds_rescaled3 = (med_img3 - bp3) / denom3
|
|
634
|
+
|
|
635
|
+
out = numba_color_unlinked_from_img(img, bp3, denom3, meds_rescaled3, float(target_median))
|
|
636
|
+
|
|
191
637
|
|
|
192
638
|
if apply_curves:
|
|
193
639
|
out = apply_curves_adjustment(out, float(target_median), float(curves_boost))
|
|
640
|
+
|
|
641
|
+
if hdr_compress and hdr_amount > 0.0:
|
|
642
|
+
# Compress highlights on luminance, then recombine via linear scaling (prevents star bloat)
|
|
643
|
+
out = hdr_compress_color_luminance(
|
|
644
|
+
out,
|
|
645
|
+
amount=float(hdr_amount),
|
|
646
|
+
knee=float(hdr_knee),
|
|
647
|
+
luma_mode="rec709",
|
|
648
|
+
)
|
|
649
|
+
|
|
194
650
|
if normalize:
|
|
195
651
|
mx = float(out.max())
|
|
196
652
|
if mx > 0:
|
|
197
653
|
out = out / mx
|
|
198
654
|
|
|
655
|
+
if high_range:
|
|
656
|
+
out = _high_range_rescale_and_softclip(
|
|
657
|
+
out,
|
|
658
|
+
target_bg=float(target_median),
|
|
659
|
+
pedestal=float(highrange_pedestal),
|
|
660
|
+
soft_ceil_pct=float(highrange_soft_ceil_pct),
|
|
661
|
+
hard_ceil_pct=float(highrange_hard_ceil_pct),
|
|
662
|
+
floor_sigma=float(blackpoint_sigma),
|
|
663
|
+
softclip_threshold=float(highrange_softclip_threshold),
|
|
664
|
+
softclip_rolloff=float(highrange_softclip_rolloff),
|
|
665
|
+
)
|
|
666
|
+
|
|
199
667
|
return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
|
|
200
668
|
|
|
201
669
|
|
|
670
|
+
|
|
202
671
|
def siril_style_autostretch(image, sigma=3.0):
|
|
203
672
|
"""
|
|
204
673
|
Perform a 'Siril-style histogram stretch' using MAD for robust contrast enhancement.
|