setiastrosuitepro 1.6.10__py3-none-any.whl → 1.7.0.post2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. setiastro/images/colorwheel.svg +97 -0
  2. setiastro/images/narrowbandnormalization.png +0 -0
  3. setiastro/images/planetarystacker.png +0 -0
  4. setiastro/saspro/__main__.py +1 -1
  5. setiastro/saspro/_generated/build_info.py +2 -2
  6. setiastro/saspro/aberration_ai.py +49 -11
  7. setiastro/saspro/aberration_ai_preset.py +29 -3
  8. setiastro/saspro/backgroundneutral.py +73 -33
  9. setiastro/saspro/blink_comparator_pro.py +116 -71
  10. setiastro/saspro/convo.py +9 -6
  11. setiastro/saspro/curve_editor_pro.py +72 -22
  12. setiastro/saspro/curves_preset.py +249 -47
  13. setiastro/saspro/doc_manager.py +178 -11
  14. setiastro/saspro/gui/main_window.py +305 -66
  15. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  16. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  17. setiastro/saspro/gui/mixins/menu_mixin.py +32 -1
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +135 -11
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +972 -0
  22. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  23. setiastro/saspro/imageops/stretch.py +66 -15
  24. setiastro/saspro/legacy/numba_utils.py +25 -48
  25. setiastro/saspro/live_stacking.py +24 -4
  26. setiastro/saspro/multiscale_decomp.py +30 -17
  27. setiastro/saspro/narrowband_normalization.py +1618 -0
  28. setiastro/saspro/numba_utils.py +0 -55
  29. setiastro/saspro/ops/script_editor.py +5 -0
  30. setiastro/saspro/ops/scripts.py +119 -0
  31. setiastro/saspro/remove_green.py +1 -1
  32. setiastro/saspro/resources.py +4 -0
  33. setiastro/saspro/ser_stack_config.py +74 -0
  34. setiastro/saspro/ser_stacker.py +2310 -0
  35. setiastro/saspro/ser_stacker_dialog.py +1500 -0
  36. setiastro/saspro/ser_tracking.py +206 -0
  37. setiastro/saspro/serviewer.py +1258 -0
  38. setiastro/saspro/sfcc.py +602 -214
  39. setiastro/saspro/shortcuts.py +35 -16
  40. setiastro/saspro/stacking_suite.py +332 -87
  41. setiastro/saspro/star_alignment.py +243 -122
  42. setiastro/saspro/stat_stretch.py +220 -31
  43. setiastro/saspro/subwindow.py +2 -4
  44. setiastro/saspro/whitebalance.py +24 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/METADATA +2 -2
  47. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/RECORD +51 -40
  48. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/LICENSE +0 -0
  51. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/license.txt +0 -0
@@ -0,0 +1,816 @@
1
+ # src/setiastro/saspro/imageops/narrowband_normalization.py
2
+ from __future__ import annotations
3
+ import os
4
+ import threading
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
+ from dataclasses import dataclass
7
+ from typing import Callable, Optional, Tuple
8
+ import numpy as np
9
+ import traceback
10
+
11
+ ProgressCB = Optional[Callable[[int, str], None]]
12
+
13
+ # ---------------- params ----------------
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class NBNParams:
17
+ scenario: str # "HOO"/"SHO"/"HSO"/"HOS"
18
+ mode: int # 0 linear, 1 non-linear
19
+ lightness: int # HOO: 0..3, others: 0..4
20
+ blackpoint: float # 0..1
21
+ hlrecover: float # >= 0.25
22
+ hlreduct: float # >= 0.25
23
+ brightness: float # >= 0.25
24
+ blendmode: int = 0 # HOO only: 0/1/2
25
+ hablend: float = 0.6 # HOO only: 0..1
26
+ oiiiboost: float = 1.0 # HOO OIII boost
27
+ siiboost: float = 1.0 # SHO/HSO/HOS
28
+ oiiiboost2: float = 1.0 # SHO/HSO/HOS
29
+ scnr: bool = False # SHO/HSO/HOS
30
+
31
+
32
+ class MissingChannelsError(ValueError):
33
+ pass
34
+
35
+
36
+ __all__ = ["NBNParams", "MissingChannelsError", "normalize_narrowband"]
37
+
38
+
39
+ # ---------------- PixelMath primitives ----------------
40
+
41
+ _EPS = 1e-12
42
+
43
+
44
+ def _clip01(x: np.ndarray) -> np.ndarray:
45
+ return np.clip(x, 0.0, 1.0)
46
+
47
+
48
+ def _inv01(x: np.ndarray) -> np.ndarray:
49
+ """PixelMath '~' complement for normalized images."""
50
+ return 1.0 - x
51
+
52
+
53
+ def _rescale(x: np.ndarray, lo: float | np.ndarray, hi: float | np.ndarray) -> np.ndarray:
54
+ """Map x from [lo, hi] -> [0, 1] (clipped)."""
55
+ loa = np.asarray(lo, dtype=np.float32)
56
+ hia = np.asarray(hi, dtype=np.float32)
57
+ denom = np.maximum(hia - loa, _EPS)
58
+ return _clip01((x - loa) / denom)
59
+
60
+
61
+ def _mtf(m: float | np.ndarray, x: np.ndarray) -> np.ndarray:
62
+ """
63
+ PixInsight Midtones Transfer Function.
64
+ For m in (0,1): m is the midtone (pivot) value.
65
+ """
66
+ m = np.asarray(m, dtype=np.float32)
67
+ x = np.asarray(x, dtype=np.float32)
68
+ m = np.clip(m, _EPS, 1.0 - _EPS)
69
+ x = _clip01(x)
70
+
71
+ # y = (m - 1) * x / ((2*m - 1)*x - m)
72
+ num = (m - 1.0) * x
73
+ den = (2.0 * m - 1.0) * x - m
74
+
75
+ # IMPORTANT: never allow 0 denominator (np.sign(0) == 0). Use ±EPS.
76
+ safe_den = np.where(
77
+ np.abs(den) < _EPS,
78
+ np.where(den >= 0.0, _EPS, -_EPS).astype(np.float32),
79
+ den,
80
+ )
81
+ return _clip01(num / safe_den)
82
+
83
+
84
+ def _adev(x: np.ndarray) -> float:
85
+ """Approx absolute deviation. PixelMath adev()."""
86
+ med = np.nanmedian(x)
87
+ return float(np.nanmedian(np.abs(x - med)))
88
+
89
+
90
+ def _stats_min_med_mean(chs: Tuple[np.ndarray, ...]) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
91
+ """
92
+ Match PI per-channel stats behavior: compute min/median/mean for each channel.
93
+ Returns float32 vectors of shape (C,).
94
+ """
95
+ c = len(chs)
96
+ mins = np.empty((c,), dtype=np.float32)
97
+ meds = np.empty((c,), dtype=np.float32)
98
+ means = np.empty((c,), dtype=np.float32)
99
+ for i, ch in enumerate(chs):
100
+ mins[i] = float(np.nanmin(ch))
101
+ meds[i] = float(np.nanmedian(ch))
102
+ means[i] = float(np.nanmean(ch))
103
+ return mins, meds, means
104
+
105
+
106
+ def _stats_adev_vec(chs: Tuple[np.ndarray, ...]) -> np.ndarray:
107
+ v = np.empty((len(chs),), dtype=np.float32)
108
+ for i, ch in enumerate(chs):
109
+ v[i] = float(_adev(ch))
110
+ return v
111
+
112
+ def _default_workers() -> int:
113
+ # Don’t go crazy; too many workers can reduce perf due to memory bandwidth.
114
+ n = os.cpu_count() or 4
115
+ return max(1, min(32, n))
116
+
117
+
118
+ def _run_tiles_parallel(
119
+ tiles: list[tuple[int, int, int, int]],
120
+ worker_fn, # callable(y0,y1,x0,x1,ti)
121
+ *,
122
+ progress_cb: ProgressCB,
123
+ p0: int,
124
+ p1: int,
125
+ label: str,
126
+ max_workers: Optional[int] = None,
127
+ ) -> None:
128
+ """
129
+ Run per-tile worker_fn in parallel. worker_fn must write into shared output using non-overlapping slices.
130
+ """
131
+ ntiles = len(tiles)
132
+ if ntiles == 0:
133
+ return
134
+
135
+ workers = int(max_workers or _default_workers())
136
+ workers = max(1, min(workers, ntiles))
137
+
138
+ # Progress bookkeeping
139
+ done = 0
140
+ lock = threading.Lock()
141
+ last_emit = {"p": -1}
142
+
143
+ def _on_done():
144
+ nonlocal done
145
+ with lock:
146
+ done += 1
147
+ # Throttle: emit only when percent changes by >=1
148
+ if progress_cb:
149
+ p = _map_progress(done, ntiles, p0, p1)
150
+ if p != last_emit["p"]:
151
+ last_emit["p"] = p
152
+ progress_cb(p, f"{label} {done}/{ntiles}")
153
+
154
+ if progress_cb:
155
+ progress_cb(p0, f"{label} 0/{ntiles} (workers={workers})")
156
+
157
+ with ThreadPoolExecutor(max_workers=workers) as ex:
158
+ futs = []
159
+ for ti, (y0, y1, x0, x1) in enumerate(tiles):
160
+ futs.append(ex.submit(worker_fn, y0, y1, x0, x1, ti))
161
+
162
+ # Drain futures; propagate exceptions
163
+ for f in as_completed(futs):
164
+ f.result()
165
+ _on_done()
166
+
167
+ if progress_cb:
168
+ progress_cb(p1, f"{label} {ntiles}/{ntiles}")
169
+
170
+
171
+ def _iter_tiles(h: int, w: int, tile: int = 1024):
172
+ """Yield (y0,y1,x0,x1) tiles covering an HxW image."""
173
+ for y0 in range(0, h, tile):
174
+ y1 = min(y0 + tile, h)
175
+ for x0 in range(0, w, tile):
176
+ x1 = min(x0 + tile, w)
177
+ yield y0, y1, x0, x1
178
+
179
+
180
+ def _map_progress(i: int, n: int, p0: int, p1: int) -> int:
181
+ """Map tile index i in [0..n] to integer percent in [p0..p1]."""
182
+ if n <= 0:
183
+ return int(p1)
184
+ return int(p0 + (p1 - p0) * (i / n))
185
+
186
+ def _finish_tiled(out: np.ndarray, params: NBNParams, progress_cb: ProgressCB) -> np.ndarray:
187
+ h, w, _ = out.shape
188
+ tiles = list(_iter_tiles(h, w, tile=1024))
189
+
190
+ if progress_cb:
191
+ progress_cb(80, f"Finishing tiles 0/{len(tiles)}")
192
+
193
+ def _worker(y0, y1, x0, x1, ti):
194
+ out[y0:y1, x0:x1, :] = _apply_hl_reduction_and_brightness_and_recover(
195
+ out[y0:y1, x0:x1, :], params
196
+ )
197
+
198
+ _run_tiles_parallel(tiles, _worker, progress_cb=progress_cb, p0=80, p1=98, label="Finishing tiles")
199
+ return out
200
+
201
+ # ---------------- Color space helpers (as in script) ----------------
202
+
203
+ def _srgb_to_linear(u: np.ndarray) -> np.ndarray:
204
+ u = np.asarray(u, dtype=np.float32)
205
+ return np.where(u > 0.04045, ((u + 0.055) / 1.055) ** 2.4, u / 12.92)
206
+
207
+
208
+ def _linear_to_srgb(u: np.ndarray) -> np.ndarray:
209
+ u = np.asarray(u, dtype=np.float32)
210
+ # Gamma encoding is undefined for negatives; clamp them.
211
+ u = np.clip(u, 0.0, 1.0)
212
+ u = np.where(np.isfinite(u), u, 0.0)
213
+ u = np.maximum(u, 0.0)
214
+ return np.where(u > 0.0031308, 1.055 * (u ** (1.0 / 2.4)) - 0.055, 12.92 * u)
215
+
216
+
217
+ def _rgb_to_xyz_pi(r: np.ndarray, g: np.ndarray, b: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
218
+ # Matches coefficients in the PixelMath script (PI's D65-ish matrix used there)
219
+ r1 = _srgb_to_linear(_clip01(r))
220
+ g1 = _srgb_to_linear(_clip01(g))
221
+ b1 = _srgb_to_linear(_clip01(b))
222
+
223
+ X = (r1 * 0.4360747) + (g1 * 0.3850649) + (b1 * 0.1430804)
224
+ Y = (r1 * 0.2225045) + (g1 * 0.7168786) + (b1 * 0.0606169)
225
+ Z = (r1 * 0.0139322) + (g1 * 0.0971045) + (b1 * 0.7141733)
226
+ return X, Y, Z
227
+
228
+
229
+ def _xyz_to_lab_pi(X: np.ndarray, Y: np.ndarray, Z: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
230
+ # PixelMath uses the 0.008856 threshold and the affine segment
231
+ def f(t: np.ndarray) -> np.ndarray:
232
+ return np.where(t > 0.008856, t ** (1.0 / 3.0), (7.787 * t) + (16.0 / 116.0))
233
+
234
+ X1 = f(X)
235
+ Y1 = f(Y)
236
+ Z1 = f(Z)
237
+
238
+ L = 116.0 * Y1 - 16.0
239
+ a = 500.0 * (X1 - Y1)
240
+ b = 200.0 * (Y1 - Z1)
241
+ return L, a, b
242
+
243
+
244
+ def _xyz_to_rgb_pi(X: np.ndarray, Y: np.ndarray, Z: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
245
+ # Inverse matrix from script
246
+ R2 = (X * 3.1338561) + (Y * -1.6168667) + (Z * -0.4906146)
247
+ G2 = (X * -0.9787684) + (Y * 1.9161415) + (Z * 0.0334540)
248
+ B2 = (X * 0.0719453) + (Y * -0.2289914) + (Z * 1.4052427)
249
+
250
+ R3 = _linear_to_srgb(R2)
251
+ G3 = _linear_to_srgb(G2)
252
+ B3 = _linear_to_srgb(B2)
253
+ return _clip01(R3), _clip01(G3), _clip01(B3)
254
+
255
+
256
+ def _ciel_lightness_from_rgb(rgb: np.ndarray) -> np.ndarray:
257
+ r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
258
+ X, Y, Z = _rgb_to_xyz_pi(r, g, b)
259
+ L, _, _ = _xyz_to_lab_pi(X, Y, Z)
260
+ return L / 100.0 # normalized-ish 0..1
261
+
262
+
263
+ def _lab_lightness_replace(
264
+ R: np.ndarray,
265
+ G: np.ndarray,
266
+ B: np.ndarray,
267
+ Y2: np.ndarray,
268
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
269
+ """
270
+ Apply the script's Lab lightness replacement path:
271
+ - Convert RGB -> XYZ -> Lab
272
+ - Replace the Y-like term using caller-supplied Y2 (already in the script's (..+0.16)/1.16 space)
273
+ - Rebuild XYZ using a/b and Y2 exactly like the script (no extra normalization)
274
+ - Convert XYZ -> RGB
275
+ """
276
+ X, Y, Z = _rgb_to_xyz_pi(R, G, B)
277
+ L, a, b = _xyz_to_lab_pi(X, Y, Z)
278
+
279
+ # Script rebuild:
280
+ X2 = (a / 500.0) + Y2
281
+ Z2 = Y2 - (b / 200.0)
282
+
283
+ def finv(t: np.ndarray) -> np.ndarray:
284
+ return np.where(t > 0.008856, t ** 3, (t - 16.0 / 116.0) / 7.787)
285
+
286
+ X3 = finv(X2)
287
+ Y3 = finv(Y2)
288
+ Z3 = finv(Z2)
289
+ return _xyz_to_rgb_pi(X3, Y3, Z3)
290
+
291
+
292
+ # ---------------- Common finishing steps ----------------
293
+
294
+ def _apply_hl_reduction_and_brightness_and_recover(E10: np.ndarray, params: NBNParams) -> np.ndarray:
295
+ hlr = max(float(params.hlreduct), 0.25) # HLReduction (0.5..2.0 typical)
296
+ br = max(float(params.brightness), 0.25) # Brightness (0.5..2.0 typical)
297
+ hrec = max(float(params.hlrecover), 0.25) # HLRecover (0.5..2.0 typical)
298
+
299
+ # E11 = (mtf(~(1/HLReduction*.5),E10)*E10) + (E10*~E10);
300
+ # NOTE: 1/HLReduction*.5 means (1/HLReduction)*0.5
301
+ m_hlr = 1.0 - (0.5 / hlr) # ~(0.5/hlr)
302
+ m_hlr = float(np.clip(m_hlr, _EPS, 1.0 - _EPS))
303
+ E11 = (_mtf(m_hlr, E10) * E10) + (E10 * _inv01(E10))
304
+
305
+ # E12 = mtf((1/Brightness*.5),E11);
306
+ m_b = float(np.clip(0.5 / br, _EPS, 1.0 - _EPS))
307
+ E12 = _mtf(m_b, E11)
308
+
309
+ # E13 = rescale(E12,0,HLRecover);
310
+ E13 = _rescale(E12, 0.0, hrec)
311
+ return _clip01(E13)
312
+
313
+
314
+ # ---------------- Shared “core normalize” building blocks ----------------
315
+
316
+ def _compute_M_E0(chs: Tuple[np.ndarray, ...], blackpoint: float) -> Tuple[np.ndarray, np.ndarray]:
317
+ """
318
+ Implements:
319
+ M = min($T) + Blackpoint*(med($T)-min($T))
320
+ E0 = adev($T)/1.2533 + mean($T) - M
321
+ on a per-channel basis.
322
+ """
323
+ mins, meds, means = _stats_min_med_mean(chs)
324
+ M = mins + float(blackpoint) * (meds - mins)
325
+ adevs = _stats_adev_vec(chs)
326
+ E0 = (adevs / 1.2533) + means - M
327
+ return M.astype(np.float32), E0.astype(np.float32)
328
+
329
+
330
+ def _pm_norm_channel(
331
+ Tref: np.ndarray,
332
+ Ta: float,
333
+ Tb: float,
334
+ Mref: float,
335
+ E0: np.ndarray,
336
+ boost: float,
337
+ ) -> np.ndarray:
338
+ """
339
+ PixelMath pattern used repeatedly:
340
+ A = E0 / ~Mref
341
+ E = (A[ref]*(1-A[other])/(A[ref]-2*A[ref]*A[other] + A[other])) / boost
342
+ E2 = rescale(Tref, Mref, 1)
343
+ E3 = ~(~mtf(E, E2) * ~min(Tref, Mref))
344
+ Caller supplies indices/values already extracted as scalars Ta/Tb etc.
345
+ """
346
+ invM = max(float(_inv01(np.asarray(Mref, dtype=np.float32))), _EPS)
347
+ A = E0 / invM
348
+
349
+ denom = (Ta - 2.0 * Ta * Tb + Tb)
350
+ E = (Ta * (1.0 - Tb)) / max(float(denom), _EPS)
351
+ E = E / max(float(boost), _EPS)
352
+
353
+ E2 = _rescale(Tref, float(Mref), 1.0)
354
+ min_T_M = np.minimum(Tref, float(Mref))
355
+ E3 = _inv01(_inv01(_mtf(E, E2)) * _inv01(min_T_M))
356
+ return _clip01(E3)
357
+
358
+
359
+ # ---------------- Scenario cores ----------------
360
+
361
+ def _normalize_hoo(ha: np.ndarray, oiii: np.ndarray, params: NBNParams, progress_cb: ProgressCB) -> np.ndarray:
362
+ T0 = ha
363
+ T1 = oiii
364
+ T2 = oiii
365
+
366
+ if progress_cb:
367
+ progress_cb(12, "Computing global stats")
368
+ M, E0 = _compute_M_E0((T0, T1, T2), params.blackpoint)
369
+
370
+ # --- scalar E1 for OIII normalize ---
371
+ invM1 = max(float(_inv01(M[1])), _EPS)
372
+ A0 = E0 / invM1
373
+ Ta = float(A0[1])
374
+ Tb = float(A0[0])
375
+ denom = (Ta - 2.0 * Ta * Tb + Tb)
376
+ E1 = (Ta * (1.0 - Tb)) / max(float(denom), _EPS)
377
+ E1 = E1 / max(float(params.oiiiboost), _EPS)
378
+
379
+ hb = float(np.clip(params.hablend, 0.0, 1.0))
380
+ inv_hb = 1.0 - hb
381
+
382
+ # Prealloc output
383
+ h, w = T0.shape
384
+ out = np.empty((h, w, 3), dtype=np.float32)
385
+
386
+ tiles = list(_iter_tiles(h, w, tile=1024))
387
+
388
+ if progress_cb:
389
+ progress_cb(18, "Normalizing channels (tiled)")
390
+
391
+ def _tile_worker(y0, y1, x0, x1, ti):
392
+ t0 = T0[y0:y1, x0:x1]
393
+ t1 = T1[y0:y1, x0:x1]
394
+
395
+ # E3 for OIII
396
+ E2 = _rescale(t1, float(M[1]), 1.0)
397
+ min_t1_m1 = np.minimum(t1, float(M[1]))
398
+ E3 = _inv01(_inv01(_mtf(E1, E2)) * _inv01(min_t1_m1))
399
+ E3 = _clip01(E3)
400
+
401
+ # Blend E4
402
+ if params.blendmode == 0:
403
+ E4 = (t0 * hb) + (E3 * inv_hb)
404
+ elif params.blendmode == 1:
405
+ E4 = (E3 * hb) + (t1 * inv_hb)
406
+ else:
407
+ E4 = (t0 * hb) + (t1 * inv_hb)
408
+
409
+ R = t0
410
+ G = E4
411
+ B = E3
412
+
413
+ if params.mode == 0:
414
+ out[y0:y1, x0:x1, 0] = R
415
+ out[y0:y1, x0:x1, 1] = G
416
+ out[y0:y1, x0:x1, 2] = B
417
+ else:
418
+ if params.lightness == 0:
419
+ X, Y, Z = _rgb_to_xyz_pi(R, G, B)
420
+ L, _, _ = _xyz_to_lab_pi(X, Y, Z)
421
+ Y2 = (L + 16.0) / 116.0
422
+ elif params.lightness == 1:
423
+ rgbT = np.stack([t0, t1, t1], axis=-1)
424
+ ciel = _ciel_lightness_from_rgb(rgbT)
425
+ Y2 = (ciel + 0.16) / 1.16
426
+ elif params.lightness == 2:
427
+ Y2 = (t0 + 0.16) / 1.16
428
+ else:
429
+ Y2 = (t1 + 0.16) / 1.16
430
+
431
+ r3, g3, b3 = _lab_lightness_replace(R, G, B, Y2.astype(np.float32))
432
+ out[y0:y1, x0:x1, 0] = r3
433
+ out[y0:y1, x0:x1, 1] = g3
434
+ out[y0:y1, x0:x1, 2] = b3
435
+
436
+ _run_tiles_parallel(
437
+ tiles,
438
+ _tile_worker,
439
+ progress_cb=progress_cb,
440
+ p0=18,
441
+ p1=75,
442
+ label="Processing tiles",
443
+ )
444
+
445
+
446
+ if progress_cb:
447
+ progress_cb(80, "Finishing (HL reduction / brightness / recover)")
448
+
449
+ # Finish as a single pass (still vectorized), or tile if you want even more granular updates
450
+ out = _finish_tiled(out, params, progress_cb)
451
+ return out
452
+
453
+ def _normalize_sho(ha: np.ndarray, oiii: np.ndarray, sii: np.ndarray, params: NBNParams, progress_cb: ProgressCB) -> np.ndarray:
454
+ # $T[0]=SII (R), $T[1]=Ha (G), $T[2]=OIII (B)
455
+ T0 = sii
456
+ T1 = ha
457
+ T2 = oiii
458
+
459
+ if progress_cb:
460
+ progress_cb(12, "Computing global stats")
461
+ M, E0 = _compute_M_E0((T0, T1, T2), params.blackpoint)
462
+
463
+ # --- scalar params for SII normalize ---
464
+ invM0 = max(float(_inv01(M[0])), _EPS)
465
+ A = E0 / invM0
466
+ Ta_sii = float(A[0])
467
+ Tb_sii = float(A[1])
468
+ denom = (Ta_sii - 2.0 * Ta_sii * Tb_sii + Tb_sii)
469
+ E1_sii = (Ta_sii * (1.0 - Tb_sii)) / max(float(denom), _EPS)
470
+ E1_sii = E1_sii / max(float(params.siiboost), _EPS)
471
+
472
+ # --- scalar params for OIII normalize ---
473
+ invM2 = max(float(_inv01(M[2])), _EPS)
474
+ A = E0 / invM2
475
+ Ta_oiii = float(A[2])
476
+ Tb_oiii = float(A[1])
477
+ denom = (Ta_oiii - 2.0 * Ta_oiii * Tb_oiii + Tb_oiii)
478
+ E1_oiii = (Ta_oiii * (1.0 - Tb_oiii)) / max(float(denom), _EPS)
479
+ E1_oiii = E1_oiii / max(float(params.oiiiboost2), _EPS)
480
+
481
+ h, w = T0.shape
482
+ out = np.empty((h, w, 3), dtype=np.float32)
483
+
484
+ tiles = list(_iter_tiles(h, w, tile=1024))
485
+
486
+ if progress_cb:
487
+ progress_cb(18, "Normalizing channels (tiled)")
488
+
489
+ def _tile_worker(y0, y1, x0, x1, ti):
490
+ t0 = T0[y0:y1, x0:x1] # SII
491
+ t1 = T1[y0:y1, x0:x1] # Ha
492
+ t2 = T2[y0:y1, x0:x1] # OIII
493
+
494
+ # SII -> E3
495
+ E2 = _rescale(t0, float(M[0]), 1.0)
496
+ min_t0_m0 = np.minimum(t0, float(M[0]))
497
+ E3 = _inv01(_inv01(_mtf(E1_sii, E2)) * _inv01(min_t0_m0))
498
+ E3 = _clip01(E3)
499
+
500
+ # OIII -> E6
501
+ E5 = _rescale(t2, float(M[2]), 1.0)
502
+ min_t2_m2 = np.minimum(t2, float(M[2]))
503
+ E6 = _inv01(_inv01(_mtf(E1_oiii, E5)) * _inv01(min_t2_m2))
504
+ E6 = _clip01(E6)
505
+
506
+ R = E3
507
+ if not params.scnr:
508
+ G = t1
509
+ else:
510
+ G = np.minimum((R + E6) * 0.5, t1)
511
+ B = E6
512
+
513
+ if params.mode == 0:
514
+ out[y0:y1, x0:x1, 0] = R
515
+ out[y0:y1, x0:x1, 1] = G
516
+ out[y0:y1, x0:x1, 2] = B
517
+ else:
518
+ if params.lightness == 0:
519
+ X, Y, Z = _rgb_to_xyz_pi(R, G, B)
520
+ L, _, _ = _xyz_to_lab_pi(X, Y, Z)
521
+ Y2 = (L + 16.0) / 116.0
522
+ elif params.lightness == 1:
523
+ rgbT = np.stack([t0, t1, t2], axis=-1)
524
+ ciel = _ciel_lightness_from_rgb(rgbT)
525
+ Y2 = (ciel + 0.16) / 1.16
526
+ elif params.lightness == 2:
527
+ Y2 = (t1 + 0.16) / 1.16 # Ha
528
+ elif params.lightness == 3:
529
+ Y2 = (t0 + 0.16) / 1.16 # SII
530
+ else:
531
+ Y2 = (t2 + 0.16) / 1.16 # OIII
532
+
533
+ r3, g3, b3 = _lab_lightness_replace(R, G, B, Y2.astype(np.float32))
534
+ out[y0:y1, x0:x1, 0] = r3
535
+ out[y0:y1, x0:x1, 1] = g3
536
+ out[y0:y1, x0:x1, 2] = b3
537
+
538
+ _run_tiles_parallel(
539
+ tiles,
540
+ _tile_worker,
541
+ progress_cb=progress_cb,
542
+ p0=18,
543
+ p1=75,
544
+ label="Processing tiles",
545
+ )
546
+
547
+
548
+ if progress_cb:
549
+ progress_cb(80, "Finishing (HL reduction / brightness / recover)")
550
+ out = _finish_tiled(out, params, progress_cb)
551
+ return out
552
+
553
+ def _normalize_hso(ha: np.ndarray, oiii: np.ndarray, sii: np.ndarray, params: NBNParams, progress_cb: ProgressCB) -> np.ndarray:
554
+ # $T[0]=Ha, $T[1]=SII, $T[2]=OIII
555
+ T0 = ha
556
+ T1 = sii
557
+ T2 = oiii
558
+
559
+ if progress_cb:
560
+ progress_cb(12, "Computing global stats")
561
+ M, E0 = _compute_M_E0((T0, T1, T2), params.blackpoint)
562
+
563
+ # scalar for SII normalize (uses M[1], A[1] vs A[0])
564
+ invM1 = max(float(_inv01(M[1])), _EPS)
565
+ A = E0 / invM1
566
+ Ta_sii = float(A[1])
567
+ Tb_sii = float(A[0])
568
+ denom = (Ta_sii - 2.0 * Ta_sii * Tb_sii + Tb_sii)
569
+ E1_sii = (Ta_sii * (1.0 - Tb_sii)) / max(float(denom), _EPS)
570
+ E1_sii = E1_sii / max(float(params.siiboost), _EPS)
571
+
572
+ # scalar for OIII normalize (uses M[2], A[2] vs A[0])
573
+ invM2 = max(float(_inv01(M[2])), _EPS)
574
+ A = E0 / invM2
575
+ Ta_oiii = float(A[2])
576
+ Tb_oiii = float(A[0])
577
+ denom = (Ta_oiii - 2.0 * Ta_oiii * Tb_oiii + Tb_oiii)
578
+ E1_oiii = (Ta_oiii * (1.0 - Tb_oiii)) / max(float(denom), _EPS)
579
+ E1_oiii = E1_oiii / max(float(params.oiiiboost2), _EPS)
580
+
581
+ h, w = T0.shape
582
+ out = np.empty((h, w, 3), dtype=np.float32)
583
+ tiles = list(_iter_tiles(h, w, tile=1024))
584
+
585
+ if progress_cb:
586
+ progress_cb(18, "Normalizing channels (tiled)")
587
+
588
+ def _tile_worker(y0, y1, x0, x1, ti):
589
+ t0 = T0[y0:y1, x0:x1] # Ha
590
+ t1 = T1[y0:y1, x0:x1] # SII
591
+ t2 = T2[y0:y1, x0:x1] # OIII
592
+
593
+ # SII -> E3 (HSO uses T1 and M[1])
594
+ E2 = _rescale(t1, float(M[1]), 1.0)
595
+ min_t1_m1 = np.minimum(t1, float(M[1]))
596
+ E3 = _inv01(_inv01(_mtf(E1_sii, E2)) * _inv01(min_t1_m1))
597
+ E3 = _clip01(E3)
598
+ # OIII -> E6
599
+ E5 = _rescale(t2, float(M[2]), 1.0)
600
+ min_t2_m2 = np.minimum(t2, float(M[2]))
601
+ E6 = _inv01(_inv01(_mtf(E1_oiii, E5)) * _inv01(min_t2_m2))
602
+ E6 = _clip01(E6)
603
+
604
+ R = t0
605
+ if not params.scnr:
606
+ G = E3
607
+ else:
608
+ G = np.minimum((R + E6) * 0.5, E3)
609
+ B = E6
610
+
611
+ if params.mode == 0:
612
+ out[y0:y1, x0:x1, 0] = R
613
+ out[y0:y1, x0:x1, 1] = G
614
+ out[y0:y1, x0:x1, 2] = B
615
+ else:
616
+ if params.lightness == 0:
617
+ X, Y, Z = _rgb_to_xyz_pi(R, G, B)
618
+ L, _, _ = _xyz_to_lab_pi(X, Y, Z)
619
+ Y2 = (L + 16.0) / 116.0
620
+ elif params.lightness == 1:
621
+ rgbT = np.stack([t0, t1, t2], axis=-1)
622
+ ciel = _ciel_lightness_from_rgb(rgbT)
623
+ Y2 = (ciel + 0.16) / 1.16
624
+ elif params.lightness == 2:
625
+ Y2 = (t1 + 0.16) / 1.16 # Ha
626
+ elif params.lightness == 3:
627
+ Y2 = (t0 + 0.16) / 1.16 # SII
628
+ else:
629
+ Y2 = (t2 + 0.16) / 1.16 # OIII
630
+
631
+ r3, g3, b3 = _lab_lightness_replace(R, G, B, Y2.astype(np.float32))
632
+ out[y0:y1, x0:x1, 0] = r3
633
+ out[y0:y1, x0:x1, 1] = g3
634
+ out[y0:y1, x0:x1, 2] = b3
635
+
636
+ _run_tiles_parallel(
637
+ tiles,
638
+ _tile_worker,
639
+ progress_cb=progress_cb,
640
+ p0=18,
641
+ p1=75,
642
+ label="Processing tiles",
643
+ )
644
+
645
+
646
+ if progress_cb:
647
+ progress_cb(80, "Finishing (HL reduction / brightness / recover)")
648
+ out = _finish_tiled(out, params, progress_cb)
649
+ return out
650
+
651
+
652
+ def _normalize_hos(ha: np.ndarray, oiii: np.ndarray, sii: np.ndarray, params: NBNParams, progress_cb: ProgressCB) -> np.ndarray:
653
+ # $T[0]=Ha, $T[1]=OIII, $T[2]=SII
654
+ T0 = ha
655
+ T1 = oiii
656
+ T2 = sii
657
+
658
+ if progress_cb:
659
+ progress_cb(12, "Computing global stats")
660
+ M, E0 = _compute_M_E0((T0, T1, T2), params.blackpoint)
661
+
662
+ # scalar for OIII normalize (uses M[1], A[1] vs A[0])
663
+ invM1 = max(float(_inv01(M[1])), _EPS)
664
+ A = E0 / invM1
665
+ Ta_oiii = float(A[1])
666
+ Tb_oiii = float(A[0])
667
+ denom = (Ta_oiii - 2.0 * Ta_oiii * Tb_oiii + Tb_oiii)
668
+ E1_oiii = (Ta_oiii * (1.0 - Tb_oiii)) / max(float(denom), _EPS)
669
+ E1_oiii = E1_oiii / max(float(params.oiiiboost2), _EPS)
670
+
671
+ # scalar for SII normalize (uses M[2], A[2] vs A[0])
672
+ invM2 = max(float(_inv01(M[2])), _EPS)
673
+ A = E0 / invM2
674
+ Ta_sii = float(A[2])
675
+ Tb_sii = float(A[0])
676
+ denom = (Ta_sii - 2.0 * Ta_sii * Tb_sii + Tb_sii)
677
+ E1_sii = (Ta_sii * (1.0 - Tb_sii)) / max(float(denom), _EPS)
678
+ E1_sii = E1_sii / max(float(params.siiboost), _EPS)
679
+
680
+ h, w = T0.shape
681
+ out = np.empty((h, w, 3), dtype=np.float32)
682
+
683
+ tiles = list(_iter_tiles(h, w, tile=1024))
684
+
685
+ if progress_cb:
686
+ progress_cb(18, "Normalizing channels (tiled)")
687
+
688
+ def _tile_worker(y0, y1, x0, x1, ti):
689
+ t0 = T0[y0:y1, x0:x1] # Ha
690
+ t1 = T1[y0:y1, x0:x1] # OIII
691
+ t2 = T2[y0:y1, x0:x1] # SII
692
+
693
+ # OIII -> E3 (uses t1 and M[1])
694
+ E2 = _rescale(t1, float(M[1]), 1.0)
695
+ min_t1_m1 = np.minimum(t1, float(M[1]))
696
+ E3 = _inv01(_inv01(_mtf(E1_oiii, E2)) * _inv01(min_t1_m1))
697
+ E3 = _clip01(E3)
698
+
699
+ # SII -> E6 (uses t2 and M[2])
700
+ E5 = _rescale(t2, float(M[2]), 1.0)
701
+ min_t2_m2 = np.minimum(t2, float(M[2]))
702
+ E6 = _inv01(_inv01(_mtf(E1_sii, E5)) * _inv01(min_t2_m2))
703
+ E6 = _clip01(E6)
704
+
705
+ R = t0
706
+ if not params.scnr:
707
+ G = E3
708
+ else:
709
+ G = np.minimum((R + E6) * 0.5, E3)
710
+ B = E6
711
+
712
+ if params.mode == 0:
713
+ out[y0:y1, x0:x1, 0] = R
714
+ out[y0:y1, x0:x1, 1] = G
715
+ out[y0:y1, x0:x1, 2] = B
716
+ else:
717
+ if params.lightness == 0:
718
+ X, Y, Z = _rgb_to_xyz_pi(R, G, B)
719
+ L, _, _ = _xyz_to_lab_pi(X, Y, Z)
720
+ Y2 = (L + 16.0) / 116.0
721
+ elif params.lightness == 1:
722
+ rgbT = np.stack([t0, t1, t2], axis=-1)
723
+ ciel = _ciel_lightness_from_rgb(rgbT)
724
+ Y2 = (ciel + 0.16) / 1.16
725
+ elif params.lightness == 2:
726
+ Y2 = (t1 + 0.16) / 1.16 # Ha
727
+ elif params.lightness == 3:
728
+ Y2 = (t0 + 0.16) / 1.16 # SII
729
+ else:
730
+ Y2 = (t2 + 0.16) / 1.16 # OIII
731
+
732
+ r3, g3, b3 = _lab_lightness_replace(R, G, B, Y2.astype(np.float32))
733
+ out[y0:y1, x0:x1, 0] = r3
734
+ out[y0:y1, x0:x1, 1] = g3
735
+ out[y0:y1, x0:x1, 2] = b3
736
+
737
+ _run_tiles_parallel(
738
+ tiles,
739
+ _tile_worker,
740
+ progress_cb=progress_cb,
741
+ p0=18,
742
+ p1=75,
743
+ label="Processing tiles",
744
+ )
745
+
746
+
747
+ if progress_cb:
748
+ progress_cb(80, "Finishing (HL reduction / brightness / recover)")
749
+ out = _finish_tiled(out, params, progress_cb)
750
+ return out
751
+
752
+
753
+ def normalize_narrowband(
754
+ ha: np.ndarray | None,
755
+ oiii: np.ndarray | None,
756
+ sii: np.ndarray | None,
757
+ params: NBNParams,
758
+ *,
759
+ progress_cb: ProgressCB = None,
760
+ ) -> np.ndarray:
761
+ """
762
+ Entry point used by the UI/worker. Dispatches to the correct scenario core.
763
+
764
+ Inputs are expected to be mono float32 arrays in [0..1] (or at least clipped-ish).
765
+ Returns RGB float32 [0..1].
766
+ """
767
+ scen = (params.scenario or "").split()[0].strip().upper()
768
+
769
+ # small helper so we can always give sane progress ranges
770
+ def cb(p: int, msg: str = ""):
771
+ if progress_cb:
772
+ progress_cb(int(max(0, min(100, p))), msg)
773
+
774
+ cb(0, f"Starting {scen}")
775
+
776
+ # Validate requirements
777
+ if scen == "HOO":
778
+ if ha is None or oiii is None:
779
+ raise MissingChannelsError("HOO requires Ha and OIII.")
780
+ # sii ignored for HOO
781
+ cb(5, "Dispatching HOO")
782
+ out = _normalize_hoo(
783
+ ha.astype(np.float32, copy=False),
784
+ oiii.astype(np.float32, copy=False),
785
+ params,
786
+ cb,
787
+ )
788
+ cb(100, "Done")
789
+ return _clip01(out).astype(np.float32, copy=False)
790
+
791
+ if scen in ("SHO", "HSO", "HOS"):
792
+ missing = []
793
+ if ha is None: missing.append("Ha")
794
+ if oiii is None: missing.append("OIII")
795
+ if sii is None: missing.append("SII")
796
+ if missing:
797
+ raise MissingChannelsError(f"{scen} requires " + ", ".join(missing) + ".")
798
+
799
+ ha = ha.astype(np.float32, copy=False)
800
+ oiii = oiii.astype(np.float32, copy=False)
801
+ sii = sii.astype(np.float32, copy=False)
802
+
803
+ cb(5, f"Dispatching {scen}")
804
+
805
+ if scen == "SHO":
806
+ out = _normalize_sho(ha, oiii, sii, params, cb)
807
+ elif scen == "HSO":
808
+ out = _normalize_hso(ha, oiii, sii, params, cb)
809
+ else: # "HOS"
810
+ out = _normalize_hos(ha, oiii, sii, params, cb)
811
+
812
+ cb(100, "Done")
813
+ return _clip01(out).astype(np.float32, copy=False)
814
+
815
+ # Unknown scenario
816
+ raise ValueError(f"Unknown narrowband normalization scenario: {params.scenario!r}")