setiastrosuitepro 1.6.2__py3-none-any.whl → 1.6.12__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 (162) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/colorwheel.svg +97 -0
  4. setiastro/images/cosmic.svg +40 -0
  5. setiastro/images/cosmicsat.svg +24 -0
  6. setiastro/images/first_quarter.png +0 -0
  7. setiastro/images/full_moon.png +0 -0
  8. setiastro/images/graxpert.svg +19 -0
  9. setiastro/images/last_quarter.png +0 -0
  10. setiastro/images/linearfit.svg +32 -0
  11. setiastro/images/new_moon.png +0 -0
  12. setiastro/images/pixelmath.svg +42 -0
  13. setiastro/images/rotatearbitrary.png +0 -0
  14. setiastro/images/waning_crescent_1.png +0 -0
  15. setiastro/images/waning_crescent_2.png +0 -0
  16. setiastro/images/waning_crescent_3.png +0 -0
  17. setiastro/images/waning_crescent_4.png +0 -0
  18. setiastro/images/waning_crescent_5.png +0 -0
  19. setiastro/images/waning_gibbous_1.png +0 -0
  20. setiastro/images/waning_gibbous_2.png +0 -0
  21. setiastro/images/waning_gibbous_3.png +0 -0
  22. setiastro/images/waning_gibbous_4.png +0 -0
  23. setiastro/images/waning_gibbous_5.png +0 -0
  24. setiastro/images/waxing_crescent_1.png +0 -0
  25. setiastro/images/waxing_crescent_2.png +0 -0
  26. setiastro/images/waxing_crescent_3.png +0 -0
  27. setiastro/images/waxing_crescent_4.png +0 -0
  28. setiastro/images/waxing_crescent_5.png +0 -0
  29. setiastro/images/waxing_gibbous_1.png +0 -0
  30. setiastro/images/waxing_gibbous_2.png +0 -0
  31. setiastro/images/waxing_gibbous_3.png +0 -0
  32. setiastro/images/waxing_gibbous_4.png +0 -0
  33. setiastro/images/waxing_gibbous_5.png +0 -0
  34. setiastro/qml/ResourceMonitor.qml +84 -82
  35. setiastro/saspro/__main__.py +20 -1
  36. setiastro/saspro/_generated/build_info.py +2 -2
  37. setiastro/saspro/abe.py +37 -4
  38. setiastro/saspro/aberration_ai.py +237 -21
  39. setiastro/saspro/acv_exporter.py +379 -0
  40. setiastro/saspro/add_stars.py +33 -6
  41. setiastro/saspro/backgroundneutral.py +114 -37
  42. setiastro/saspro/blemish_blaster.py +4 -1
  43. setiastro/saspro/blink_comparator_pro.py +548 -275
  44. setiastro/saspro/clahe.py +4 -1
  45. setiastro/saspro/continuum_subtract.py +4 -1
  46. setiastro/saspro/convo.py +13 -7
  47. setiastro/saspro/cosmicclarity.py +129 -18
  48. setiastro/saspro/crop_dialog_pro.py +134 -8
  49. setiastro/saspro/curve_editor_pro.py +109 -42
  50. setiastro/saspro/doc_manager.py +246 -16
  51. setiastro/saspro/exoplanet_detector.py +120 -28
  52. setiastro/saspro/frequency_separation.py +1158 -204
  53. setiastro/saspro/function_bundle.py +16 -16
  54. setiastro/saspro/ghs_dialog_pro.py +81 -16
  55. setiastro/saspro/graxpert.py +1 -0
  56. setiastro/saspro/gui/main_window.py +519 -289
  57. setiastro/saspro/gui/mixins/dock_mixin.py +276 -42
  58. setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
  59. setiastro/saspro/gui/mixins/menu_mixin.py +28 -1
  60. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  61. setiastro/saspro/gui/mixins/toolbar_mixin.py +416 -27
  62. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  63. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  64. setiastro/saspro/halobgon.py +4 -0
  65. setiastro/saspro/histogram.py +5 -1
  66. setiastro/saspro/image_combine.py +4 -0
  67. setiastro/saspro/image_peeker_pro.py +4 -0
  68. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  69. setiastro/saspro/imageops/stretch.py +582 -62
  70. setiastro/saspro/isophote.py +4 -0
  71. setiastro/saspro/layers.py +13 -9
  72. setiastro/saspro/layers_dock.py +183 -3
  73. setiastro/saspro/legacy/image_manager.py +154 -20
  74. setiastro/saspro/legacy/numba_utils.py +67 -47
  75. setiastro/saspro/legacy/xisf.py +240 -98
  76. setiastro/saspro/live_stacking.py +180 -79
  77. setiastro/saspro/luminancerecombine.py +228 -27
  78. setiastro/saspro/mask_creation.py +174 -15
  79. setiastro/saspro/mfdeconv.py +113 -35
  80. setiastro/saspro/mfdeconvcudnn.py +119 -70
  81. setiastro/saspro/mfdeconvsport.py +112 -35
  82. setiastro/saspro/morphology.py +4 -0
  83. setiastro/saspro/multiscale_decomp.py +748 -255
  84. setiastro/saspro/numba_utils.py +72 -57
  85. setiastro/saspro/ops/commands.py +18 -18
  86. setiastro/saspro/ops/script_editor.py +10 -2
  87. setiastro/saspro/ops/scripts.py +122 -0
  88. setiastro/saspro/perfect_palette_picker.py +37 -3
  89. setiastro/saspro/plate_solver.py +84 -49
  90. setiastro/saspro/psf_viewer.py +119 -37
  91. setiastro/saspro/remove_stars_preset.py +55 -13
  92. setiastro/saspro/resources.py +97 -11
  93. setiastro/saspro/rgbalign.py +4 -0
  94. setiastro/saspro/selective_color.py +83 -21
  95. setiastro/saspro/sfcc.py +364 -152
  96. setiastro/saspro/shortcuts.py +253 -49
  97. setiastro/saspro/signature_insert.py +692 -33
  98. setiastro/saspro/stacking_suite.py +1610 -574
  99. setiastro/saspro/star_alignment.py +522 -453
  100. setiastro/saspro/star_spikes.py +4 -0
  101. setiastro/saspro/star_stretch.py +38 -3
  102. setiastro/saspro/stat_stretch.py +743 -128
  103. setiastro/saspro/status_log_dock.py +1 -1
  104. setiastro/saspro/subwindow.py +786 -360
  105. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  106. setiastro/saspro/swap_manager.py +77 -42
  107. setiastro/saspro/translations/all_source_strings.json +1588 -516
  108. setiastro/saspro/translations/ar_translations.py +915 -684
  109. setiastro/saspro/translations/de_translations.py +442 -463
  110. setiastro/saspro/translations/es_translations.py +277 -47
  111. setiastro/saspro/translations/fr_translations.py +279 -47
  112. setiastro/saspro/translations/hi_translations.py +253 -21
  113. setiastro/saspro/translations/integrate_translations.py +3 -2
  114. setiastro/saspro/translations/it_translations.py +1211 -161
  115. setiastro/saspro/translations/ja_translations.py +3340 -3107
  116. setiastro/saspro/translations/pt_translations.py +3315 -3337
  117. setiastro/saspro/translations/ru_translations.py +351 -117
  118. setiastro/saspro/translations/saspro_ar.qm +0 -0
  119. setiastro/saspro/translations/saspro_ar.ts +15902 -138
  120. setiastro/saspro/translations/saspro_de.qm +0 -0
  121. setiastro/saspro/translations/saspro_de.ts +14428 -133
  122. setiastro/saspro/translations/saspro_es.qm +0 -0
  123. setiastro/saspro/translations/saspro_es.ts +11503 -7821
  124. setiastro/saspro/translations/saspro_fr.qm +0 -0
  125. setiastro/saspro/translations/saspro_fr.ts +11168 -7812
  126. setiastro/saspro/translations/saspro_hi.qm +0 -0
  127. setiastro/saspro/translations/saspro_hi.ts +14733 -135
  128. setiastro/saspro/translations/saspro_it.qm +0 -0
  129. setiastro/saspro/translations/saspro_it.ts +14347 -7821
  130. setiastro/saspro/translations/saspro_ja.qm +0 -0
  131. setiastro/saspro/translations/saspro_ja.ts +14860 -137
  132. setiastro/saspro/translations/saspro_pt.qm +0 -0
  133. setiastro/saspro/translations/saspro_pt.ts +14904 -137
  134. setiastro/saspro/translations/saspro_ru.qm +0 -0
  135. setiastro/saspro/translations/saspro_ru.ts +11766 -168
  136. setiastro/saspro/translations/saspro_sw.qm +0 -0
  137. setiastro/saspro/translations/saspro_sw.ts +15115 -135
  138. setiastro/saspro/translations/saspro_uk.qm +0 -0
  139. setiastro/saspro/translations/saspro_uk.ts +11206 -6729
  140. setiastro/saspro/translations/saspro_zh.qm +0 -0
  141. setiastro/saspro/translations/saspro_zh.ts +10581 -7812
  142. setiastro/saspro/translations/sw_translations.py +282 -56
  143. setiastro/saspro/translations/uk_translations.py +264 -35
  144. setiastro/saspro/translations/zh_translations.py +282 -47
  145. setiastro/saspro/view_bundle.py +17 -17
  146. setiastro/saspro/wavescale_hdr.py +4 -1
  147. setiastro/saspro/wavescalede.py +4 -1
  148. setiastro/saspro/whitebalance.py +84 -12
  149. setiastro/saspro/widgets/common_utilities.py +28 -21
  150. setiastro/saspro/widgets/minigame/game.js +11 -6
  151. setiastro/saspro/widgets/resource_monitor.py +133 -57
  152. setiastro/saspro/widgets/spinboxes.py +28 -13
  153. setiastro/saspro/wimi.py +92 -721
  154. setiastro/saspro/wims.py +46 -36
  155. setiastro/saspro/window_shelf.py +2 -2
  156. setiastro/saspro/xisf.py +101 -11
  157. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +8 -7
  158. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +162 -128
  159. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
  160. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
  161. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
  162. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.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,229 @@ 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
+ luma_blend: float = 1.0,
508
+ high_range: bool = False,
509
+ highrange_pedestal: float = 0.001,
510
+ highrange_soft_ceil_pct: float = 99.0,
511
+ highrange_hard_ceil_pct: float = 99.99,
512
+ highrange_softclip_threshold: float = 0.98,
513
+ highrange_softclip_rolloff: float = 2.0) -> np.ndarray:
151
514
  img = image.astype(np.float32, copy=False)
152
515
 
153
- # Mono/single-channel → reuse mono path and broadcast to 3-ch for display
516
+ # Mono/single-channel
154
517
  if img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1):
155
518
  mono = img.squeeze()
156
- mono_out = stretch_mono_image(mono, target_median, normalize=normalize,
157
- apply_curves=apply_curves, curves_boost=curves_boost)
519
+ mono_out = stretch_mono_image(
520
+ mono,
521
+ target_median,
522
+ normalize=normalize,
523
+ apply_curves=apply_curves,
524
+ curves_boost=curves_boost,
525
+ blackpoint_sigma=blackpoint_sigma,
526
+ hdr_compress=hdr_compress,
527
+ hdr_amount=hdr_amount,
528
+ hdr_knee=hdr_knee,
529
+ high_range=high_range,
530
+ highrange_pedestal=highrange_pedestal,
531
+ highrange_soft_ceil_pct=highrange_soft_ceil_pct,
532
+ highrange_hard_ceil_pct=highrange_hard_ceil_pct,
533
+ highrange_softclip_threshold=highrange_softclip_threshold,
534
+ highrange_softclip_rolloff=highrange_softclip_rolloff,
535
+ )
158
536
  return np.stack([mono_out] * 3, axis=-1)
159
537
 
160
- # Color
538
+ sig = float(blackpoint_sigma)
539
+
540
+ # ----- LUMA ONLY PATH (now with optional blending) -----
541
+ if luma_only:
542
+ b = float(np.clip(luma_blend, 0.0, 1.0))
543
+
544
+ # --- A) Normal linked RGB stretch (same settings, but NOT luma-only) ---
545
+ # Force linked=True here (matches "normal linked stretch" expectation)
546
+ # We compute this first so b=0 is fast-ish if you later optimize.
547
+ if no_black_clip:
548
+ bp = float(img.min())
549
+ med_img = float(np.median(img))
550
+ else:
551
+ bp, med_img = _compute_blackpoint_sigma(img, sig)
552
+
553
+ denom = max(1.0 - bp, 1e-12)
554
+ med_rescaled = (med_img - bp) / denom
555
+
556
+ linked_out = numba_color_linked_from_img(img, bp, denom, float(med_rescaled), float(target_median))
557
+
558
+ if apply_curves:
559
+ linked_out = apply_curves_adjustment(linked_out, float(target_median), float(curves_boost))
560
+
561
+ if hdr_compress and hdr_amount > 0.0:
562
+ linked_out = hdr_compress_color_luminance(
563
+ linked_out,
564
+ amount=float(hdr_amount),
565
+ knee=float(hdr_knee),
566
+ luma_mode="rec709",
567
+ )
568
+
569
+ if high_range:
570
+ linked_out = _high_range_rescale_and_softclip(
571
+ linked_out,
572
+ target_bg=float(target_median),
573
+ pedestal=float(highrange_pedestal),
574
+ soft_ceil_pct=float(highrange_soft_ceil_pct),
575
+ hard_ceil_pct=float(highrange_hard_ceil_pct),
576
+ floor_sigma=float(blackpoint_sigma),
577
+ softclip_threshold=float(highrange_softclip_threshold),
578
+ softclip_rolloff=float(highrange_softclip_rolloff),
579
+ )
580
+
581
+ if normalize:
582
+ mx = float(linked_out.max())
583
+ if mx > 0:
584
+ linked_out = linked_out / mx
585
+
586
+ linked_out = np.clip(linked_out, 0.0, 1.0).astype(np.float32, copy=False)
587
+
588
+ # Short-circuit if blend is 0 (pure linked)
589
+ if b <= 0.0:
590
+ return linked_out
591
+
592
+ # --- B) Your existing luma-only recombine stretch ---
593
+ resolved_method, w, _profile_name = resolve_luma_profile_weights(luma_mode)
594
+
595
+ ns = None
596
+ if resolved_method == "snr":
597
+ ns = _estimate_noise_sigma_per_channel(img)
598
+ L = compute_luminance(img, method=resolved_method, weights=w, noise_sigma=ns)
599
+
600
+ Ls = stretch_mono_image(
601
+ L,
602
+ target_median,
603
+ normalize=False,
604
+ apply_curves=apply_curves,
605
+ curves_boost=curves_boost,
606
+ blackpoint_sigma=sig,
607
+ no_black_clip=no_black_clip,
608
+ hdr_compress=False,
609
+ hdr_amount=0.0,
610
+ hdr_knee=hdr_knee,
611
+ high_range=False,
612
+ )
613
+
614
+ if hdr_compress and hdr_amount > 0.0:
615
+ Ls = hdr_compress_highlights(Ls, float(hdr_amount), knee=float(hdr_knee))
616
+
617
+ if w is not None and np.asarray(w).size == 3:
618
+ rw = np.asarray(w, dtype=np.float32)
619
+ s = float(rw.sum())
620
+ if s > 0:
621
+ rw = rw / s
622
+ else:
623
+ if resolved_method == "rec601":
624
+ rw = np.array([0.2990, 0.5870, 0.1140], dtype=np.float32)
625
+ elif resolved_method == "rec2020":
626
+ rw = np.array([0.2627, 0.6780, 0.0593], dtype=np.float32)
627
+ else:
628
+ rw = np.array([0.2126, 0.7152, 0.0722], dtype=np.float32)
629
+
630
+ luma_out = recombine_luminance_linear_scale(
631
+ img,
632
+ Ls,
633
+ weights=rw,
634
+ blend=1.0,
635
+ highlight_soft_knee=0.0,
636
+ )
637
+
638
+ if high_range:
639
+ luma_out = _high_range_rescale_and_softclip(
640
+ luma_out,
641
+ target_bg=float(target_median),
642
+ pedestal=float(highrange_pedestal),
643
+ soft_ceil_pct=float(highrange_soft_ceil_pct),
644
+ hard_ceil_pct=float(highrange_hard_ceil_pct),
645
+ floor_sigma=float(blackpoint_sigma),
646
+ softclip_threshold=float(highrange_softclip_threshold),
647
+ softclip_rolloff=float(highrange_softclip_rolloff),
648
+ )
649
+
650
+ if normalize:
651
+ mx = float(luma_out.max())
652
+ if mx > 0:
653
+ luma_out = luma_out / mx
654
+
655
+ luma_out = np.clip(luma_out, 0.0, 1.0).astype(np.float32, copy=False)
656
+
657
+ # --- Final blend: exactly “blend two separate stretched images” ---
658
+ out = (1.0 - b) * linked_out + b * luma_out
659
+ return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
660
+
661
+ # ----- NORMAL RGB PATH -----
161
662
  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))
663
+ if no_black_clip:
664
+ bp = float(img.min())
665
+ med_img = float(np.median(img))
666
+ else:
667
+ bp, med_img = _compute_blackpoint_sigma(img, sig)
668
+
669
+ denom = max(1.0 - bp, 1e-12)
670
+ med_rescaled = (med_img - bp) / denom
671
+
672
+ out = numba_color_linked_from_img(img, bp, denom, float(med_rescaled), float(target_median))
172
673
  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))
674
+ if no_black_clip:
675
+ bp3 = np.array([float(img[...,0].min()),
676
+ float(img[...,1].min()),
677
+ float(img[...,2].min())], dtype=np.float32)
678
+ med_img3 = np.median(img, axis=(0, 1)).astype(np.float32)
679
+ else:
680
+ bp3 = _compute_blackpoint_sigma_per_channel(img, sig).astype(np.float32, copy=False)
681
+ med_img3 = np.median(img, axis=(0, 1)).astype(np.float32)
682
+
683
+ denom3 = np.maximum(1.0 - bp3, 1e-12).astype(np.float32)
684
+ meds_rescaled3 = (med_img3 - bp3) / denom3
685
+
686
+ out = numba_color_unlinked_from_img(img, bp3, denom3, meds_rescaled3, float(target_median))
687
+
191
688
 
192
689
  if apply_curves:
193
690
  out = apply_curves_adjustment(out, float(target_median), float(curves_boost))
691
+
692
+ if hdr_compress and hdr_amount > 0.0:
693
+ # Compress highlights on luminance, then recombine via linear scaling (prevents star bloat)
694
+ out = hdr_compress_color_luminance(
695
+ out,
696
+ amount=float(hdr_amount),
697
+ knee=float(hdr_knee),
698
+ luma_mode="rec709",
699
+ )
700
+
194
701
  if normalize:
195
702
  mx = float(out.max())
196
703
  if mx > 0:
197
704
  out = out / mx
198
705
 
706
+ if high_range:
707
+ out = _high_range_rescale_and_softclip(
708
+ out,
709
+ target_bg=float(target_median),
710
+ pedestal=float(highrange_pedestal),
711
+ soft_ceil_pct=float(highrange_soft_ceil_pct),
712
+ hard_ceil_pct=float(highrange_hard_ceil_pct),
713
+ floor_sigma=float(blackpoint_sigma),
714
+ softclip_threshold=float(highrange_softclip_threshold),
715
+ softclip_rolloff=float(highrange_softclip_rolloff),
716
+ )
717
+
199
718
  return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
200
719
 
201
720
 
721
+
202
722
  def siril_style_autostretch(image, sigma=3.0):
203
723
  """
204
724
  Perform a 'Siril-style histogram stretch' using MAD for robust contrast enhancement.