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
@@ -1,3 +1,4 @@
1
+ #src/setiastro/saspro/luminancerecombine.py
1
2
  from __future__ import annotations
2
3
  import numpy as np
3
4
  import cv2
@@ -13,12 +14,184 @@ from setiastro.saspro.widgets.image_utils import (
13
14
  to_float01_strict as _to_float01_strict,
14
15
  )
15
16
 
16
- # Linear luma weights
17
17
  _LUMA_REC709 = np.array([0.2126, 0.7152, 0.0722], dtype=np.float32)
18
18
  _LUMA_REC601 = np.array([0.2990, 0.5870, 0.1140], dtype=np.float32)
19
19
  _LUMA_REC2020 = np.array([0.2627, 0.6780, 0.0593], dtype=np.float32)
20
20
 
21
+ # ---- Luma profiles (UI selectable) ----
22
+ # Key = what the UI stores in self.luma_method / preset["mode"]
23
+ # weights must be length-3 (RGB), assumed linear
24
+ LUMA_PROFILES: dict[str, dict] = {
25
+ # --- Standard ---
26
+ "rec709": {"method": "rec709", "weights": _LUMA_REC709, "category": "Standard", "description": "Broadband RGB (Rec.709)"},
27
+ "rec601": {"method": "rec601", "weights": _LUMA_REC601, "category": "Standard", "description": "Rec.601"},
28
+ "rec2020": {"method": "rec2020", "weights": _LUMA_REC2020, "category": "Standard", "description": "Rec.2020"},
29
+ "equal": {"method": "equal", "weights": None, "category": "Standard", "description": "Equal RGB"},
30
+ "max": {"method": "max", "weights": None, "category": "Standard", "description": "Max (Narrowband mappings)"},
31
+ "median": {"method": "median", "weights": None, "category": "Standard", "description": "Median RGB"},
32
+ "snr": {"method": "snr", "weights": None, "category": "Standard", "description": "Unequal Noise (SNR)"},
33
+
34
+ # --- Sensors (examples — paste your whole list here) ---
35
+ "sensor:Sony IMX571 (ASI2600/QHY268)": {
36
+ "method": "custom",
37
+ "weights": np.array([0.2944, 0.5021, 0.2035], dtype=np.float32),
38
+ "category": "Sensors/Sony Modern BSI",
39
+ "description": "Sony IMX571 26MP APS-C BSI (STARVIS)",
40
+ "info": "Gold standard APS-C. Excellent balance for broadband.",
41
+ },
42
+ "sensor:Sony IMX533 (ASI533)": {
43
+ "method": "custom",
44
+ "weights": np.array([0.2910, 0.5072, 0.2018], dtype=np.float32),
45
+ "category": "Sensors/Sony Modern BSI",
46
+ "description": "Sony IMX533 9MP 1\" Square BSI (STARVIS)",
47
+ "info": "Popular square format. Very low noise.",
48
+ },
49
+ "sensor:Sony IMX455 (ASI6200/QHY600)": {
50
+ "weights": (0.2987, 0.5001, 0.2013),
51
+ "description": "Sony IMX455 61MP Full Frame BSI (STARVIS)",
52
+ "info": "Full frame reference sensor.",
53
+ "category": "Sony / Modern BSI",
54
+ },
55
+ "sensor:Sony IMX294 (ASI294)": {
56
+ "weights": (0.3068, 0.5008, 0.1925),
57
+ "description": "Sony IMX294 11.7MP 4/3\" BSI",
58
+ "info": "High sensitivity 4/3 format.",
59
+ "category": "Sony / Modern BSI",
60
+ },
61
+ "sensor:Sony IMX183 (ASI183)": {
62
+ "weights": (0.2967, 0.4983, 0.2050),
63
+ "description": "Sony IMX183 20MP 1\" BSI",
64
+ "info": "High resolution 1-inch sensor.",
65
+ "category": "Sony / Modern BSI",
66
+ },
67
+ "sensor:Sony IMX178 (ASI178)": {
68
+ "weights": (0.2346, 0.5206, 0.2448),
69
+ "description": "Sony IMX178 6.4MP 1/1.8\" BSI",
70
+ "info": "High resolution entry-level sensor.",
71
+ "category": "Sony / Modern BSI",
72
+ },
73
+ "sensor:Sony IMX224 (ASI224)": {
74
+ "weights": (0.3402, 0.4765, 0.1833),
75
+ "description": "Sony IMX224 1.27MP 1/3\" BSI",
76
+ "info": "Classic planetary sensor. High Red response.",
77
+ "category": "Sony / Modern BSI",
78
+ },
79
+
80
+ # --- SONY STARVIS 2 (NIR Optimized) ---
81
+ "sensor:Sony IMX585 (ASI585) - STARVIS 2": {
82
+ "weights": (0.3431, 0.4822, 0.1747),
83
+ "description": "Sony IMX585 8.3MP 1/1.2\" BSI (STARVIS 2)",
84
+ "info": "NIR optimized. Excellent for H-Alpha/Narrowband.",
85
+ "category": "Sony / STARVIS 2",
86
+ },
87
+ "sensor:Sony IMX662 (ASI662) - STARVIS 2": {
88
+ "weights": (0.3430, 0.4821, 0.1749),
89
+ "description": "Sony IMX662 2.1MP 1/2.8\" BSI (STARVIS 2)",
90
+ "info": "Planetary/Guiding. High Red/NIR sensitivity.",
91
+ "category": "Sony / STARVIS 2",
92
+ },
93
+ "sensor:Sony IMX678/715 - STARVIS 2": {
94
+ "weights": (0.3426, 0.4825, 0.1750),
95
+ "description": "Sony IMX678/715 BSI (STARVIS 2)",
96
+ "info": "High resolution planetary/security sensors.",
97
+ "category": "Sony / STARVIS 2",
98
+ },
99
+
100
+ # --- PANASONIC / OTHERS ---
101
+ "sensor:Panasonic MN34230 (ASI1600/QHY163)": {
102
+ "weights": (0.2650, 0.5250, 0.2100),
103
+ "description": "Panasonic MN34230 4/3\" CMOS",
104
+ "info": "Classic Mono/OSC sensor. Optimized weights.",
105
+ "category": "Panasonic",
106
+ },
107
+
108
+ # --- CANON DSLR (Averaged Profiles) ---
109
+ "sensor:Canon EOS (Modern - 60D/6D/R)": {
110
+ "weights": (0.2550, 0.5250, 0.2200),
111
+ "description": "Canon CMOS Profile (Modern)",
112
+ "info": "Balanced profile for most Canon EOS cameras (60D, 6D, 5D, R-series).",
113
+ "category": "Canon",
114
+ },
115
+ "sensor:Canon EOS (Legacy - 300D/40D)": {
116
+ "weights": (0.2400, 0.5400, 0.2200),
117
+ "description": "Canon CMOS Profile (Legacy)",
118
+ "info": "For older Canon models (Digic 2/3 era).",
119
+ "category": "Canon",
120
+ },
121
+
122
+ # --- NIKON DSLR (Averaged Profiles) ---
123
+ "sensor:Nikon DSLR (Modern - D5300/D850)": {
124
+ "weights": (0.2600, 0.5100, 0.2300),
125
+ "description": "Nikon CMOS Profile (Modern)",
126
+ "info": "Balanced profile for Nikon Expeed 4+ cameras.",
127
+ "category": "Nikon",
128
+ },
129
+
130
+ # --- SMART TELESCOPES ---
131
+ "sensor:ZWO Seestar S50": {
132
+ "weights": (0.3333, 0.4866, 0.1801),
133
+ "description": "ZWO Seestar S50 (IMX462)",
134
+ "info": "Specific profile for Seestar S50 smart telescope.",
135
+ "category": "Smart Telescopes",
136
+ },
137
+ "sensor:ZWO Seestar S30": {
138
+ "weights": (0.2928, 0.5053, 0.2019),
139
+ "description": "ZWO Seestar S30",
140
+ "info": "Specific profile for Seestar S30 smart telescope.",
141
+ "category": "Smart Telescopes",
142
+ },
143
+ }
144
+
145
+
21
146
  # ---------- helpers ----------
147
+ def resolve_luma_profile_weights(mode: str | None):
148
+ """
149
+ Returns (resolved_method, weights_or_None, profile_name_or_None)
150
+
151
+ - Standard modes return (mode, None or standard weights, None)
152
+ - Sensor profiles return ("custom", weights, <profile display name>)
153
+ """
154
+ if mode is None:
155
+ mode = "rec709"
156
+ key = str(mode).strip()
157
+
158
+ # common aliases
159
+ alias = {
160
+ "rec.709": "rec709",
161
+ "rec-709": "rec709",
162
+ "rgb": "rec709",
163
+ "k": "rec709",
164
+ "rec.601": "rec601",
165
+ "rec-601": "rec601",
166
+ "rec.2020": "rec2020",
167
+ "rec-2020": "rec2020",
168
+ "nb_max": "max",
169
+ "narrowband": "max",
170
+ "snr_unequal": "snr",
171
+ "unequal_noise": "snr",
172
+ }
173
+ key = alias.get(key.lower(), key)
174
+
175
+ prof = LUMA_PROFILES.get(key)
176
+ if not prof:
177
+ # fallback
178
+ return ("rec709", _LUMA_REC709, None)
179
+
180
+ method = str(prof.get("method", "rec709")).strip().lower()
181
+ w = prof.get("weights", None)
182
+ if w is not None:
183
+ w = np.asarray(w, dtype=np.float32)
184
+
185
+ if key.startswith("sensor:"):
186
+ # Use "custom" path in compute_luminance by passing weights
187
+ # We'll return resolved_method="rec709" (ignored) and weights=w
188
+ # BUT to keep your API simple: return ("rec709", w, profile_name)
189
+ profile_name = key.split("sensor:", 1)[1].strip()
190
+ return ("rec709", w, profile_name)
191
+
192
+ # Standard modes
193
+ return (key, w, None)
194
+
22
195
 
23
196
  def _estimate_noise_sigma_per_channel(img01: np.ndarray) -> np.ndarray:
24
197
  # unchanged (but call with strict input)
@@ -83,6 +256,10 @@ def compute_luminance(
83
256
  lum = f.max(axis=2)
84
257
  elif method == "median":
85
258
  lum = np.median(f, axis=2)
259
+ elif method == "rec601":
260
+ lum = np.tensordot(f[..., :3], _LUMA_REC601, axes=([2],[0]))
261
+ elif method == "rec2020":
262
+ lum = np.tensordot(f[..., :3], _LUMA_REC2020, axes=([2],[0]))
86
263
  else: # default rec709
87
264
  lum = np.tensordot(f[..., :3], _LUMA_REC709, axes=([2],[0]))
88
265
 
@@ -159,42 +336,66 @@ def apply_recombine_to_doc(
159
336
  """
160
337
  base = _to_float01_strict(np.asarray(target_doc.image))
161
338
 
162
- # Decide weights for both compute+recombine
163
- if method == "rec601":
164
- w = _LUMA_REC601
165
- elif method == "rec2020":
166
- w = _LUMA_REC2020
167
- elif weights is not None:
168
- w = np.asarray(weights, dtype=np.float32)
339
+ # Resolve profile (sensor profiles return weights w)
340
+ resolved_method, w, profile_name = resolve_luma_profile_weights(method)
341
+
342
+ # Caller override for weights wins (useful for custom UI / scripts)
343
+ if weights is not None:
344
+ w = np.asarray(weights, dtype=np.float32).reshape(-1)
169
345
  if w.size != 3:
170
- raise ValueError("Custom weights must be length-3.")
171
- else:
172
- w = _LUMA_REC709
346
+ raise ValueError("weights must be a 3-element RGB vector")
347
+ elif w is not None:
348
+ w = np.asarray(w, dtype=np.float32).reshape(-1)
349
+ if w.size != 3:
350
+ w = None # ignore bad profile weights defensively
173
351
 
174
352
  # Build L (mono source passes through; RGB is weighted)
175
353
  src = _to_float01_strict(luminance_source_img)
176
354
  if src.ndim == 2 or (src.ndim == 3 and src.shape[2] == 1):
177
355
  L = src if src.ndim == 2 else src[..., 0]
356
+ # For mono L sources, we still want recombine weights to match the selected method/profile.
178
357
  else:
358
+ # Noise sigma: if caller provided, use it; otherwise estimate when needed
179
359
  ns = None
180
- if method == "snr":
181
- ns = _estimate_noise_sigma_per_channel(src)
182
- L = compute_luminance(src, method=method, weights=w if weights is not None else None, noise_sigma=ns)
183
-
184
- replaced = recombine_luminance_linear_scale(base, L, weights=w, blend=blend, highlight_soft_knee=soft_knee)
185
-
186
- # destination-mask blend if active
187
- m = _active_mask_array_from_doc(target_doc)
188
- if m is not None:
189
- m3 = np.repeat(m[..., None], 3, axis=2).astype(np.float32)
190
- replaced = base * (1.0 - m3) + replaced * m3
191
-
192
- target_doc.apply_edit(
193
- replaced,
194
- metadata={"step_name": "Recombine Luminance", "luma_method": method, "luma_weights": w.tolist()},
195
- step_name="Recombine Luminance",
360
+ if resolved_method == "snr":
361
+ if noise_sigma is not None:
362
+ ns = np.asarray(noise_sigma, dtype=np.float32).reshape(-1)
363
+ else:
364
+ ns = _estimate_noise_sigma_per_channel(src)
365
+
366
+ # compute_luminance respects weights override; for sensor/custom profiles w is used
367
+ L = compute_luminance(src, method=resolved_method, weights=w, noise_sigma=ns)
368
+
369
+ # For scaling recombine, we need an actual RGB weight vector.
370
+ # If we don't have one from the chosen mode/profile, fall back sensibly.
371
+ if w is not None and w.size == 3:
372
+ recombine_w = w
373
+ else:
374
+ # If your resolver returns w=None for rec709/rec601/rec2020, fill explicitly here:
375
+ if resolved_method == "rec601":
376
+ recombine_w = _LUMA_REC601
377
+ elif resolved_method == "rec2020":
378
+ recombine_w = _LUMA_REC2020
379
+ else:
380
+ recombine_w = _LUMA_REC709
381
+
382
+ replaced = recombine_luminance_linear_scale(
383
+ base,
384
+ L,
385
+ weights=recombine_w,
386
+ blend=float(blend),
387
+ highlight_soft_knee=float(soft_knee),
196
388
  )
197
389
 
390
+ # Metadata
391
+ md = {"step_name": "Recombine Luminance", "luma_method": resolved_method}
392
+ if profile_name:
393
+ md["luma_profile"] = profile_name
394
+ if w is not None:
395
+ md["luma_weights"] = np.asarray(w, dtype=np.float32).tolist()
396
+
397
+ target_doc.apply_edit(replaced.astype(np.float32, copy=False), metadata=md, step_name="Recombine Luminance")
398
+
198
399
 
199
400
  def run_recombine_luminance_via_preset(main_or_ctx, preset=None, target_doc=None):
200
401
  """
@@ -17,7 +17,7 @@ except Exception:
17
17
  from PyQt6.QtCore import Qt, QPointF, QRectF, QTimer, QEvent
18
18
  from PyQt6.QtGui import (
19
19
  QImage, QPixmap, QPainter, QColor, QPen, QBrush,
20
- QPainterPath, QWheelEvent, QPolygonF
20
+ QPainterPath, QWheelEvent, QPolygonF, QMouseEvent
21
21
  )
22
22
  from PyQt6.QtWidgets import (
23
23
  QInputDialog, QMessageBox, QFileDialog, # QFileDialog only used if you later add “export”
@@ -32,7 +32,7 @@ from PyQt6.QtWidgets import (
32
32
 
33
33
  from .masks_core import MaskLayer
34
34
  from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
35
-
35
+ from setiastro.saspro.imageops.stretch import stretch_color_image
36
36
 
37
37
  # ---------- small utils ----------
38
38
 
@@ -48,6 +48,38 @@ def _to_qpixmap01(img01: np.ndarray) -> QPixmap:
48
48
  qimg = QImage(buf.data, w, h, buf.strides[0], QImage.Format.Format_RGB888)
49
49
  return QPixmap.fromImage(qimg)
50
50
 
51
+ def _display_stretch(img01: np.ndarray) -> np.ndarray:
52
+ """
53
+ Display-only stretch. Does NOT modify underlying data used for mask creation.
54
+ Returns float32 in [0,1].
55
+ """
56
+ a = np.asarray(img01, dtype=np.float32)
57
+ a = np.clip(a, 0.0, 1.0)
58
+
59
+ # Color: use your existing stretch if available
60
+ if a.ndim == 3 and a.shape[2] == 3 and stretch_color_image is not None:
61
+ try:
62
+ return np.clip(stretch_color_image(a, 0.25, linked=False, normalize=False), 0.0, 1.0).astype(np.float32)
63
+ except Exception:
64
+ pass
65
+
66
+ # Mono (or fallback): simple robust stretch around median
67
+ # (keeps it predictable and fast; display-only)
68
+ m = float(np.nanmedian(a))
69
+ if not np.isfinite(m):
70
+ return a.astype(np.float32, copy=False)
71
+
72
+ # Simple gamma-like lift using median anchor
73
+ # If median is tiny, boost; if already bright, minimal change.
74
+ target = 0.25
75
+ eps = 1e-8
76
+ scale = target / max(m, eps)
77
+ out = np.clip(a * scale, 0.0, 1.0)
78
+
79
+ # Gentle midtone curve
80
+ out = np.sqrt(out)
81
+ return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
82
+
51
83
 
52
84
  def _find_main_window(w):
53
85
  p = w
@@ -248,10 +280,14 @@ class MaskCanvas(QGraphicsView):
248
280
  super().__init__(parent)
249
281
  self.setRenderHint(QPainter.RenderHint.Antialiasing)
250
282
 
283
+ self._base_image01 = np.asarray(image01, dtype=np.float32)
284
+ self._display_stretch_enabled = False
285
+
251
286
  # scene + background image
252
287
  self.scene = QGraphicsScene(self)
253
288
  self.setScene(self.scene)
254
- self.bg_item = QGraphicsPixmapItem(_to_qpixmap01(image01))
289
+
290
+ self.bg_item = QGraphicsPixmapItem(_to_qpixmap01(self._base_image01))
255
291
  self.scene.addItem(self.bg_item)
256
292
 
257
293
  # --- NEW: basic zoom state ---
@@ -306,6 +342,38 @@ class MaskCanvas(QGraphicsView):
306
342
  super().wheelEvent(ev)
307
343
  # ----------------- END: Zoom API ---------------------
308
344
 
345
+ def set_display_stretch_enabled(self, enabled: bool):
346
+ enabled = bool(enabled)
347
+ if enabled == self._display_stretch_enabled:
348
+ return
349
+ self._display_stretch_enabled = enabled
350
+ self._refresh_background_pixmap(keep_view=True)
351
+
352
+ def display_stretch_enabled(self) -> bool:
353
+ return bool(self._display_stretch_enabled)
354
+
355
+ def current_display_image01(self) -> np.ndarray:
356
+ """Returns the image currently used for *display* (not for mask math)."""
357
+ if self._display_stretch_enabled:
358
+ return _display_stretch(self._base_image01)
359
+ return self._base_image01
360
+
361
+ def _refresh_background_pixmap(self, keep_view: bool = True):
362
+ # Preserve current view transform/center so toggling doesn't “jump”
363
+ old_transform = self.transform()
364
+ old_center = self.mapToScene(self.viewport().rect().center())
365
+
366
+ disp = self.current_display_image01()
367
+ self.bg_item.setPixmap(_to_qpixmap01(disp))
368
+
369
+ # Ensure scene rect still matches image pixels
370
+ self.setSceneRect(self.bg_item.boundingRect())
371
+
372
+ if keep_view:
373
+ self.setTransform(old_transform)
374
+ self.centerOn(old_center)
375
+
376
+
309
377
  def set_mode(self, mode: str):
310
378
  assert mode in ('polygon', 'ellipse', 'select')
311
379
  self.mode = mode
@@ -453,9 +521,10 @@ class LivePreviewDialog(QDialog):
453
521
  Qt.AspectRatioMode.KeepAspectRatio,
454
522
  Qt.TransformationMode.SmoothTransformation))
455
523
 
524
+ def set_base_image(self, image01: np.ndarray):
525
+ self.base_pixmap = _to_qpixmap01(image01)
456
526
 
457
527
  # ---------- Preview (push-as-doc) ----------
458
-
459
528
  class MaskPreviewDialog(QDialog):
460
529
  """Scrollable preview + 'Push as New Document…'."""
461
530
  def __init__(self, mask01: np.ndarray, parent=None):
@@ -463,29 +532,50 @@ class MaskPreviewDialog(QDialog):
463
532
  self.setWindowTitle(self.tr("Mask Preview"))
464
533
  self.mask = np.clip(mask01, 0, 1).astype(np.float32)
465
534
 
466
- self.scroll = QScrollArea(self); self.scroll.setWidgetResizable(False)
535
+ # --- drag-pan state ---
536
+ self._dragging = False
537
+ self._drag_start = None
538
+ self._h_start = 0
539
+ self._v_start = 0
540
+
541
+ # Build UI first
542
+ self.scroll = QScrollArea(self)
543
+ self.scroll.setWidgetResizable(False)
467
544
  self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
545
+
468
546
  self.label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
469
547
  self.label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
470
- self.pixmap = self._to_pixmap(self.mask); self.label.setPixmap(self.pixmap)
548
+
549
+ self.pixmap = self._to_pixmap(self.mask)
550
+ self.label.setPixmap(self.pixmap)
551
+ self.label.resize(self.pixmap.size())
552
+
471
553
  self.scroll.setWidget(self.label)
472
554
 
555
+ # Enable mouse drag panning on the label (NOW label exists)
556
+ self.label.setMouseTracking(True)
557
+ self.label.installEventFilter(self)
558
+
473
559
  btns = QHBoxLayout()
474
560
  b_in = themed_toolbtn("zoom-in", "Zoom In")
475
561
  b_out = themed_toolbtn("zoom-out", "Zoom Out")
476
562
  b_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
477
-
478
-
479
563
  b_push = QPushButton(self.tr("Push as New Document…"))
564
+
480
565
  b_in.clicked.connect(lambda: self._zoom(1.2))
481
566
  b_out.clicked.connect(lambda: self._zoom(1/1.2))
482
567
  b_fit.clicked.connect(self._fit)
483
568
  b_push.clicked.connect(self.push_as_new_document)
569
+
484
570
  for b in (b_in, b_out, b_fit, b_push):
485
571
  btns.addWidget(b)
486
572
 
487
- lay = QVBoxLayout(self); lay.addWidget(self.scroll); lay.addLayout(btns)
488
- self.scale = 1.0; self.setMinimumSize(600, 400)
573
+ lay = QVBoxLayout(self)
574
+ lay.addWidget(self.scroll)
575
+ lay.addLayout(btns)
576
+
577
+ self.scale = 1.0
578
+ self.setMinimumSize(600, 400)
489
579
 
490
580
  def _to_pixmap(self, mask01: np.ndarray) -> QPixmap:
491
581
  m8 = (np.clip(mask01, 0, 1) * 255).astype(np.uint8)
@@ -495,17 +585,52 @@ class MaskPreviewDialog(QDialog):
495
585
 
496
586
  def _zoom(self, factor: float):
497
587
  self.scale *= factor
498
- scaled = self.pixmap.scaled(self.pixmap.size() * self.scale,
499
- Qt.AspectRatioMode.KeepAspectRatio,
500
- Qt.TransformationMode.SmoothTransformation)
501
- self.label.setPixmap(scaled); self.label.resize(scaled.size())
588
+ scaled = self.pixmap.scaled(
589
+ self.pixmap.size() * self.scale,
590
+ Qt.AspectRatioMode.KeepAspectRatio,
591
+ Qt.TransformationMode.SmoothTransformation
592
+ )
593
+ self.label.setPixmap(scaled)
594
+ self.label.resize(scaled.size())
502
595
 
503
596
  def _fit(self):
504
597
  vp = self.scroll.viewport().size()
505
598
  if self.pixmap.width() and self.pixmap.height():
506
599
  s = min(vp.width()/self.pixmap.width(), vp.height()/self.pixmap.height())
507
600
  self.scale = max(0.05, s)
508
- self._zoom(1.0)
601
+ # re-render at the new scale (don’t multiply again)
602
+ scaled = self.pixmap.scaled(
603
+ self.pixmap.size() * self.scale,
604
+ Qt.AspectRatioMode.KeepAspectRatio,
605
+ Qt.TransformationMode.SmoothTransformation
606
+ )
607
+ self.label.setPixmap(scaled)
608
+ self.label.resize(scaled.size())
609
+
610
+ def eventFilter(self, obj, ev):
611
+ if obj is self.label:
612
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
613
+ self._dragging = True
614
+ self._drag_start = ev.globalPosition().toPoint()
615
+ self._h_start = self.scroll.horizontalScrollBar().value()
616
+ self._v_start = self.scroll.verticalScrollBar().value()
617
+ self.setCursor(Qt.CursorShape.ClosedHandCursor)
618
+ return True
619
+
620
+ if ev.type() == QEvent.Type.MouseMove and self._dragging:
621
+ p = ev.globalPosition().toPoint()
622
+ d = p - self._drag_start
623
+ self.scroll.horizontalScrollBar().setValue(self._h_start - d.x())
624
+ self.scroll.verticalScrollBar().setValue(self._v_start - d.y())
625
+ return True
626
+
627
+ if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
628
+ self._dragging = False
629
+ self._drag_start = None
630
+ self.unsetCursor()
631
+ return True
632
+
633
+ return super().eventFilter(obj, ev)
509
634
 
510
635
  def push_as_new_document(self):
511
636
  if self.mask is None:
@@ -558,6 +683,10 @@ class MaskCreationDialog(QDialog):
558
683
  self.setWindowFlag(Qt.WindowType.Window, True)
559
684
  self.setWindowModality(Qt.WindowModality.NonModal)
560
685
  self.setModal(False)
686
+ try:
687
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
688
+ except Exception:
689
+ pass # older PyQt6 versions
561
690
  self.image = np.asarray(image01, dtype=np.float32).copy()
562
691
  self.mask: np.ndarray | None = None
563
692
  self.live_preview = LivePreviewDialog(self.image, parent=self)
@@ -601,6 +730,18 @@ class MaskCreationDialog(QDialog):
601
730
  zoom_bar.addWidget(z_out); zoom_bar.addWidget(z_in); zoom_bar.addWidget(z_fit)
602
731
  layout.addLayout(zoom_bar)
603
732
 
733
+ # Display stretch toggle (display-only; never modifies image data)
734
+ self.btn_disp_stretch = QPushButton(self.tr("Toggle Display Stretch"))
735
+ self.btn_disp_stretch.setCheckable(True)
736
+ self.btn_disp_stretch.setToolTip(
737
+ "Display-only stretch for easier masking on linear images.\n"
738
+ "This does NOT change the image data or the generated mask."
739
+ )
740
+ self.btn_disp_stretch.toggled.connect(self._toggle_display_stretch)
741
+ self.btn_disp_stretch.setChecked(False)
742
+ self.btn_disp_stretch.setText("Enable Display Stretch")
743
+ zoom_bar.addWidget(self.btn_disp_stretch)
744
+
604
745
  # Canvas
605
746
  self.canvas = MaskCanvas(self.image)
606
747
  layout.addWidget(self.canvas, 1)
@@ -707,6 +848,24 @@ class MaskCreationDialog(QDialog):
707
848
  if self.link_cb.isChecked():
708
849
  self.upper_sl.setValue(v)
709
850
 
851
+ def _toggle_display_stretch(self, enabled: bool):
852
+ try:
853
+ self.canvas.set_display_stretch_enabled(bool(enabled))
854
+
855
+ # keep button label in sync
856
+ self.btn_disp_stretch.setText(
857
+ self.tr("Disable Display Stretch") if enabled else self.tr("Enable Display Stretch")
858
+ )
859
+
860
+ # Keep the live preview background in sync (Range Selection uses it)
861
+ if hasattr(self, "live_preview") and self.live_preview is not None:
862
+ self.live_preview.set_base_image(self.canvas.current_display_image01())
863
+ if self.live_preview.isVisible():
864
+ self._update_live_preview()
865
+ except Exception:
866
+ pass
867
+
868
+
710
869
  # ---- generators
711
870
  def _component_lightness(self) -> np.ndarray:
712
871
  if self.image.ndim == 3: