setiastrosuitepro 1.6.4__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.

Files changed (112) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/cosmic.svg +40 -0
  4. setiastro/images/cosmicsat.svg +24 -0
  5. setiastro/images/first_quarter.png +0 -0
  6. setiastro/images/full_moon.png +0 -0
  7. setiastro/images/graxpert.svg +19 -0
  8. setiastro/images/last_quarter.png +0 -0
  9. setiastro/images/linearfit.svg +32 -0
  10. setiastro/images/new_moon.png +0 -0
  11. setiastro/images/pixelmath.svg +42 -0
  12. setiastro/images/waning_crescent_1.png +0 -0
  13. setiastro/images/waning_crescent_2.png +0 -0
  14. setiastro/images/waning_crescent_3.png +0 -0
  15. setiastro/images/waning_crescent_4.png +0 -0
  16. setiastro/images/waning_crescent_5.png +0 -0
  17. setiastro/images/waning_gibbous_1.png +0 -0
  18. setiastro/images/waning_gibbous_2.png +0 -0
  19. setiastro/images/waning_gibbous_3.png +0 -0
  20. setiastro/images/waning_gibbous_4.png +0 -0
  21. setiastro/images/waning_gibbous_5.png +0 -0
  22. setiastro/images/waxing_crescent_1.png +0 -0
  23. setiastro/images/waxing_crescent_2.png +0 -0
  24. setiastro/images/waxing_crescent_3.png +0 -0
  25. setiastro/images/waxing_crescent_4.png +0 -0
  26. setiastro/images/waxing_crescent_5.png +0 -0
  27. setiastro/images/waxing_gibbous_1.png +0 -0
  28. setiastro/images/waxing_gibbous_2.png +0 -0
  29. setiastro/images/waxing_gibbous_3.png +0 -0
  30. setiastro/images/waxing_gibbous_4.png +0 -0
  31. setiastro/images/waxing_gibbous_5.png +0 -0
  32. setiastro/qml/ResourceMonitor.qml +84 -82
  33. setiastro/saspro/__main__.py +19 -0
  34. setiastro/saspro/_generated/build_info.py +2 -2
  35. setiastro/saspro/abe.py +37 -4
  36. setiastro/saspro/aberration_ai.py +237 -21
  37. setiastro/saspro/acv_exporter.py +379 -0
  38. setiastro/saspro/add_stars.py +33 -6
  39. setiastro/saspro/backgroundneutral.py +35 -7
  40. setiastro/saspro/blemish_blaster.py +4 -1
  41. setiastro/saspro/blink_comparator_pro.py +74 -24
  42. setiastro/saspro/clahe.py +4 -1
  43. setiastro/saspro/continuum_subtract.py +4 -1
  44. setiastro/saspro/convo.py +4 -1
  45. setiastro/saspro/cosmicclarity.py +129 -18
  46. setiastro/saspro/crop_dialog_pro.py +123 -7
  47. setiastro/saspro/curve_editor_pro.py +109 -42
  48. setiastro/saspro/doc_manager.py +67 -4
  49. setiastro/saspro/exoplanet_detector.py +120 -28
  50. setiastro/saspro/frequency_separation.py +1158 -204
  51. setiastro/saspro/ghs_dialog_pro.py +81 -16
  52. setiastro/saspro/graxpert.py +1 -0
  53. setiastro/saspro/gui/main_window.py +393 -204
  54. setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
  55. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  56. setiastro/saspro/gui/mixins/toolbar_mixin.py +356 -12
  57. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  58. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  59. setiastro/saspro/halobgon.py +4 -0
  60. setiastro/saspro/histogram.py +5 -1
  61. setiastro/saspro/image_combine.py +4 -0
  62. setiastro/saspro/image_peeker_pro.py +4 -0
  63. setiastro/saspro/imageops/stretch.py +531 -62
  64. setiastro/saspro/isophote.py +4 -0
  65. setiastro/saspro/layers.py +13 -9
  66. setiastro/saspro/layers_dock.py +183 -3
  67. setiastro/saspro/legacy/image_manager.py +154 -20
  68. setiastro/saspro/legacy/numba_utils.py +43 -0
  69. setiastro/saspro/legacy/xisf.py +240 -98
  70. setiastro/saspro/live_stacking.py +180 -79
  71. setiastro/saspro/luminancerecombine.py +228 -27
  72. setiastro/saspro/mask_creation.py +174 -15
  73. setiastro/saspro/mfdeconv.py +113 -35
  74. setiastro/saspro/mfdeconvcudnn.py +119 -70
  75. setiastro/saspro/mfdeconvsport.py +112 -35
  76. setiastro/saspro/morphology.py +4 -0
  77. setiastro/saspro/multiscale_decomp.py +51 -12
  78. setiastro/saspro/numba_utils.py +72 -2
  79. setiastro/saspro/ops/commands.py +18 -18
  80. setiastro/saspro/ops/script_editor.py +5 -2
  81. setiastro/saspro/ops/scripts.py +3 -0
  82. setiastro/saspro/perfect_palette_picker.py +37 -3
  83. setiastro/saspro/plate_solver.py +84 -49
  84. setiastro/saspro/psf_viewer.py +119 -37
  85. setiastro/saspro/resources.py +67 -0
  86. setiastro/saspro/rgbalign.py +4 -0
  87. setiastro/saspro/selective_color.py +4 -1
  88. setiastro/saspro/sfcc.py +60 -2
  89. setiastro/saspro/shortcuts.py +142 -23
  90. setiastro/saspro/signature_insert.py +692 -33
  91. setiastro/saspro/stacking_suite.py +1017 -400
  92. setiastro/saspro/star_alignment.py +4 -1
  93. setiastro/saspro/star_spikes.py +4 -0
  94. setiastro/saspro/star_stretch.py +38 -3
  95. setiastro/saspro/stat_stretch.py +702 -128
  96. setiastro/saspro/subwindow.py +786 -360
  97. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  98. setiastro/saspro/wavescale_hdr.py +4 -1
  99. setiastro/saspro/wavescalede.py +4 -1
  100. setiastro/saspro/whitebalance.py +60 -12
  101. setiastro/saspro/widgets/common_utilities.py +28 -21
  102. setiastro/saspro/widgets/resource_monitor.py +109 -59
  103. setiastro/saspro/widgets/spinboxes.py +10 -13
  104. setiastro/saspro/wimi.py +27 -656
  105. setiastro/saspro/wims.py +13 -3
  106. setiastro/saspro/xisf.py +101 -11
  107. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/METADATA +2 -1
  108. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/RECORD +112 -80
  109. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/WHEEL +0 -0
  110. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/entry_points.txt +0 -0
  111. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/LICENSE +0 -0
  112. {setiastrosuitepro-1.6.4.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
- # Vectorized fallbacks (no Numba)
17
- def numba_mono_final_formula(rescaled, median_rescaled, target_median):
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 numba_color_final_formula_linked(rescaled, median_rescaled, target_median):
26
- r = rescaled
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 numba_color_final_formula_unlinked(rescaled, medians_rescaled, target_median):
34
- r = rescaled
35
- med = np.asarray(medians_rescaled, dtype=np.float32).reshape((1, 1, 3))
36
- num = (med - 1.0) * target_median * r
37
- den = med * (target_median + r - 1.0) - target_median * r
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) -> np.ndarray:
114
- """
115
- image: float32 preferred, ~[0..1]. Returns float32 in [0..1].
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
- # Black point from SASv2 logic
120
- med = float(np.median(img))
121
- std = float(np.std(img))
122
- bp = max(float(img.min()), med - 2.7 * std)
123
- denom = 1.0 - bp
124
- if abs(denom) < 1e-12:
125
- denom = 1e-12
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
- rescaled = (img - bp) / denom
128
- med_rescaled = float(np.median(rescaled))
457
+ denom = max(1.0 - bp, 1e-12)
458
+ med_rescaled = (med_img - bp) / denom
129
459
 
130
- out = numba_mono_final_formula(rescaled, med_rescaled, float(target_median))
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) -> np.ndarray:
148
- """
149
- image: float32 preferred, ~[0..1]. Returns float32 in [0..1].
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 → reuse mono path and broadcast to 3-ch for display
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(mono, target_median, normalize=normalize,
157
- apply_curves=apply_curves, curves_boost=curves_boost)
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
- # Color
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
- comb_med = float(np.median(img))
163
- comb_std = float(np.std(img))
164
- bp = max(float(img.min()), comb_med - 2.7 * comb_std)
165
- denom = 1.0 - bp
166
- if abs(denom) < 1e-12:
167
- denom = 1e-12
168
-
169
- rescaled = (img - bp) / denom
170
- med_rescaled = float(np.median(rescaled))
171
- out = numba_color_final_formula_linked(rescaled, med_rescaled, float(target_median))
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
- # Optimized: compute all channel statistics in single vectorized calls
174
- # instead of per-channel loop (2-3x faster)
175
- ch_meds = np.median(img, axis=(0, 1)) # Shape: (3,)
176
- ch_stds = np.std(img, axis=(0, 1)) # Shape: (3,)
177
- ch_mins = img.min(axis=(0, 1)) # Shape: (3,)
178
-
179
- # Compute black points for all channels at once
180
- bp = np.maximum(ch_mins, ch_meds - 2.7 * ch_stds)
181
- denom = np.maximum(1.0 - bp, 1e-12) # Avoid divide by zero
182
-
183
- # Rescale all channels at once using broadcasting
184
- rescaled = (img - bp.reshape(1, 1, 3)) / denom.reshape(1, 1, 3)
185
- rescaled = rescaled.astype(np.float32, copy=False)
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.
@@ -363,6 +363,10 @@ class IsophoteModelerDialog(QDialog):
363
363
  self.setWindowFlag(Qt.WindowType.Window, True)
364
364
  self.setWindowModality(Qt.WindowModality.NonModal)
365
365
  self.setModal(False)
366
+ try:
367
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
368
+ except Exception:
369
+ pass # older PyQt6 versions
366
370
  self.image_manager = image_manager
367
371
  self.doc_manager = doc_manager
368
372