setiastrosuitepro 1.6.1.post1__py3-none-any.whl → 1.6.4__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 (139) hide show
  1. setiastro/images/Background_startup.jpg +0 -0
  2. setiastro/images/rotatearbitrary.png +0 -0
  3. setiastro/qml/ResourceMonitor.qml +126 -0
  4. setiastro/saspro/__main__.py +162 -25
  5. setiastro/saspro/_generated/build_info.py +2 -1
  6. setiastro/saspro/abe.py +62 -11
  7. setiastro/saspro/aberration_ai.py +3 -3
  8. setiastro/saspro/add_stars.py +5 -2
  9. setiastro/saspro/astrobin_exporter.py +3 -0
  10. setiastro/saspro/astrospike_python.py +3 -1
  11. setiastro/saspro/autostretch.py +4 -2
  12. setiastro/saspro/backgroundneutral.py +60 -9
  13. setiastro/saspro/batch_convert.py +3 -0
  14. setiastro/saspro/batch_renamer.py +3 -0
  15. setiastro/saspro/blemish_blaster.py +3 -0
  16. setiastro/saspro/blink_comparator_pro.py +474 -251
  17. setiastro/saspro/cheat_sheet.py +50 -15
  18. setiastro/saspro/clahe.py +27 -1
  19. setiastro/saspro/comet_stacking.py +103 -38
  20. setiastro/saspro/convo.py +3 -0
  21. setiastro/saspro/copyastro.py +3 -0
  22. setiastro/saspro/cosmicclarity.py +70 -45
  23. setiastro/saspro/crop_dialog_pro.py +28 -1
  24. setiastro/saspro/curve_editor_pro.py +18 -0
  25. setiastro/saspro/debayer.py +3 -0
  26. setiastro/saspro/doc_manager.py +40 -17
  27. setiastro/saspro/fitsmodifier.py +3 -0
  28. setiastro/saspro/frequency_separation.py +8 -2
  29. setiastro/saspro/function_bundle.py +18 -16
  30. setiastro/saspro/generate_translations.py +715 -1
  31. setiastro/saspro/ghs_dialog_pro.py +3 -0
  32. setiastro/saspro/graxpert.py +3 -0
  33. setiastro/saspro/gui/main_window.py +364 -92
  34. setiastro/saspro/gui/mixins/dock_mixin.py +119 -7
  35. setiastro/saspro/gui/mixins/file_mixin.py +7 -0
  36. setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
  37. setiastro/saspro/gui/mixins/menu_mixin.py +29 -0
  38. setiastro/saspro/gui/mixins/toolbar_mixin.py +33 -10
  39. setiastro/saspro/gui/statistics_dialog.py +47 -0
  40. setiastro/saspro/halobgon.py +29 -3
  41. setiastro/saspro/histogram.py +3 -0
  42. setiastro/saspro/history_explorer.py +2 -0
  43. setiastro/saspro/i18n.py +22 -10
  44. setiastro/saspro/image_combine.py +3 -0
  45. setiastro/saspro/image_peeker_pro.py +3 -0
  46. setiastro/saspro/imageops/stretch.py +5 -13
  47. setiastro/saspro/isophote.py +3 -0
  48. setiastro/saspro/legacy/numba_utils.py +64 -47
  49. setiastro/saspro/linear_fit.py +3 -0
  50. setiastro/saspro/live_stacking.py +13 -2
  51. setiastro/saspro/mask_creation.py +3 -0
  52. setiastro/saspro/mfdeconv.py +5 -0
  53. setiastro/saspro/morphology.py +30 -5
  54. setiastro/saspro/multiscale_decomp.py +713 -256
  55. setiastro/saspro/nbtorgb_stars.py +12 -2
  56. setiastro/saspro/numba_utils.py +148 -47
  57. setiastro/saspro/ops/scripts.py +77 -17
  58. setiastro/saspro/ops/settings.py +1 -43
  59. setiastro/saspro/perfect_palette_picker.py +1 -0
  60. setiastro/saspro/pixelmath.py +6 -2
  61. setiastro/saspro/plate_solver.py +1 -0
  62. setiastro/saspro/remove_green.py +18 -1
  63. setiastro/saspro/remove_stars.py +136 -162
  64. setiastro/saspro/remove_stars_preset.py +55 -13
  65. setiastro/saspro/resources.py +36 -10
  66. setiastro/saspro/rgb_combination.py +1 -0
  67. setiastro/saspro/rgbalign.py +4 -4
  68. setiastro/saspro/save_options.py +1 -0
  69. setiastro/saspro/selective_color.py +79 -20
  70. setiastro/saspro/sfcc.py +50 -8
  71. setiastro/saspro/shortcuts.py +94 -21
  72. setiastro/saspro/signature_insert.py +3 -0
  73. setiastro/saspro/stacking_suite.py +924 -446
  74. setiastro/saspro/star_alignment.py +291 -331
  75. setiastro/saspro/star_spikes.py +116 -32
  76. setiastro/saspro/star_stretch.py +38 -1
  77. setiastro/saspro/stat_stretch.py +35 -3
  78. setiastro/saspro/status_log_dock.py +1 -1
  79. setiastro/saspro/subwindow.py +63 -2
  80. setiastro/saspro/supernovaasteroidhunter.py +3 -0
  81. setiastro/saspro/swap_manager.py +77 -42
  82. setiastro/saspro/translations/all_source_strings.json +4726 -0
  83. setiastro/saspro/translations/ar_translations.py +4096 -0
  84. setiastro/saspro/translations/de_translations.py +441 -446
  85. setiastro/saspro/translations/es_translations.py +278 -32
  86. setiastro/saspro/translations/fr_translations.py +280 -32
  87. setiastro/saspro/translations/hi_translations.py +3803 -0
  88. setiastro/saspro/translations/integrate_translations.py +38 -1
  89. setiastro/saspro/translations/it_translations.py +1211 -145
  90. setiastro/saspro/translations/ja_translations.py +556 -307
  91. setiastro/saspro/translations/pt_translations.py +3316 -3322
  92. setiastro/saspro/translations/ru_translations.py +3082 -0
  93. setiastro/saspro/translations/saspro_ar.qm +0 -0
  94. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  95. setiastro/saspro/translations/saspro_de.qm +0 -0
  96. setiastro/saspro/translations/saspro_de.ts +14428 -133
  97. setiastro/saspro/translations/saspro_es.qm +0 -0
  98. setiastro/saspro/translations/saspro_es.ts +11503 -7821
  99. setiastro/saspro/translations/saspro_fr.qm +0 -0
  100. setiastro/saspro/translations/saspro_fr.ts +11168 -7812
  101. setiastro/saspro/translations/saspro_hi.qm +0 -0
  102. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  103. setiastro/saspro/translations/saspro_it.qm +0 -0
  104. setiastro/saspro/translations/saspro_it.ts +14347 -7821
  105. setiastro/saspro/translations/saspro_ja.qm +0 -0
  106. setiastro/saspro/translations/saspro_ja.ts +14860 -137
  107. setiastro/saspro/translations/saspro_pt.qm +0 -0
  108. setiastro/saspro/translations/saspro_pt.ts +14904 -137
  109. setiastro/saspro/translations/saspro_ru.qm +0 -0
  110. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  111. setiastro/saspro/translations/saspro_sw.qm +0 -0
  112. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  113. setiastro/saspro/translations/saspro_uk.qm +0 -0
  114. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  115. setiastro/saspro/translations/saspro_zh.qm +0 -0
  116. setiastro/saspro/translations/saspro_zh.ts +10581 -7812
  117. setiastro/saspro/translations/sw_translations.py +3897 -0
  118. setiastro/saspro/translations/uk_translations.py +3929 -0
  119. setiastro/saspro/translations/zh_translations.py +283 -32
  120. setiastro/saspro/versioning.py +36 -5
  121. setiastro/saspro/view_bundle.py +20 -17
  122. setiastro/saspro/wavescale_hdr.py +22 -1
  123. setiastro/saspro/wavescalede.py +23 -1
  124. setiastro/saspro/whitebalance.py +39 -3
  125. setiastro/saspro/widgets/minigame/game.js +991 -0
  126. setiastro/saspro/widgets/minigame/index.html +53 -0
  127. setiastro/saspro/widgets/minigame/style.css +241 -0
  128. setiastro/saspro/widgets/resource_monitor.py +263 -0
  129. setiastro/saspro/widgets/spinboxes.py +18 -0
  130. setiastro/saspro/widgets/wavelet_utils.py +52 -20
  131. setiastro/saspro/wimi.py +100 -80
  132. setiastro/saspro/wims.py +33 -33
  133. setiastro/saspro/window_shelf.py +2 -2
  134. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/METADATA +15 -4
  135. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/RECORD +139 -115
  136. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/WHEEL +0 -0
  137. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/entry_points.txt +0 -0
  138. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/LICENSE +0 -0
  139. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/license.txt +0 -0
@@ -136,154 +136,82 @@ def _mtf_params_unlinked(img_rgb01: np.ndarray,
136
136
  Works on float32 data assumed in [0,1].
137
137
  Returns dict with arrays: {'s': (C,), 'm': (C,), 'h': (C,)}.
138
138
  """
139
- x = np.asarray(img_rgb01, dtype=np.float32)
140
- # Force 3 channels internally (Siril expects 1 or 3; we always give it 3 here)
141
- if x.ndim == 2:
142
- x = np.stack([x] * 3, axis=-1)
143
- elif x.ndim == 3 and x.shape[2] == 1:
144
- x = np.repeat(x, 3, axis=2)
145
-
146
- C = x.shape[2]
147
- s = np.zeros(C, dtype=np.float32)
148
- m = np.zeros(C, dtype=np.float32)
149
- h = np.zeros(C, dtype=np.float32)
150
-
151
- med = np.zeros(C, dtype=np.float32)
152
- mad = np.zeros(C, dtype=np.float32)
153
- inverted_flags = np.zeros(C, dtype=bool)
154
-
155
- # --- stats per channel (Siril: median / normValue, mad / normValue * MAD_NORM) ---
156
- # Here normValue == 1.0 because we're already in [0,1]
157
- for c in range(C):
158
- ch = x[..., c].astype(np.float32, copy=False)
159
- med_c = float(np.median(ch))
160
- mad_raw = float(np.median(np.abs(ch - med_c)))
161
- mad_c = mad_raw * _MAD_NORM
162
- if mad_c == 0.0:
163
- mad_c = 0.001
164
-
165
- med[c] = med_c
166
- mad[c] = mad_c
167
- if med_c > 0.5:
168
- inverted_flags[c] = True
169
-
170
- inverted_channels = int(inverted_flags.sum())
171
-
172
- # --- Main branch (non-inverted dominant) ---
173
- if inverted_channels < C:
174
- for c in range(C):
175
- median = float(med[c])
176
- mad_c = float(mad[c])
177
-
178
- c0 = median + shadows_clipping * mad_c
179
- if c0 < 0.0:
180
- c0 = 0.0
181
- # Siril: m2 = median - c0; midtones = MTF(m2, target_bg, 0,1)
182
- m2 = median - c0
183
- mid = float(_mtf_scalar(m2, targetbg, 0.0, 1.0))
184
-
185
- s[c] = c0
186
- m[c] = mid
187
- h[c] = 1.0
188
-
189
- # --- Inverted channel branch ---
190
- else:
191
- for c in range(C):
192
- median = float(med[c])
193
- mad_c = float(mad[c])
194
-
195
- c1 = median - shadows_clipping * mad_c
196
- if c1 > 1.0:
197
- c1 = 1.0
198
- m2 = c1 - median
199
- mid = 1.0 - float(_mtf_scalar(m2, targetbg, 0.0, 1.0))
200
-
201
- s[c] = 0.0
202
- m[c] = mid
203
- h[c] = c1
204
-
205
- return {"s": s, "m": m, "h": h}
206
-
207
-
208
- def _apply_mtf_unlinked_rgb(img_rgb01: np.ndarray, p: dict) -> np.ndarray:
209
139
  """
210
- Apply per-channel MTF exactly. p from _mtf_params_unlinked.
140
+ Siril-style per-channel MTF parameter estimation, matching
141
+ find_unlinked_midtones_balance_default() / find_unlinked_midtones_balance().
142
+
143
+ Works on float32 data assumed in [0,1].
144
+ Returns dict with arrays: {'s': (C,), 'm': (C,), 'h': (C,)}.
211
145
  """
212
146
  x = np.asarray(img_rgb01, dtype=np.float32)
147
+
148
+ # Analyze input shape to handle mono efficiently
213
149
  if x.ndim == 2:
214
- x = np.stack([x]*3, axis=-1)
150
+ # (H, W) -> treat as single channel
151
+ x_in = x[..., None] # Virtual 3D (H,W,1)
152
+ C_in = 1
215
153
  elif x.ndim == 3 and x.shape[2] == 1:
216
- x = np.repeat(x, 3, axis=2)
217
-
218
- out = np.empty_like(x, dtype=np.float32)
219
- for c in range(x.shape[2]):
220
- out[..., c] = _mtf_apply(x[..., c], float(p["s"][c]), float(p["m"][c]), float(p["h"][c]))
221
- return np.clip(out, 0.0, 1.0)
222
-
223
-
224
- def _invert_mtf_unlinked_rgb(img_rgb01: np.ndarray, p: dict) -> np.ndarray:
225
- """
226
- Exact analytic inverse per channel (uses same s/m/h arrays).
227
- """
228
- y = np.asarray(img_rgb01, dtype=np.float32)
229
- if y.ndim == 2:
230
- y = np.stack([y]*3, axis=-1)
231
- elif y.ndim == 3 and y.shape[2] == 1:
232
- y = np.repeat(y, 3, axis=2)
233
-
234
- out = np.empty_like(y, dtype=np.float32)
235
- for c in range(y.shape[2]):
236
- out[..., c] = _mtf_inverse(y[..., c], float(p["s"][c]), float(p["m"][c]), float(p["h"][c]))
237
- return np.clip(out, 0.0, 1.0)
238
-
239
- def _stat_stretch_rgb(img: np.ndarray,
240
- lo_pct: float = 0.25,
241
- hi_pct: float = 99.75) -> tuple[np.ndarray, dict]:
242
- """
243
- Make sure img is RGB float32 in [0,1], stretch each channel to [0,1]
244
- using percentiles. Returns (stretched_img, params) where params can be
245
- fed to _stat_unstretch_rgb() to invert exactly.
246
- """
247
- was_single = (img.ndim == 2) or (img.ndim == 3 and img.shape[2] == 1)
248
- if was_single:
249
- img = np.stack([img] * 3, axis=-1)
250
-
251
- x = img.astype(np.float32, copy=False)
252
- out = np.empty_like(x, dtype=np.float32)
253
- lo_vals, hi_vals = [], []
154
+ x_in = x
155
+ C_in = 1
156
+ else:
157
+ x_in = x
158
+ C_in = x.shape[2]
159
+
160
+ # Vectorized stats calculation on actual data only
161
+ med = np.median(x_in, axis=(0, 1)).astype(np.float32) # shape (C_in,)
162
+
163
+ # MAD requires centered abs diff
164
+ diff = np.abs(x_in - med.reshape(1, 1, C_in))
165
+ mad_raw = np.median(diff, axis=(0, 1)).astype(np.float32) # shape (C_in,)
166
+
167
+ mad = mad_raw * _MAD_NORM
168
+ mad[mad == 0] = 0.001
169
+
170
+ inverted_flags = (med > 0.5)
171
+ # If mono, we just check the one channel. If RGB, we check all.
172
+ # Logic below assumes we return 3-channel params s,m,h even for mono input (broadcasted).
173
+
174
+ # To match original behavior which always returned 3-element arrays for s,m,h:
175
+ # We will compute s_in, m_in, h_in for the input channels, then broadcast to 3.
176
+
177
+ s_in = np.zeros(C_in, dtype=np.float32)
178
+ m_in = np.zeros(C_in, dtype=np.float32)
179
+ h_in = np.zeros(C_in, dtype=np.float32)
180
+
181
+ # We iterate C_in times (1 or 3)
182
+ for c in range(C_in):
183
+ is_inv = inverted_flags[c]
184
+ md = med[c]
185
+ md_dev = mad[c]
186
+
187
+ if not is_inv:
188
+ # Normal
189
+ c0 = max(md + shadows_clipping * md_dev, 0.0)
190
+ m2 = md - c0
191
+
192
+ s_in[c] = c0
193
+ m_in[c] = float(_mtf_scalar(m2, targetbg, 0.0, 1.0))
194
+ h_in[c] = 1.0
195
+ else:
196
+ # Inverted
197
+ c1 = min(md - shadows_clipping * md_dev, 1.0)
198
+ m2 = c1 - md
199
+
200
+ s_in[c] = 0.0
201
+ m_in[c] = 1.0 - float(_mtf_scalar(m2, targetbg, 0.0, 1.0))
202
+ h_in[c] = c1
203
+
204
+ # Broadcast to 3 channels if needed
205
+ if C_in == 1:
206
+ s = np.repeat(s_in, 3)
207
+ m = np.repeat(m_in, 3)
208
+ h = np.repeat(h_in, 3)
209
+ else:
210
+ s = s_in
211
+ m = m_in
212
+ h = h_in
254
213
 
255
- for c in range(3):
256
- ch = x[..., c]
257
- lo = float(np.percentile(ch, lo_pct))
258
- hi = float(np.percentile(ch, hi_pct))
259
- if not np.isfinite(lo): lo = 0.0
260
- if not np.isfinite(hi): hi = 1.0
261
- if hi - lo < 1e-6:
262
- hi = lo + 1e-6
263
- lo_vals.append(lo); hi_vals.append(hi)
264
- out[..., c] = (ch - lo) / (hi - lo)
265
-
266
- out = np.clip(out, 0.0, 1.0)
267
- params = {"lo": lo_vals, "hi": hi_vals, "was_single": was_single}
268
- return out, params
269
-
270
-
271
- def _stat_unstretch_rgb(img: np.ndarray, params: dict) -> np.ndarray:
272
- """
273
- Inverse of _stat_stretch_rgb. Expects img RGB float32 [0,1].
274
- """
275
- lo = np.asarray(params["lo"], dtype=np.float32)
276
- hi = np.asarray(params["hi"], dtype=np.float32)
277
- out = img.astype(np.float32, copy=True)
278
- for c in range(3):
279
- out[..., c] = out[..., c] * (hi[c] - lo[c]) + lo[c]
280
- out = np.clip(out, 0.0, 1.0)
281
- if params.get("was_single", False):
282
- out = out.mean(axis=2, keepdims=False) # back to single channel if needed
283
- # StarNet needs RGB during processing; we keep RGB after removal for consistency.
284
- # If you want to return mono to the doc when the source was mono, do it at the very end.
285
- out = np.stack([out] * 3, axis=-1)
286
- return out
214
+ return {"s": s, "m": m, "h": h}
287
215
 
288
216
  def _mtf_scalar(x: float, m: float, lo: float = 0.0, hi: float = 1.0) -> float:
289
217
  """
@@ -324,6 +252,36 @@ def _mtf_scalar(x: float, m: float, lo: float = 0.0, hi: float = 1.0) -> float:
324
252
  return float(y)
325
253
 
326
254
 
255
+ def _apply_mtf_unlinked_rgb(img_rgb01: np.ndarray, p: dict) -> np.ndarray:
256
+ """
257
+ Apply per-channel MTF exactly. p from _mtf_params_unlinked.
258
+ """
259
+ x = np.asarray(img_rgb01, dtype=np.float32)
260
+ if x.ndim == 2:
261
+ x = np.stack([x]*3, axis=-1)
262
+ elif x.ndim == 3 and x.shape[2] == 1:
263
+ x = np.repeat(x, 3, axis=2)
264
+
265
+ out = np.empty_like(x, dtype=np.float32)
266
+ for c in range(x.shape[2]):
267
+ out[..., c] = _mtf_apply(x[..., c], float(p["s"][c]), float(p["m"][c]), float(p["h"][c]))
268
+ return np.clip(out, 0.0, 1.0)
269
+
270
+
271
+ def _invert_mtf_unlinked_rgb(img_rgb01: np.ndarray, p: dict) -> np.ndarray:
272
+ """
273
+ Exact analytic inverse per channel (uses same s/m/h arrays).
274
+ """
275
+ y = np.asarray(img_rgb01, dtype=np.float32)
276
+ if y.ndim == 2:
277
+ y = np.stack([y]*3, axis=-1)
278
+ elif y.ndim == 3 and y.shape[2] == 1:
279
+ y = np.repeat(y, 3, axis=2)
280
+
281
+ out = np.empty_like(y, dtype=np.float32)
282
+ for c in range(y.shape[2]):
283
+ out[..., c] = _mtf_inverse(y[..., c], float(p["s"][c]), float(p["m"][c]), float(p["h"][c]))
284
+ return np.clip(out, 0.0, 1.0)
327
285
  # ------------------------------------------------------------
328
286
  # Settings helper
329
287
  # ------------------------------------------------------------
@@ -370,24 +328,28 @@ def starnet_starless_from_array(arr_rgb01: np.ndarray, settings, *, tmp_prefix="
370
328
  in_path = os.path.join(workdir, f"{tmp_prefix}_in.tif")
371
329
  out_path = os.path.join(workdir, f"{tmp_prefix}_out.tif")
372
330
 
373
- # --- Normalize input shape and safe values ---
374
- x = arr
375
- if x.ndim == 2:
376
- x = np.stack([x] * 3, axis=-1)
377
- elif x.ndim == 3 and x.shape[2] == 1:
378
- x = np.repeat(x, 3, axis=2)
379
- x = np.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32, copy=False)
331
+ # --- Normalize input shape (virtual) and safe values ---
332
+ x_in = np.asarray(arr, dtype=np.float32)
333
+
334
+ # If (H,W,1), collapse to (H,W) so mono flows cleanly
335
+ if x_in.ndim == 3 and x_in.shape[2] == 1:
336
+ x_in = x_in[..., 0]
337
+
338
+ # sanitize
339
+ x_in = np.nan_to_num(x_in, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32, copy=False)
380
340
 
381
341
  # Preserve original numeric scale if users pass >1.0
382
- xmax = float(np.max(x)) if x.size else 1.0
342
+ xmax = float(np.max(x_in)) if x_in.size else 1.0
383
343
  scale_factor = xmax if xmax > 1.01 else 1.0
384
- xin = (x / scale_factor) if scale_factor > 1.0 else x
344
+
345
+ xin = (x_in / scale_factor) if scale_factor > 1.0 else x_in
385
346
  xin = np.clip(xin, 0.0, 1.0)
386
347
 
387
348
  # --- Siril-style unlinked MTF params + pre-stretch ---
388
349
  mtf_params = _mtf_params_unlinked(xin, shadows_clipping=-2.8, targetbg=0.25)
389
350
  x_for_starnet = _apply_mtf_unlinked_rgb(xin, mtf_params).astype(np.float32, copy=False)
390
351
 
352
+
391
353
  # --- Write 16-bit TIFF for StarNet ---
392
354
  save_image(
393
355
  x_for_starnet, in_path,
@@ -662,18 +624,19 @@ def _run_starnet(main, doc):
662
624
  )
663
625
  except Exception:
664
626
  pass
665
- # --- Ensure RGB float32 in safe range
627
+ # --- Ensure RGB float32 in safe range (without expanding yet)
628
+ # Starnet needs RGB eventually, but we can compute stats/normalization on mono
666
629
  src = np.asarray(doc.image)
667
- if src.ndim == 2:
668
- processing_image = np.stack([src]*3, axis=-1)
669
- elif src.ndim == 3 and src.shape[2] == 1:
670
- processing_image = np.repeat(src, 3, axis=2)
630
+ if src.ndim == 3 and src.shape[2] == 1:
631
+ # standardizing shape is cheap
632
+ processing_image = src[..., 0]
671
633
  else:
672
634
  processing_image = src
635
+
673
636
  processing_image = np.nan_to_num(processing_image.astype(np.float32, copy=False),
674
637
  nan=0.0, posinf=0.0, neginf=0.0)
675
638
 
676
- # --- Scale normalization if >1.0 (same reason as before: 16-bit export safety)
639
+ # --- Scale normalization if >1.0
677
640
  scale_factor = float(np.max(processing_image))
678
641
  if scale_factor > 1.0:
679
642
  processing_norm = processing_image / scale_factor
@@ -1027,11 +990,10 @@ def _run_darkstar(main, doc):
1027
990
  pass
1028
991
 
1029
992
  # --- Build processing image (RGB float32, normalized) ---
993
+ # DarkStar needs RGB, but we can delay expansion until save
1030
994
  src = np.asarray(doc.image)
1031
- if src.ndim == 2:
1032
- processing_image = np.stack([src] * 3, axis=-1)
1033
- elif src.ndim == 3 and src.shape[2] == 1:
1034
- processing_image = np.repeat(src, 3, axis=2)
995
+ if src.ndim == 3 and src.shape[2] == 1:
996
+ processing_image = src[..., 0]
1035
997
  else:
1036
998
  processing_image = src
1037
999
 
@@ -1088,8 +1050,20 @@ def _run_darkstar(main, doc):
1088
1050
  # --- Save pre-stretched image as 32-bit float TIFF for DarkStar ---
1089
1051
  in_path = os.path.join(input_dir, "imagetoremovestars.tif")
1090
1052
  try:
1053
+ # Check if we need to expand on-the-fly for DarkStar (it expects RGB input)
1054
+ # If img_for_darkstar is mono, save_image might save mono.
1055
+ # "is_mono=False" flag to save_image hints we want RGB.
1056
+ # If the array is 2D, save_image might still save mono unless we feed it 3D.
1057
+ # For safety with DarkStar, we create the 3D view now if needed.
1058
+
1059
+ to_save = img_for_darkstar
1060
+ if to_save.ndim == 2:
1061
+ to_save = np.stack([to_save]*3, axis=-1)
1062
+ elif to_save.ndim == 3 and to_save.shape[2] == 1:
1063
+ to_save = np.repeat(to_save, 3, axis=2)
1064
+
1091
1065
  save_image(
1092
- img_for_darkstar,
1066
+ to_save,
1093
1067
  in_path,
1094
1068
  original_format="tif",
1095
1069
  bit_depth="32-bit floating point",
@@ -12,7 +12,7 @@ from setiastro.saspro.legacy.image_manager import save_image, load_image
12
12
  # Reuse helpers & plumbing from the interactive module
13
13
  from .remove_stars import (
14
14
  _ProcThread, _ProcDialog,
15
- _stat_stretch_rgb, _stat_unstretch_rgb,
15
+ _mtf_params_unlinked, _apply_mtf_unlinked_rgb, _invert_mtf_unlinked_rgb,
16
16
  _active_mask3_from_doc, _mask_blend_with_doc_mask, _push_as_new_doc,
17
17
  _ensure_exec_bit,
18
18
  )
@@ -125,24 +125,48 @@ def _run_starnet_headless(main, doc, p):
125
125
  processing_image = processing_image.astype(np.float32, copy=False)
126
126
 
127
127
  is_linear = bool(p.get("linear", True))
128
- did_stretch = False
129
- stretch_params = None
128
+ did_stretch = is_linear
129
+
130
+ # sanitize + normalize if needed (keep exactly like interactive)
131
+ processing_image = np.nan_to_num(processing_image, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32, copy=False)
132
+
133
+ scale_factor = float(np.max(processing_image)) if processing_image.size else 1.0
134
+ processing_norm = (processing_image / scale_factor) if scale_factor > 1.0 else processing_image
135
+ processing_norm = np.clip(processing_norm, 0.0, 1.0)
136
+
137
+ img_for_starnet = processing_norm
138
+
130
139
  if is_linear:
131
- processing_image, stretch_params = _stat_stretch_rgb(processing_image)
132
- did_stretch = True
133
- setattr(main, "_starnet_last_stretch_params", stretch_params)
140
+ mtf_params = _mtf_params_unlinked(processing_norm, shadows_clipping=-2.8, targetbg=0.25)
141
+ img_for_starnet = _apply_mtf_unlinked_rgb(processing_norm, mtf_params)
142
+
143
+ # stash for inverse step (same keys as interactive)
144
+ try:
145
+ setattr(main, "_starnet_stat_meta", {
146
+ "scheme": "siril_mtf",
147
+ "s": np.asarray(mtf_params["s"], dtype=np.float32),
148
+ "m": np.asarray(mtf_params["m"], dtype=np.float32),
149
+ "h": np.asarray(mtf_params["h"], dtype=np.float32),
150
+ "scale": float(scale_factor),
151
+ })
152
+ except Exception:
153
+ pass
134
154
  else:
135
- if hasattr(main, "_starnet_last_stretch_params"):
136
- delattr(main, "_starnet_last_stretch_params")
155
+ try:
156
+ if hasattr(main, "_starnet_stat_meta"):
157
+ delattr(main, "_starnet_stat_meta")
158
+ except Exception:
159
+ pass
160
+
137
161
 
138
162
  starnet_dir = os.path.dirname(exe) or os.getcwd()
139
163
  in_path = os.path.join(starnet_dir, "imagetoremovestars.tif")
140
164
  out_path = os.path.join(starnet_dir, "starless.tif")
141
165
 
142
166
  try:
143
- save_image(processing_image, in_path, original_format="tif",
144
- bit_depth="16-bit", original_header=None, is_mono=False,
145
- image_meta=None, file_meta=None)
167
+ save_image(img_for_starnet, in_path, original_format="tif",
168
+ bit_depth="16-bit", original_header=None, is_mono=False,
169
+ image_meta=None, file_meta=None)
146
170
  except Exception as e:
147
171
  QMessageBox.critical(main, "StarNet", f"Failed to write input TIFF:\n{e}")
148
172
  return
@@ -179,12 +203,30 @@ def _finish_starnet(main, doc, rc, dlg, in_path, out_path, did_stretch):
179
203
  starless_rgb = starless_rgb.astype(np.float32, copy=False)
180
204
 
181
205
  if did_stretch:
206
+ meta = getattr(main, "_starnet_stat_meta", None)
207
+ if isinstance(meta, dict) and meta.get("scheme") == "siril_mtf":
208
+ try:
209
+ p = {
210
+ "s": np.asarray(meta.get("s"), dtype=np.float32),
211
+ "m": np.asarray(meta.get("m"), dtype=np.float32),
212
+ "h": np.asarray(meta.get("h"), dtype=np.float32),
213
+ }
214
+ inv = _invert_mtf_unlinked_rgb(starless_rgb, p)
215
+ sc = float(meta.get("scale", 1.0))
216
+ if sc > 1.0:
217
+ inv *= sc
218
+ starless_rgb = np.clip(inv, 0.0, 1.0).astype(np.float32, copy=False)
219
+ except Exception:
220
+ pass
221
+
222
+ # cleanup so it can't leak
182
223
  try:
183
- params = getattr(main, "_starnet_last_stretch_params", None)
184
- if params: starless_rgb = _stat_unstretch_rgb(starless_rgb, params)
224
+ if hasattr(main, "_starnet_stat_meta"):
225
+ delattr(main, "_starnet_stat_meta")
185
226
  except Exception:
186
227
  pass
187
228
 
229
+
188
230
  # original as RGB
189
231
  orig = np.asarray(doc.image)
190
232
  if orig.ndim == 2: original_rgb = np.stack([orig]*3, axis=-1)
@@ -123,17 +123,31 @@ def _get_base_path() -> str:
123
123
 
124
124
 
125
125
  def _resource_path(filename: str) -> str:
126
- """Get full path to a resource file."""
127
126
  base = _get_base_path()
128
-
129
- # Check if it's an image file - look in images/ subdirectory
130
- if filename.endswith(('.png', '.ico', '.gif', '.icns', '.svg')):
131
- images_path = os.path.join(base, 'images', filename)
132
- if os.path.exists(images_path):
133
- return images_path
134
-
135
- # Fallback to root directory (for data files like .csv, .fits, etc.)
136
- return os.path.join(base, filename)
127
+ fn = filename
128
+
129
+ is_img = fn.lower().endswith(('.png','.ico','.gif','.icns','.svg','.jpg','.jpeg','.bmp'))
130
+ if is_img:
131
+ candidates = [
132
+ os.path.join(base, 'images', fn),
133
+ os.path.join(base, 'setiastro', 'images', fn),
134
+ os.path.join(base, 'setiastro', 'saspro', 'images', fn),
135
+ ]
136
+ for p in candidates:
137
+ if os.path.exists(p):
138
+ return p
139
+
140
+ # data / other files
141
+ candidates = [
142
+ os.path.join(base, fn),
143
+ os.path.join(base, 'setiastro', fn),
144
+ os.path.join(base, 'setiastro', 'saspro', fn),
145
+ ]
146
+ for p in candidates:
147
+ if os.path.exists(p):
148
+ return p
149
+
150
+ return os.path.join(base, fn)
137
151
 
138
152
 
139
153
  class Icons:
@@ -210,6 +224,7 @@ class Icons:
210
224
  ROTATE_CW = property(lambda self: _resource_path('rotateclockwise.png'))
211
225
  ROTATE_CCW = property(lambda self: _resource_path('rotatecounterclockwise.png'))
212
226
  ROTATE_180 = property(lambda self: _resource_path('rotate180.png'))
227
+ ROTATE_ANY = property(lambda self: _resource_path('rotatearbitrary.png'))
213
228
  RESCALE = property(lambda self: _resource_path('rescale.png'))
214
229
 
215
230
  # Masks
@@ -395,6 +410,7 @@ def _init_legacy_paths():
395
410
  'rotateclockwise_path': get_icon_path('rotateclockwise.png'),
396
411
  'rotatecounterclockwise_path': get_icon_path('rotatecounterclockwise.png'),
397
412
  'rotate180_path': get_icon_path('rotate180.png'),
413
+ 'rotatearbitrary_path': get_icon_path('rotatearbitrary.png'),
398
414
  'maskcreate_path': get_icon_path('maskcreate.png'),
399
415
  'maskapply_path': get_icon_path('maskapply.png'),
400
416
  'maskremove_path': get_icon_path('maskremove.png'),
@@ -469,9 +485,19 @@ def _init_legacy_paths():
469
485
  _legacy = _init_legacy_paths()
470
486
  globals().update(_legacy)
471
487
 
488
+
489
+ # Background for startup
490
+ background_startup_path = _resource_path('Background_startup.jpg')
491
+ _legacy['background_startup_path'] = background_startup_path
492
+
493
+ # QML helper
494
+ resource_monitor_qml = _resource_path(os.path.join("qml", "ResourceMonitor.qml"))
495
+
472
496
  # Export list for `from setiastro.saspro.resources import *`
473
497
  __all__ = [
474
498
  'Icons', 'Resources',
475
499
  'get_icons', 'get_resources',
476
500
  'get_icon_path', 'get_data_path',
501
+ 'background_startup_path',
477
502
  ] + list(_legacy.keys())
503
+
@@ -44,6 +44,7 @@ class RGBCombinationDialogPro(QDialog):
44
44
  super().__init__(parent)
45
45
  self.setWindowTitle(self.tr("RGB Combination"))
46
46
  self.setWindowFlag(Qt.WindowType.Window, True)
47
+ self.setWindowModality(Qt.WindowModality.NonModal)
47
48
  self.setModal(False)
48
49
  #self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
49
50
  self._list_open_docs = list_open_docs_fn or (lambda: [])
@@ -324,15 +324,12 @@ class RGBAlignWorker(QThread):
324
324
  def _warp_channel(self, ch: np.ndarray, kind: str, X, ref_shape):
325
325
  H, W = ref_shape[:2]
326
326
  if kind == "affine":
327
- if cv2 is None:
328
- return ch
327
+ # Just assume cv2 is available (standard dependency) for perf
329
328
  A = np.asarray(X, dtype=np.float32).reshape(2, 3)
330
329
  return cv2.warpAffine(ch, A, (W, H), flags=cv2.INTER_LANCZOS4,
331
330
  borderMode=cv2.BORDER_CONSTANT, borderValue=0)
332
331
 
333
332
  if kind == "homography":
334
- if cv2 is None:
335
- return ch
336
333
  Hm = np.asarray(X, dtype=np.float32).reshape(3, 3)
337
334
  return cv2.warpPerspective(ch, Hm, (W, H), flags=cv2.INTER_LANCZOS4,
338
335
  borderMode=cv2.BORDER_CONSTANT, borderValue=0)
@@ -350,6 +347,9 @@ class RGBAlignDialog(QDialog):
350
347
  def __init__(self, parent=None, document=None):
351
348
  super().__init__(parent)
352
349
  self.setWindowTitle(self.tr("RGB Align"))
350
+ self.setWindowFlag(Qt.WindowType.Window, True)
351
+ self.setWindowModality(Qt.WindowModality.NonModal)
352
+ self.setModal(False)
353
353
  self.parent = parent
354
354
  # document could be a view; try to unwrap
355
355
  self.doc_view = document
@@ -20,6 +20,7 @@ class SaveOptionsDialog(QDialog):
20
20
  super().__init__(parent)
21
21
  self.setWindowTitle(self.tr("Save Options"))
22
22
  self.setWindowFlag(Qt.WindowType.Window, True)
23
+ self.setWindowModality(Qt.WindowModality.NonModal)
23
24
  self.setModal(False)
24
25
  #self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
25
26