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
@@ -16,6 +16,7 @@ import hashlib
16
16
  from numpy.lib.format import open_memmap
17
17
  import tzlocal
18
18
  import weakref
19
+ import ast
19
20
  import re
20
21
  import unicodedata
21
22
  import math # used in compute_safe_chunk
@@ -152,65 +153,256 @@ _WINDOWS_RESERVED = {
152
153
 
153
154
  _FITS_EXTS = ('.fits', '.fit', '.fts', '.fits.gz', '.fit.gz', '.fts.gz', '.fz')
154
155
 
156
+ def _coerce_fits_value(v):
157
+ """Convert XISF keyword 'value' strings to reasonable python scalars."""
158
+ if v is None:
159
+ return None
160
+ if isinstance(v, (int, float, bool)):
161
+ return v
162
+ s = str(v).strip()
163
+
164
+ # PixInsight often uses 'T'/'F'
165
+ if s in ("T", "TRUE", "True", "true"):
166
+ return True
167
+ if s in ("F", "FALSE", "False", "false"):
168
+ return False
169
+
170
+ # int?
171
+ try:
172
+ if s.isdigit() or (s.startswith(("+", "-")) and s[1:].isdigit()):
173
+ return int(s)
174
+ except Exception:
175
+ pass
176
+
177
+ # float?
178
+ try:
179
+ # handles "8.9669e+03", etc.
180
+ return float(s)
181
+ except Exception:
182
+ pass
183
+
184
+ # keep as string (strip surrounding quotes if present)
185
+ if (len(s) >= 2) and ((s[0] == s[-1]) and s[0] in ("'", '"')):
186
+ s = s[1:-1]
187
+ return s
188
+
189
+
190
+ def xisf_fits_header(path: str, image_index: int = 0) -> fits.Header:
191
+ """
192
+ Extract FITS keywords from XISF file into astropy.io.fits.Header.
193
+
194
+ Your XISF structure has:
195
+ ims[0]["FITSKeywords"][KEY] = [ {"value": "...", "comment": "..."}, ... ]
196
+ Sometimes nested under ims[0]["xisf_meta"] (dict or stringified dict).
197
+ """
198
+ hdr = fits.Header()
199
+ if XISF is None:
200
+ return hdr
201
+
202
+ x = XISF(path)
203
+ ims = x.get_images_metadata() or []
204
+ if not ims:
205
+ return hdr
206
+
207
+ im = ims[min(max(image_index, 0), len(ims) - 1)]
208
+
209
+ # 1) direct
210
+ kw = im.get("FITSKeywords")
211
+
212
+ # 2) nested inside xisf_meta dict
213
+ if kw is None:
214
+ xm = im.get("xisf_meta")
215
+ if isinstance(xm, dict):
216
+ kw = xm.get("FITSKeywords")
217
+
218
+ # 3) xisf_meta stringified dict (your dump shows this exact situation)
219
+ if kw is None:
220
+ xm = im.get("xisf_meta")
221
+ if isinstance(xm, str) and "FITSKeywords" in xm:
222
+ try:
223
+ xm2 = ast.literal_eval(xm)
224
+ if isinstance(xm2, dict):
225
+ kw = xm2.get("FITSKeywords")
226
+ except Exception:
227
+ kw = None
228
+
229
+ if not isinstance(kw, dict):
230
+ return hdr
231
+
232
+ # Build header
233
+ for key, entries in kw.items():
234
+ try:
235
+ k = str(key).strip()
236
+ if not k:
237
+ continue
238
+
239
+ # entries is usually a list of dicts: [{"value": "...", "comment":"..."}]
240
+ if isinstance(entries, list) and entries:
241
+ e0 = entries[0]
242
+ if isinstance(e0, dict):
243
+ val = _coerce_fits_value(e0.get("value"))
244
+ com = e0.get("comment")
245
+ else:
246
+ val = _coerce_fits_value(e0)
247
+ com = None
248
+ elif isinstance(entries, dict):
249
+ val = _coerce_fits_value(entries.get("value"))
250
+ com = entries.get("comment")
251
+ else:
252
+ val = _coerce_fits_value(entries)
253
+ com = None
254
+
255
+ if com is not None:
256
+ hdr[k] = (val, str(com))
257
+ else:
258
+ hdr[k] = val
259
+ except Exception:
260
+ # never let one bad keyword kill header extraction
261
+ pass
262
+
263
+ return hdr
264
+
155
265
  def get_valid_header(path: str):
156
266
  """
157
- Return a robust FITS header for both normal and compressed FITS.
267
+ Fast header-only peek with targeted fallback.
158
268
 
159
- - Opens the HDU list and picks the first image-like HDU (ndim >= 2).
160
- - Forces NAXIS, NAXIS1, NAXIS2 from the actual data.shape if possible.
161
- - Falls back to ZNAXIS1/2 for tile-compressed images.
269
+ FITS/FITS-like:
270
+ 1) Header-only scan (lazy_load_hdus=True, never touches .data)
271
+ 2) If NAXIS1/2 still missing/invalid, fallback to reading ONE image HDU's data
272
+ to infer shape, then patch NAXIS/NAXIS1/NAXIS2.
273
+
274
+ XISF:
275
+ - Parse XML header only (no pixel decode)
276
+ - Synthesize a FITS-like header dict with keys used by stacking ingest:
277
+ NAXIS1, NAXIS2, (optional NAXIS3), EXPOSURE/EXPTIME, IMAGETYP, FILTER, etc.
278
+
279
+ Returns: (hdr_like, ok_bool)
280
+ - hdr_like is an astropy Header for FITS, or a dict for XISF
162
281
  """
163
282
  try:
283
+ lp = (path or "").lower()
284
+
285
+ if lp.endswith(".xisf"):
286
+ from astropy.io import fits
287
+
288
+ # Grab FITS keywords from the XISF
289
+ hdr = xisf_fits_header(path)
290
+
291
+ # Still need geometry for NAXISn
292
+ x = XISF(path)
293
+ ims = x.get_images_metadata() or []
294
+ if ims:
295
+ im = ims[0]
296
+ w, h, chc = im.get("geometry", (0, 0, 0))
297
+ w = int(w or 0)
298
+ h = int(h or 0)
299
+ c = int(chc or 0)
300
+
301
+ hdr["NAXIS"] = 3 if c > 1 else 2
302
+ hdr["NAXIS1"] = w
303
+ hdr["NAXIS2"] = h
304
+ if c > 1:
305
+ hdr["NAXIS3"] = c
306
+
307
+ # Normalize exposure keyword convenience
308
+ if "EXPTIME" not in hdr and "EXPOSURE" in hdr:
309
+ hdr["EXPTIME"] = hdr["EXPOSURE"]
310
+ if "EXPOSURE" not in hdr and "EXPTIME" in hdr:
311
+ hdr["EXPOSURE"] = hdr["EXPTIME"]
312
+
313
+ return hdr, True
314
+
315
+ # ---------------------------
316
+ # FITS path (your existing logic)
317
+ # ---------------------------
164
318
  from astropy.io import fits
165
319
 
166
- with fits.open(path, memmap=False) as hdul:
320
+ def _is_good_dim(v):
321
+ try:
322
+ return int(v) > 0
323
+ except Exception:
324
+ return False
325
+
326
+ # ---------------------------
327
+ # Pass 1: header-only
328
+ # ---------------------------
329
+ with fits.open(path, mode="readonly", memmap=True, lazy_load_hdus=True) as hdul:
167
330
  science_hdu = None
168
331
 
169
- # Prefer the first HDU that actually has 2D+ image data
170
332
  for hdu in hdul:
171
- data = getattr(hdu, "data", None)
172
- if data is None:
173
- continue
174
- if getattr(data, "ndim", 0) >= 2:
333
+ hdr = hdu.header
334
+
335
+ # Prefer HDUs that *declare* 2D+ via header
336
+ naxis = hdr.get("NAXIS", None)
337
+ znaxis = hdr.get("ZNAXIS", None)
338
+
339
+ looks_2d = False
340
+ try:
341
+ if naxis is not None and int(naxis) >= 2:
342
+ looks_2d = True
343
+ except Exception:
344
+ pass
345
+ try:
346
+ if znaxis is not None and int(znaxis) >= 2:
347
+ looks_2d = True
348
+ except Exception:
349
+ pass
350
+
351
+ if looks_2d:
175
352
  science_hdu = hdu
176
353
  break
177
354
 
178
355
  if science_hdu is None:
179
- # Fallback: just use primary
180
356
  science_hdu = hdul[0]
181
357
 
182
358
  hdr = science_hdu.header.copy()
183
- data = science_hdu.data
184
359
 
185
- # --- Ensure NAXIS / NAXIS1 / NAXIS2 are real numbers ---
186
- try:
187
- if data is not None and getattr(data, "ndim", 0) >= 2:
188
- shape = data.shape
189
- # FITS: final axes are X, Y
190
- ny, nx = shape[-2], shape[-1]
191
- hdr["NAXIS"] = int(data.ndim)
192
- hdr["NAXIS1"] = int(nx)
193
- hdr["NAXIS2"] = int(ny)
194
- except Exception:
195
- pass
360
+ # Prefer normal NAXISn; fallback to ZNAXISn for tile-compressed
361
+ if not _is_good_dim(hdr.get("NAXIS1")) and _is_good_dim(hdr.get("ZNAXIS1")):
362
+ hdr["NAXIS1"] = int(hdr["ZNAXIS1"])
363
+ if not _is_good_dim(hdr.get("NAXIS2")) and _is_good_dim(hdr.get("ZNAXIS2")):
364
+ hdr["NAXIS2"] = int(hdr["ZNAXIS2"])
196
365
 
197
- # --- Extra fallback from ZNAXISn (tile-compressed FITS) ---
198
- for ax in (1, 2):
199
- key = f"NAXIS{ax}"
200
- zkey = f"ZNAXIS{ax}"
201
- val = hdr.get(key, None)
202
- if (val is None or (isinstance(val, str) and not val.strip())) and zkey in hdr:
203
- try:
204
- hdr[key] = int(hdr[zkey])
205
- except Exception:
206
- pass
366
+ # FAST PATH
367
+ if _is_good_dim(hdr.get("NAXIS1")) and _is_good_dim(hdr.get("NAXIS2")):
368
+ return hdr, True
207
369
 
208
- return hdr, True
370
+ # ---------------------------
371
+ # Pass 2: slow fallback (ONLY if needed)
372
+ # ---------------------------
373
+ with fits.open(path, mode="readonly", memmap=False) as hdul:
374
+ target_hdu = None
375
+ for hdu in hdul:
376
+ naxis = hdu.header.get("NAXIS", 0)
377
+ znaxis = hdu.header.get("ZNAXIS", 0)
209
378
 
210
- except Exception:
211
- return None, False
379
+ try:
380
+ if int(naxis) >= 2 or int(znaxis) >= 2:
381
+ target_hdu = hdu
382
+ break
383
+ except Exception:
384
+ continue
385
+
386
+ if target_hdu is None:
387
+ target_hdu = hdul[0]
388
+
389
+ data = getattr(target_hdu, "data", None)
390
+ hdr2 = target_hdu.header.copy()
391
+
392
+ if data is not None and getattr(data, "ndim", 0) >= 2:
393
+ try:
394
+ ny, nx = data.shape[-2], data.shape[-1]
395
+ hdr2["NAXIS"] = int(getattr(data, "ndim", hdr2.get("NAXIS", 2)))
396
+ hdr2["NAXIS1"] = int(nx)
397
+ hdr2["NAXIS2"] = int(ny)
398
+ return hdr2, True
399
+ except Exception:
400
+ pass
212
401
 
402
+ return hdr2, True
213
403
 
404
+ except Exception:
405
+ return None, False
214
406
 
215
407
 
216
408
  def _read_tile_stack(file_list, y0, y1, x0, x1, channels, out_buf):
@@ -1523,11 +1715,20 @@ class _MMFits:
1523
1715
  raise ValueError(f"Unsupported ndim={self.ndim} for {path}")
1524
1716
 
1525
1717
  def _apply_fixed_fits_scale(self, arr: np.ndarray) -> np.ndarray:
1718
+ """
1719
+ Map 8/16-bit FITS integer samples to [0,1] using a fixed divisor.
1720
+ IMPORTANT: Only do this for integer dtypes. If Astropy already returned
1721
+ float (e.g. BSCALE/BZERO applied), do NOT divide again.
1722
+ """
1723
+ # Only scale raw integer pixel arrays
1724
+ if arr.dtype.kind not in ("u", "i"):
1725
+ return arr
1726
+
1526
1727
  bitpix = getattr(self, "_bitpix", 0)
1527
1728
  if bitpix == 8:
1528
- arr /= 255.0
1729
+ return arr / 255.0
1529
1730
  elif bitpix == 16:
1530
- arr /= 65535.0
1731
+ return arr / 65535.0
1531
1732
  return arr
1532
1733
 
1533
1734
  def read_tile(self, y0, y1, x0, x1) -> np.ndarray:
@@ -1655,9 +1856,9 @@ class ReferenceFrameReviewDialog(QDialog):
1655
1856
  self.initUI()
1656
1857
  self.loadImageArray() # Load the image into self.original_image
1657
1858
  if self.original_image is not None:
1658
- self.updatePreview(self.original_image) # Ensure the first image is shown
1659
- if self.original_image is not None:
1660
- QTimer.singleShot(0, self.zoomIn)
1859
+ QTimer.singleShot(0, lambda: self.updatePreview(self.original_image, fit=True))
1860
+ #if self.original_image is not None:
1861
+ # QTimer.singleShot(0, self.zoomIn)
1661
1862
 
1662
1863
 
1663
1864
  def initUI(self):
@@ -1715,6 +1916,89 @@ class ReferenceFrameReviewDialog(QDialog):
1715
1916
  self.setLayout(main_layout)
1716
1917
  self.zoomIn()
1717
1918
 
1919
+ def _ensure_hwc(self, x: np.ndarray) -> np.ndarray:
1920
+ """Ensure HWC for RGB, HW for mono."""
1921
+ if x is None:
1922
+ return None
1923
+ x = np.asarray(x)
1924
+ # CHW -> HWC
1925
+ if x.ndim == 3 and x.shape[0] == 3 and x.shape[-1] != 3:
1926
+ x = np.transpose(x, (1, 2, 0))
1927
+ # squeeze HWC with singleton
1928
+ if x.ndim == 3 and x.shape[-1] == 1:
1929
+ x = np.squeeze(x, axis=-1)
1930
+ return x
1931
+
1932
+
1933
+ def _robust_preview_stretch(self, img: np.ndarray,
1934
+ lo_pct: float = 0.25,
1935
+ hi_pct: float = 99.75,
1936
+ gamma: float = 0.65) -> np.ndarray:
1937
+ """
1938
+ Robust preview stretch:
1939
+ - nan/inf safe
1940
+ - pedestal remove per channel (img - min)
1941
+ - percentile clip to kill outliers
1942
+ - scale to 0..1
1943
+ - gentle gamma (default <1 brightens)
1944
+ Returns float32 in [0,1] and preserves mono vs RGB.
1945
+ """
1946
+ x = self._ensure_hwc(img)
1947
+ if x is None:
1948
+ return None
1949
+
1950
+ x = np.asarray(x, dtype=np.float32)
1951
+ x = np.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0)
1952
+
1953
+ # Mono
1954
+ if x.ndim == 2:
1955
+ x = x - float(x.min())
1956
+ # percentile clip on non-flat data
1957
+ p_lo = float(np.percentile(x, lo_pct))
1958
+ p_hi = float(np.percentile(x, hi_pct))
1959
+ if p_hi > p_lo:
1960
+ x = np.clip(x, p_lo, p_hi)
1961
+ x = (x - p_lo) / (p_hi - p_lo)
1962
+ else:
1963
+ mx = float(x.max())
1964
+ if mx > 0:
1965
+ x = x / mx
1966
+ if gamma is not None and gamma > 0:
1967
+ x = np.power(np.clip(x, 0.0, 1.0), gamma)
1968
+ return np.clip(x, 0.0, 1.0).astype(np.float32, copy=False)
1969
+
1970
+ # RGB (HWC)
1971
+ if x.ndim == 3 and x.shape[2] == 3:
1972
+ out = np.empty_like(x, dtype=np.float32)
1973
+ for c in range(3):
1974
+ ch = x[..., c]
1975
+ ch = ch - float(ch.min())
1976
+ p_lo = float(np.percentile(ch, lo_pct))
1977
+ p_hi = float(np.percentile(ch, hi_pct))
1978
+ if p_hi > p_lo:
1979
+ ch = np.clip(ch, p_lo, p_hi)
1980
+ ch = (ch - p_lo) / (p_hi - p_lo)
1981
+ else:
1982
+ mx = float(ch.max())
1983
+ if mx > 0:
1984
+ ch = ch / mx
1985
+ out[..., c] = ch
1986
+
1987
+ if gamma is not None and gamma > 0:
1988
+ out = np.power(np.clip(out, 0.0, 1.0), gamma)
1989
+
1990
+ return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
1991
+
1992
+ # Fallback: treat as scalar field
1993
+ x = x - float(x.min())
1994
+ mx = float(x.max())
1995
+ if mx > 0:
1996
+ x = x / mx
1997
+ if gamma is not None and gamma > 0:
1998
+ x = np.power(np.clip(x, 0.0, 1.0), gamma)
1999
+ return np.clip(x, 0.0, 1.0).astype(np.float32, copy=False)
2000
+
2001
+
1718
2002
  def fitToPreview(self):
1719
2003
  """Calculate and set the zoom factor so that the image fills the preview area."""
1720
2004
  if self.original_image is None:
@@ -1741,32 +2025,46 @@ class ReferenceFrameReviewDialog(QDialog):
1741
2025
 
1742
2026
  def _normalize_preview_01(self, img: np.ndarray) -> np.ndarray:
1743
2027
  """
1744
- Normalize image to [0,1] for preview/stretch:
1745
-
1746
- 1. Handle NaNs/inf safely.
1747
- 2. If min < 0 or max > 1, do (img - min) / (max - min).
1748
- 3. Always return float32 in [0,1].
2028
+ Always normalize to [0,1]:
2029
+ img = img - min(img)
2030
+ img = img / max(img)
2031
+ Per-channel if RGB, global if mono.
1749
2032
  """
1750
2033
  if img is None:
1751
2034
  return None
1752
2035
 
1753
- img = np.asarray(img, dtype=np.float32)
1754
- img = np.nan_to_num(img, nan=0.0, posinf=0.0, neginf=0.0)
1755
-
1756
- finite = np.isfinite(img)
1757
- if not finite.any():
1758
- return np.zeros_like(img, dtype=np.float32)
1759
-
1760
- mn = float(img[finite].min())
1761
- mx = float(img[finite].max())
1762
- if mx == mn:
1763
- # flat frame β†’ just zero it
1764
- return np.zeros_like(img, dtype=np.float32)
2036
+ x = np.asarray(img, dtype=np.float32)
2037
+ x = np.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0)
1765
2038
 
1766
- if mn < 0.0 or mx > 1.0:
1767
- img = (img - mn) / (mx - mn)
2039
+ if x.ndim == 2:
2040
+ mn = float(x.min())
2041
+ x = x - mn
2042
+ mx = float(x.max())
2043
+ if mx > 0:
2044
+ x = x / mx
2045
+ return np.clip(x, 0.0, 1.0).astype(np.float32, copy=False)
2046
+
2047
+ if x.ndim == 3 and x.shape[2] == 3:
2048
+ # per-channel pedestal remove + normalize
2049
+ out = x.copy()
2050
+ for c in range(3):
2051
+ ch = out[..., c]
2052
+ mn = float(ch.min())
2053
+ ch = ch - mn
2054
+ mx = float(ch.max())
2055
+ if mx > 0:
2056
+ ch = ch / mx
2057
+ out[..., c] = ch
2058
+ return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
2059
+
2060
+ # fallback
2061
+ mn = float(x.min())
2062
+ x = x - mn
2063
+ mx = float(x.max())
2064
+ if mx > 0:
2065
+ x = x / mx
2066
+ return np.clip(x, 0.0, 1.0).astype(np.float32, copy=False)
1768
2067
 
1769
- return np.clip(img, 0.0, 1.0)
1770
2068
 
1771
2069
 
1772
2070
  def loadImageArray(self):
@@ -1789,22 +2087,44 @@ class ReferenceFrameReviewDialog(QDialog):
1789
2087
 
1790
2088
  self.original_image = img
1791
2089
 
2090
+ def _fit_zoom_to_viewport(self, image: np.ndarray):
2091
+ """Set zoom_factor so image fits inside the scrollArea viewport."""
2092
+ if image is None:
2093
+ return
2094
+
2095
+ img = self._ensure_hwc(image)
2096
+
2097
+ if img.ndim == 2:
2098
+ h, w = img.shape
2099
+ elif img.ndim == 3 and img.shape[2] == 3:
2100
+ h, w = img.shape[:2]
2101
+ else:
2102
+ return
2103
+
2104
+ vp = self.scrollArea.viewport().size()
2105
+ if vp.width() <= 0 or vp.height() <= 0 or w <= 0 or h <= 0:
2106
+ return
2107
+
2108
+ # Fit-to-viewport zoom
2109
+ self.zoom_factor = min(vp.width() / w, vp.height() / h)
1792
2110
 
1793
- def updatePreview(self, image):
1794
- """
1795
- Convert a given image array to a QPixmap and update the preview label.
1796
- """
2111
+ def updatePreview(self, image, *, fit: bool = False):
1797
2112
  self.current_preview_image = image
2113
+
2114
+ if fit:
2115
+ self._fit_zoom_to_viewport(image)
2116
+
1798
2117
  pixmap = self.convertArrayToPixmap(image)
1799
2118
  if pixmap is None or pixmap.isNull():
1800
2119
  self.previewLabel.setText(self.tr("Unable to load preview."))
1801
- else:
1802
- available_size = self.scrollArea.viewport().size()
1803
- new_size = QSize(int(available_size.width() * self.zoom_factor),
1804
- int(available_size.height() * self.zoom_factor))
1805
- scaled_pixmap = pixmap.scaled(new_size, Qt.AspectRatioMode.KeepAspectRatio,
1806
- Qt.TransformationMode.SmoothTransformation)
1807
- self.previewLabel.setPixmap(scaled_pixmap)
2120
+ return
2121
+
2122
+ scaled = pixmap.scaled(
2123
+ pixmap.size() * self.zoom_factor,
2124
+ Qt.AspectRatioMode.KeepAspectRatio,
2125
+ Qt.TransformationMode.SmoothTransformation
2126
+ )
2127
+ self.previewLabel.setPixmap(scaled)
1808
2128
 
1809
2129
  def _preview_boost(self, img: np.ndarray) -> np.ndarray:
1810
2130
  """Robust, very gentle stretch for display when image would quantize to black."""
@@ -1822,62 +2142,48 @@ class ReferenceFrameReviewDialog(QDialog):
1822
2142
  if image is None:
1823
2143
  return None
1824
2144
 
1825
- img = image.astype(np.float32, copy=False)
1826
-
1827
- # If image is so dim or flat that 8-bit will zero-out, boost for preview
1828
- ptp = float(img.max() - img.min())
1829
- needs_boost = (float(img.max()) <= (1.0 / 255.0)) or (ptp < 1e-6) or (not np.isfinite(img).all())
1830
- if needs_boost:
1831
- img = self._preview_boost(np.nan_to_num(img, nan=0.0, posinf=0.0, neginf=0.0))
2145
+ # ALWAYS normalize to [0,1]
2146
+ img = self._normalize_preview_01(image)
1832
2147
 
1833
2148
  # Convert to 8-bit for QImage
1834
2149
  display_image = (img * 255.0).clip(0, 255).astype(np.uint8)
1835
2150
 
2151
+ # IMPORTANT: ensure contiguous memory
2152
+ display_image = np.ascontiguousarray(display_image)
2153
+
2154
+ # Keep a reference so Qt's QImage always has valid backing memory
2155
+ self._last_preview_u8 = display_image
2156
+
1836
2157
  if display_image.ndim == 2:
1837
2158
  h, w = display_image.shape
1838
- q_image = QImage(display_image.data, w, h, w, QImage.Format.Format_Grayscale8)
2159
+ q_image = QImage(self._last_preview_u8.data, w, h, w, QImage.Format.Format_Grayscale8)
2160
+ q_image = q_image.copy() # detach from numpy buffer (extra safety)
1839
2161
  elif display_image.ndim == 3 and display_image.shape[2] == 3:
1840
2162
  h, w, _ = display_image.shape
1841
- q_image = QImage(display_image.data, w, h, 3 * w, QImage.Format.Format_RGB888)
2163
+ q_image = QImage(self._last_preview_u8.data, w, h, 3 * w, QImage.Format.Format_RGB888)
2164
+ q_image = q_image.copy() # detach
1842
2165
  else:
1843
2166
  return None
2167
+
1844
2168
  return QPixmap.fromImage(q_image)
1845
-
2169
+
1846
2170
  def toggleAutostretch(self):
1847
2171
  if self.original_image is None:
1848
2172
  QMessageBox.warning(self, self.tr("Error"), self.tr("Reference image not loaded."))
1849
2173
  return
1850
2174
 
1851
- # πŸ”Ή Ensure the image we feed to Statistical Stretch is in [0,1]
1852
- base = self._normalize_preview_01(self.original_image)
1853
-
1854
2175
  self.autostretch_enabled = not self.autostretch_enabled
2176
+
1855
2177
  if self.autostretch_enabled:
1856
- if base.ndim == 2:
1857
- new_image = stretch_mono_image(
1858
- base,
1859
- target_median=0.3,
1860
- normalize=True,
1861
- apply_curves=False
1862
- )
1863
- elif base.ndim == 3 and base.shape[2] == 3:
1864
- new_image = stretch_color_image(
1865
- base,
1866
- target_median=0.3,
1867
- linked=False,
1868
- normalize=True,
1869
- apply_curves=False
1870
- )
1871
- else:
1872
- new_image = base
2178
+ new_image = self._robust_preview_stretch(self.original_image)
1873
2179
  self.toggleAutoStretchButton.setText(self.tr("Disable Autostretch"))
1874
2180
  else:
1875
- new_image = base
2181
+ new_image = self._normalize_preview_01(self.original_image)
1876
2182
  self.toggleAutoStretchButton.setText(self.tr("Enable Autostretch"))
1877
2183
 
1878
- self.updatePreview(new_image)
2184
+ self.updatePreview(new_image, fit=True)
1879
2185
 
1880
-
2186
+
1881
2187
  def zoomIn(self):
1882
2188
  self.zoom_factor *= 1.2
1883
2189
  if self.current_preview_image is not None:
@@ -3340,7 +3646,8 @@ class _MMImage:
3340
3646
  self._orig_dtype = None
3341
3647
  self._color_axis = None
3342
3648
  self._spat_axes = (0, 1)
3343
-
3649
+ self._dbg = bool(os.environ.get("SASPRO_MMIMAGE_DEBUG", "0") == "1")
3650
+ self._dbg_count = 0
3344
3651
  self._xisf = None
3345
3652
  self._xisf_memmap = None # np.memmap when possible
3346
3653
  self._xisf_arr = None # decompressed ndarray when needed
@@ -3362,6 +3669,11 @@ class _MMImage:
3362
3669
  self._open_fits(path)
3363
3670
  self._kind = "fits"
3364
3671
 
3672
+ def _dbg_log(self, msg: str):
3673
+ if not getattr(self, "_dbg", False):
3674
+ return
3675
+ print(msg) # or your logger
3676
+
3365
3677
  # ---------------- FITS ----------------
3366
3678
  def _open_fits(self, path: str):
3367
3679
  """
@@ -3467,17 +3779,27 @@ class _MMImage:
3467
3779
  # ---------------- common API ----------------
3468
3780
  def _apply_fixed_fits_scale(self, arr: np.ndarray) -> np.ndarray:
3469
3781
  """
3470
- Map 8/16-bit FITS (already BZERO/BSCALE-scaled by Astropy) to [0,1]
3471
- using a fixed divisor. No per-frame img/max(img) normalization.
3782
+ Map 8/16-bit FITS integer samples to [0,1] using a fixed divisor.
3783
+ IMPORTANT: Only do this for integer dtypes. If Astropy already returned
3784
+ float (e.g. BSCALE/BZERO applied), do NOT divide again.
3472
3785
  """
3786
+ # Only scale raw integer pixel arrays
3787
+ if arr.dtype.kind not in ("u", "i"):
3788
+ return arr
3789
+
3473
3790
  bitpix = getattr(self, "_bitpix", 0)
3474
3791
  if bitpix == 8:
3475
- arr /= 255.0
3792
+ return arr / 255.0
3476
3793
  elif bitpix == 16:
3477
- arr /= 65535.0
3794
+ return arr / 65535.0
3478
3795
  return arr
3479
3796
 
3797
+
3480
3798
  def read_tile(self, y0, y1, x0, x1) -> np.ndarray:
3799
+ import os
3800
+ import numpy as np
3801
+
3802
+ # ---- FITS / XISF tile read (unchanged) ----
3481
3803
  if self._kind == "fits":
3482
3804
  d = self._fits_data
3483
3805
  if self.ndim == 2:
@@ -3491,23 +3813,23 @@ class _MMImage:
3491
3813
  tile = np.moveaxis(tile, self._color_axis, -1)
3492
3814
  else:
3493
3815
  if self._xisf_memmap is not None:
3494
- # memmapped (C,H,W) β†’ slice, then move to (H,W,C)
3495
3816
  C = 1 if self.ndim == 2 else self.shape[2]
3496
3817
  if C == 1:
3497
3818
  tile = self._xisf_memmap[0, y0:y1, x0:x1]
3498
3819
  else:
3499
- tile = np.moveaxis(
3500
- self._xisf_memmap[:, y0:y1, x0:x1], 0, -1
3501
- )
3820
+ tile = np.moveaxis(self._xisf_memmap[:, y0:y1, x0:x1], 0, -1)
3502
3821
  else:
3503
3822
  tile = self._xisf_arr[y0:y1, x0:x1]
3504
3823
 
3505
- # Cast to float32
3824
+ # Cast to float32 copy (what you actually feed the stacker)
3506
3825
  out = np.array(tile, dtype=np.float32, copy=True, order="C")
3507
3826
 
3508
- # For FITS, apply fixed 8/16-bit normalization
3827
+
3828
+ # ---- APPLY FIXED SCALE (your real suspect) ----
3509
3829
  if self._kind == "fits":
3510
- out = self._apply_fixed_fits_scale(out)
3830
+ out2 = self._apply_fixed_fits_scale(out)
3831
+
3832
+ out = out2
3511
3833
 
3512
3834
  # ensure (h,w,3) or (h,w)
3513
3835
  if out.ndim == 3 and out.shape[-1] not in (1, 3):
@@ -3515,6 +3837,7 @@ class _MMImage:
3515
3837
  out = np.moveaxis(out, 0, -1)
3516
3838
  if out.ndim == 3 and out.shape[-1] == 1:
3517
3839
  out = np.squeeze(out, axis=-1)
3840
+
3518
3841
  return out
3519
3842
 
3520
3843
  def read_full(self) -> np.ndarray:
@@ -3905,6 +4228,175 @@ def _bias_to_match_light(light_data, master_bias):
3905
4228
  return b[:, :, 0][None, :, :] # (H,W,1) -> (1,H,W)
3906
4229
  return b
3907
4230
 
4231
+ def _read_center_patch_via_mmimage(path: str, y0: int, y1: int, x0: int, x1: int):
4232
+ src = _MMImage(path)
4233
+ try:
4234
+ sub = src.read_tile(y0, y1, x0, x1)
4235
+ return sub
4236
+ finally:
4237
+ try:
4238
+ src.close()
4239
+ except Exception:
4240
+ pass
4241
+
4242
+ def _get_key_float(hdr: fits.Header, key: str):
4243
+ try:
4244
+ v = hdr.get(key, None)
4245
+ if v is None:
4246
+ return None
4247
+ # handle strings like "-10.0" or "-10 C"
4248
+ if isinstance(v, str):
4249
+ v = v.strip().replace("C", "").replace("Β°", "").strip()
4250
+ return float(v)
4251
+ except Exception:
4252
+ return None
4253
+
4254
+ def _collect_temp_stats(file_list: list[str]):
4255
+ ccd = []
4256
+ setp = []
4257
+ n_ccd = 0
4258
+ n_set = 0
4259
+
4260
+ for p in file_list:
4261
+ try:
4262
+ hdr = fits.getheader(p, memmap=True)
4263
+ except Exception:
4264
+ continue
4265
+
4266
+ v1 = _get_key_float(hdr, "CCD-TEMP")
4267
+ v2 = _get_key_float(hdr, "SET-TEMP")
4268
+
4269
+ if v1 is not None:
4270
+ ccd.append(v1); n_ccd += 1
4271
+ if v2 is not None:
4272
+ setp.append(v2); n_set += 1
4273
+
4274
+ def _stats(arr):
4275
+ if not arr:
4276
+ return None, None, None, None
4277
+ a = np.asarray(arr, dtype=np.float32)
4278
+ return float(np.median(a)), float(np.min(a)), float(np.max(a)), float(np.std(a))
4279
+
4280
+ c_med, c_min, c_max, c_std = _stats(ccd)
4281
+ s_med, s_min, s_max, s_std = _stats(setp)
4282
+
4283
+ return {
4284
+ "ccd_med": c_med, "ccd_min": c_min, "ccd_max": c_max, "ccd_std": c_std, "ccd_n": n_ccd,
4285
+ "set_med": s_med, "set_min": s_min, "set_max": s_max, "set_std": s_std, "set_n": n_set,
4286
+ "n_files": len(file_list),
4287
+ }
4288
+
4289
+ def _temp_to_stem_tag(temp_c: float, *, prefix: str = "") -> str:
4290
+ """
4291
+ Filename-safe temperature token:
4292
+ -10.0 -> 'm10p0C'
4293
+ +5.25 -> 'p5p3C' (rounded to 0.1C if you pass that in)
4294
+ Uses:
4295
+ m = minus, p = plus/decimal separator
4296
+ Never produces '_-' which your _normalize_master_stem would collapse.
4297
+ """
4298
+ try:
4299
+ t = float(temp_c)
4300
+ except Exception:
4301
+ return ""
4302
+
4303
+ sign = "m" if t < 0 else "p"
4304
+ t_abs = abs(t)
4305
+
4306
+ # keep one decimal place (match your earlier plan)
4307
+ s = f"{t_abs:.1f}" # e.g. "10.0"
4308
+ s = s.replace(".", "p") # e.g. "10p0"
4309
+ return f"{prefix}{sign}{s}C"
4310
+
4311
+
4312
+ def _arr_stats(a: np.ndarray):
4313
+ a = np.asarray(a)
4314
+ fin = np.isfinite(a)
4315
+ if fin.any():
4316
+ v = a[fin]
4317
+ return dict(
4318
+ dtype=str(a.dtype),
4319
+ shape=tuple(a.shape),
4320
+ finite=int(fin.sum()),
4321
+ nan=int(np.isnan(a).sum()),
4322
+ inf=int(np.isinf(a).sum()),
4323
+ min=float(v.min()),
4324
+ max=float(v.max()),
4325
+ p01=float(np.percentile(v, 1)),
4326
+ p50=float(np.percentile(v, 50)),
4327
+ p99=float(np.percentile(v, 99)),
4328
+ mean=float(v.mean()),
4329
+ )
4330
+ return dict(dtype=str(a.dtype), shape=tuple(a.shape), finite=0, nan=int(np.isnan(a).sum()), inf=int(np.isinf(a).sum()))
4331
+
4332
+ def _print_stats(tag: str, a: np.ndarray, *, bit_depth=None, hdr=None):
4333
+ s = _arr_stats(a)
4334
+ bd = f", bit_depth={bit_depth}" if bit_depth is not None else ""
4335
+ print(f"πŸ§ͺ {tag}{bd} dtype={s['dtype']} shape={s['shape']} finite={s['finite']} nan={s['nan']} inf={s['inf']}")
4336
+ if s["finite"] > 0:
4337
+ print(f" min={s['min']:.6f} p01={s['p01']:.6f} p50={s['p50']:.6f} p99={s['p99']:.6f} max={s['max']:.6f} mean={s['mean']:.6f}")
4338
+ # Header hints (best-effort)
4339
+ if hdr is not None:
4340
+ try:
4341
+ # FITS-ish
4342
+ if hasattr(hdr, "get"):
4343
+ print(f" hdr: BITPIX={hdr.get('BITPIX', 'NA')} BSCALE={hdr.get('BSCALE', 'NA')} BZERO={hdr.get('BZERO', 'NA')}")
4344
+ except Exception:
4345
+ pass
4346
+
4347
+ def _warn_if_units_mismatch(light: np.ndarray, dark: np.ndarray | None, flat: np.ndarray | None):
4348
+ # Heuristic: if one is ~0..1 and another is hundreds/thousands, you’ve got mixed scaling.
4349
+ def _range_kind(a):
4350
+ if a is None:
4351
+ return None
4352
+ fin = np.isfinite(a)
4353
+ if not fin.any():
4354
+ return None
4355
+ mx = float(np.max(a[fin]))
4356
+ mn = float(np.min(a[fin]))
4357
+ return (mn, mx)
4358
+
4359
+ lr = _range_kind(light)
4360
+ dr = _range_kind(dark)
4361
+ fr = _range_kind(flat)
4362
+
4363
+ def _is_01(r):
4364
+ if r is None: return False
4365
+ mn, mx = r
4366
+ return mx <= 2.5 and mn >= -0.5
4367
+
4368
+ def _is_aduish(r):
4369
+ if r is None: return False
4370
+ mn, mx = r
4371
+ return mx >= 50.0 # conservative
4372
+
4373
+ if lr and dr and _is_01(lr) and _is_aduish(dr):
4374
+ print("🚨 UNITS MISMATCH: light looks ~0–1, but dark looks like ADU (tens/hundreds/thousands). Expect huge negatives after subtraction.")
4375
+ if lr and fr and _is_01(lr) and _is_aduish(fr):
4376
+ print("🚨 UNITS MISMATCH: light looks ~0–1, but flat looks like ADU. Flat division will be wrong unless normalized to ~1 first.")
4377
+
4378
+ def _maybe_normalize_16bit_float(a: np.ndarray, *, name: str = "") -> np.ndarray:
4379
+ """
4380
+ Fast guard:
4381
+ - If float array has max > 10, assume it's really 16-bit ADU data stored as float,
4382
+ and normalize to 0..1 by dividing by 65535.
4383
+ """
4384
+ if a is None:
4385
+ return a
4386
+ if not np.issubdtype(a.dtype, np.floating):
4387
+ return a
4388
+
4389
+ fin = np.isfinite(a)
4390
+ if not fin.any():
4391
+ return a
4392
+
4393
+ mx = float(a[fin].max()) # fast reduction
4394
+
4395
+ if mx > 10.0:
4396
+ print(f"πŸ›‘οΈ Units-guard: {name or 'array'} max={mx:.3f} (>10). Assuming 16-bit ADU-in-float; normalizing /65535.")
4397
+ return (a / 65535.0).astype(np.float32, copy=False)
4398
+
4399
+ return a
3908
4400
 
3909
4401
  class StackingSuiteDialog(QDialog):
3910
4402
  requestRelaunch = pyqtSignal(str, str) # old_dir, new_dir
@@ -3916,7 +4408,11 @@ class StackingSuiteDialog(QDialog):
3916
4408
  self._wrench_path = wrench_path
3917
4409
  self._spinner_path = spinner_path
3918
4410
  self._post_progress_label = None
3919
-
4411
+ self._dark_group_item = {} # key -> QTreeWidgetItem
4412
+ self._flat_filter_item = {} # filter_name -> QTreeWidgetItem
4413
+ self._flat_exp_item = {} # (filter_name, want_label) -> QTreeWidgetItem
4414
+ self._light_filter_item = {} # filter_name -> QTreeWidgetItem
4415
+ self._light_exp_item = {} # (filter_name, want_label) -> QTreeWidgetItem
3920
4416
 
3921
4417
  self.setWindowTitle(self.tr("Stacking Suite"))
3922
4418
  self.setGeometry(300, 200, 800, 600)
@@ -4040,7 +4536,7 @@ class StackingSuiteDialog(QDialog):
4040
4536
  self.image_integration_tab = self.create_image_registration_tab()
4041
4537
 
4042
4538
  # Add tabs
4043
- self.tabs.addTab(self.conversion_tab, self.tr("Convert Non-FITS Formats"))
4539
+ self.tabs.addTab(self.conversion_tab, self.tr("Convert Camera RAW/TIFF Formats"))
4044
4540
  self.tabs.addTab(self.dark_tab, self.tr("Darks"))
4045
4541
  self.tabs.addTab(self.flat_tab, self.tr("Flats"))
4046
4542
  self.tabs.addTab(self.light_tab, self.tr("Lights"))
@@ -5123,8 +5619,9 @@ class StackingSuiteDialog(QDialog):
5123
5619
  disto_form.addRow(self.tr("Max control points:"), self.align_max_cp)
5124
5620
 
5125
5621
  self.align_downsample = QSpinBox()
5126
- self.align_downsample.setRange(1, 8)
5127
- self.align_downsample.setValue(self.settings.value("stacking/align/downsample", 2, type=int))
5622
+ self.align_downsample.setRange(1, 64) # or 1..32; 64 if you want β€œany integer”
5623
+ self.align_downsample.setValue(self.settings.value("stacking/align/downsample", 3, type=int))
5624
+ self.align_downsample.setToolTip(self.tr("Alignment solve downsample. 1 = full res; higher = faster but less accurate."))
5128
5625
  disto_form.addRow(self.tr("Solve downsample:"), self.align_downsample)
5129
5626
 
5130
5627
  # Homography / Similarity-specific RANSAC reprojection threshold
@@ -6012,12 +6509,19 @@ class StackingSuiteDialog(QDialog):
6012
6509
  w.blockSignals(True); w.setValue(v); w.blockSignals(False)
6013
6510
 
6014
6511
  def _get_drizzle_scale(self) -> float:
6015
- # Accepts "1x/2x/3x" or numeric
6016
- val = self.settings.value("stacking/drizzle_scale", "2x", type=str)
6017
- if isinstance(val, str) and val.endswith("x"):
6018
- try: return float(val[:-1])
6019
- except: return 2.0
6020
- return float(val)
6512
+ val = self.settings.value("stacking/drizzle_scale", "2x")
6513
+ if isinstance(val, (int, float)):
6514
+ return float(val)
6515
+ if isinstance(val, str):
6516
+ s = val.strip().lower()
6517
+ if s.endswith("x"):
6518
+ s = s[:-1]
6519
+ try:
6520
+ return float(s)
6521
+ except Exception:
6522
+ return 2.0
6523
+ return 2.0
6524
+
6021
6525
 
6022
6526
  def _set_drizzle_scale(self, r: float | str) -> None:
6023
6527
  if isinstance(r, str):
@@ -6033,6 +6537,25 @@ class StackingSuiteDialog(QDialog):
6033
6537
  self.drizzle_scale_combo.setCurrentText(txt)
6034
6538
  self.drizzle_scale_combo.blockSignals(False)
6035
6539
 
6540
+ def _get_drizzle_enabled(self) -> bool:
6541
+ # UI checkbox wins if it exists (most β€œlive” truth)
6542
+ cb = getattr(self, "drizzle_checkbox", None)
6543
+ if cb is not None:
6544
+ try:
6545
+ return bool(cb.isChecked())
6546
+ except Exception:
6547
+ pass
6548
+ # fallback to settings (headless / older flows)
6549
+ return bool(self.settings.value("stacking/drizzle_enabled", False, type=bool))
6550
+
6551
+ def _set_drizzle_enabled(self, on: bool) -> None:
6552
+ on = bool(on)
6553
+ self.settings.setValue("stacking/drizzle_enabled", on)
6554
+ cb = getattr(self, "drizzle_checkbox", None)
6555
+ if cb is not None and cb.isChecked() != on:
6556
+ cb.blockSignals(True)
6557
+ cb.setChecked(on)
6558
+ cb.blockSignals(False)
6036
6559
 
6037
6560
  def closeEvent(self, e):
6038
6561
  # Graceful shutdown for any running workers
@@ -6426,6 +6949,22 @@ class StackingSuiteDialog(QDialog):
6426
6949
 
6427
6950
  return tab
6428
6951
 
6952
+ def _bucket_temp(self, t: float | None, step: float = 3.0) -> float | None:
6953
+ """Round to stable bucket. Example: -10.2 -> -10.0 when step=1.0"""
6954
+ if t is None:
6955
+ return None
6956
+ try:
6957
+ return round(float(t) / float(step)) * float(step)
6958
+ except Exception:
6959
+ return None
6960
+
6961
+ def _temp_label(self, t: float | None, step: float = 1.0) -> str:
6962
+ if t is None:
6963
+ return "Temp: Unknown"
6964
+ # show fewer decimals if step is 1.0
6965
+ return f"Temp: {t:+.0f}C" if step >= 1.0 else f"Temp: {t:+.1f}C"
6966
+
6967
+
6429
6968
  def _tree_for_type(self, t: str):
6430
6969
  t = (t or "").upper()
6431
6970
  if t == "LIGHT": return getattr(self, "light_tree", None)
@@ -8088,13 +8627,18 @@ class StackingSuiteDialog(QDialog):
8088
8627
  mf_row3.addWidget(self.mf_Huber_hint)
8089
8628
 
8090
8629
  mf_row3.addSpacing(16)
8630
+
8091
8631
  self.mf_use_star_mask_cb = QCheckBox(self.tr("Auto Star Mask"))
8092
8632
  self.mf_use_noise_map_cb = QCheckBox(self.tr("Auto Noise Map"))
8093
- self.mf_use_star_mask_cb.setChecked(self.settings.value("stacking/mfdeconv/use_star_masks", False, type=bool))
8094
- self.mf_use_noise_map_cb.setChecked(self.settings.value("stacking/mfdeconv/use_noise_maps", False, type=bool))
8633
+
8634
+ # Always ON by default (session-only toggles)
8635
+ self.mf_use_star_mask_cb.setChecked(True)
8636
+ self.mf_use_noise_map_cb.setChecked(True)
8637
+
8095
8638
  mf_row3.addWidget(self.mf_use_star_mask_cb)
8096
8639
  mf_row3.addWidget(self.mf_use_noise_map_cb)
8097
8640
  mf_row3.addStretch(1)
8641
+
8098
8642
  mf_v.addLayout(mf_row3)
8099
8643
 
8100
8644
  # persist
@@ -8873,9 +9417,13 @@ class StackingSuiteDialog(QDialog):
8873
9417
  # ensure attrs exist
8874
9418
  if not hasattr(self, "_reg_excluded_files"):
8875
9419
  self._reg_excluded_files = set()
8876
- if not hasattr(self, "deleted_calibrated_files"):
8877
- self.deleted_calibrated_files = []
8878
9420
 
9421
+ # Track "removed from Registration tab" for this session so stacking won't use them
9422
+ if (not hasattr(self, "deleted_calibrated_files")) or (self.deleted_calibrated_files is None):
9423
+ self.deleted_calibrated_files = set()
9424
+ elif isinstance(self.deleted_calibrated_files, list):
9425
+ # backward compat if you previously used list
9426
+ self.deleted_calibrated_files = set(self.deleted_calibrated_files)
8879
9427
  removed_paths = []
8880
9428
 
8881
9429
  for item in selected_items:
@@ -8931,18 +9479,23 @@ class StackingSuiteDialog(QDialog):
8931
9479
  # If you want a separate "Exclude" feature later, keep _reg_excluded_files for that.
8932
9480
  # For now, removing should be reversible via "Add Light Files".
8933
9481
 
9482
+ # Persist "removed from registration" list (session)
9483
+ dead = {os.path.normcase(os.path.abspath(p)) for p in removed_paths if isinstance(p, str)}
9484
+ if dead:
9485
+ self.deleted_calibrated_files |= dead
9486
+
8934
9487
  # Also prune manual list so it doesn't re-inject removed files *in this session*
8935
9488
  if hasattr(self, "manual_light_files") and self.manual_light_files:
8936
- dead = {os.path.normcase(os.path.abspath(p)) for p in removed_paths if isinstance(p, str)}
8937
9489
  self.manual_light_files = [
8938
9490
  p for p in self.manual_light_files
8939
9491
  if os.path.normcase(os.path.abspath(p)) not in dead
8940
9492
  ]
8941
9493
 
8942
9494
  # refresh UI
8943
- self.populate_calibrated_lights()
9495
+ # IMPORTANT: do NOT call populate_calibrated_lights() here, it can resurrect removed items
8944
9496
  self._refresh_reg_tree_summaries()
8945
9497
 
9498
+
8946
9499
  def rebuild_flat_tree(self):
8947
9500
  """Regroup flat frames in the flat_tree based on the exposure tolerance."""
8948
9501
  self.flat_tree.clear()
@@ -9287,6 +9840,22 @@ class StackingSuiteDialog(QDialog):
9287
9840
  return (f"Drizzle: True, Scale: {scale:g}x, Drop: {drop:.2f}"
9288
9841
  if enabled else "Drizzle: False")
9289
9842
 
9843
+ def _get_group_key(self, top_item) -> str:
9844
+ """Stable key for a group item; survives UI text decoration."""
9845
+ key = top_item.data(0, Qt.ItemDataRole.UserRole)
9846
+ if key:
9847
+ return str(key)
9848
+ # fallback to visible text if older items don't have it yet
9849
+ return str(top_item.text(0)).strip()
9850
+
9851
+ def _ensure_group_key(self, top_item, group_key: str | None = None) -> str:
9852
+ """Set canonical key on item if missing."""
9853
+ if group_key is None:
9854
+ group_key = str(top_item.text(0)).strip()
9855
+ if not top_item.data(0, Qt.ItemDataRole.UserRole):
9856
+ top_item.setData(0, Qt.ItemDataRole.UserRole, group_key)
9857
+ return str(group_key)
9858
+
9290
9859
  def _set_drizzle_on_items(self, items, enabled: bool, scale: float, drop: float):
9291
9860
  txt_on = self._format_drizzle_text(True, scale, drop)
9292
9861
  txt_off = self._format_drizzle_text(False, scale, drop)
@@ -9294,7 +9863,8 @@ class StackingSuiteDialog(QDialog):
9294
9863
  # dedupe child selection β†’ parent group
9295
9864
  if it.parent() is not None:
9296
9865
  it = it.parent()
9297
- group_key = it.text(0)
9866
+ # Canonical key stored on the item (NOT display label)
9867
+ group_key = self._ensure_group_key(it)
9298
9868
  it.setText(2, txt_on if enabled else txt_off)
9299
9869
  self.per_group_drizzle[group_key] = {
9300
9870
  "enabled": bool(enabled),
@@ -9319,11 +9889,10 @@ class StackingSuiteDialog(QDialog):
9319
9889
  return
9320
9890
 
9321
9891
  for item in selected_items:
9322
- # If the user selected a child row, go up to its parent group
9323
9892
  if item.parent() is not None:
9324
9893
  item = item.parent()
9325
9894
 
9326
- group_key = item.text(0)
9895
+ group_key = self._ensure_group_key(item) # βœ… stable key
9327
9896
 
9328
9897
  if drizzle_enabled:
9329
9898
  # Show scale + drop shrink
@@ -9355,7 +9924,7 @@ class StackingSuiteDialog(QDialog):
9355
9924
  seen, targets = set(), []
9356
9925
  for it in sel:
9357
9926
  top = it if it.parent() is None else it.parent()
9358
- key = top.text(0)
9927
+ key = self._ensure_group_key(top)
9359
9928
  if key not in seen:
9360
9929
  seen.add(key); targets.append(top)
9361
9930
  else:
@@ -9378,7 +9947,7 @@ class StackingSuiteDialog(QDialog):
9378
9947
 
9379
9948
  out = {}
9380
9949
  for top in self._iter_group_items():
9381
- group_key = top.text(0)
9950
+ group_key = self._ensure_group_key(top) # βœ… stable key
9382
9951
  state = self.per_group_drizzle.get(group_key)
9383
9952
  if not state:
9384
9953
  state = {"enabled": global_enabled, "scale": global_scale, "drop": global_drop}
@@ -9554,7 +10123,10 @@ class StackingSuiteDialog(QDialog):
9554
10123
  def load_master_dark(self):
9555
10124
  """ Loads a Master Dark and updates the UI. """
9556
10125
  last_dir = self.settings.value("last_opened_folder", "", type=str) # Get last folder
9557
- files, _ = QFileDialog.getOpenFileNames(self, "Select Master Dark", last_dir, "FITS Files (*.fits *.fit)")
10126
+ files, _ = QFileDialog.getOpenFileNames(
10127
+ self, "Select Master Dark", last_dir,
10128
+ "Master Calibration (*.fits *.fit *.xisf);;All Files (*)"
10129
+ )
9558
10130
 
9559
10131
  if files:
9560
10132
  self.settings.setValue("last_opened_folder", os.path.dirname(files[0])) # Save last used folder
@@ -9569,7 +10141,10 @@ class StackingSuiteDialog(QDialog):
9569
10141
 
9570
10142
  def load_master_flat(self):
9571
10143
  last_dir = self.settings.value("last_opened_folder", "", type=str)
9572
- files, _ = QFileDialog.getOpenFileNames(self, "Select Master Flat", last_dir, "FITS Files (*.fits *.fit)")
10144
+ files, _ = QFileDialog.getOpenFileNames(
10145
+ self, "Select Master Flat", last_dir,
10146
+ "Master Calibration (*.fits *.fit *.xisf);;All Files (*)"
10147
+ )
9573
10148
 
9574
10149
  if files:
9575
10150
  self.settings.setValue("last_opened_folder", os.path.dirname(files[0]))
@@ -9582,7 +10157,7 @@ class StackingSuiteDialog(QDialog):
9582
10157
  last_dir = self.settings.value("last_opened_folder", "", type=str)
9583
10158
  files, _ = QFileDialog.getOpenFileNames(
9584
10159
  self, title, last_dir,
9585
- "FITS Files (*.fits *.fit *.fts *.fits.gz *.fit.gz *.fz)"
10160
+ "Images (*.fits *.fit *.fts *.fits.gz *.fit.gz *.fz *.xisf);;All Files (*)"
9586
10161
  )
9587
10162
  if not files:
9588
10163
  return
@@ -9661,7 +10236,7 @@ class StackingSuiteDialog(QDialog):
9661
10236
 
9662
10237
  # --- Directory walking ---------------------------------------------------------
9663
10238
  def _collect_fits_paths(self, root: str, recursive: bool = True) -> list[str]:
9664
- exts = (".fits", ".fit", ".fts", ".fits.gz", ".fit.gz", ".fz")
10239
+ exts = (".fits", ".fit", ".fts", ".fits.gz", ".fit.gz", ".fz", ".xisf")
9665
10240
  paths = []
9666
10241
  if recursive:
9667
10242
  for d, _subdirs, files in os.walk(root):
@@ -10000,24 +10575,32 @@ class StackingSuiteDialog(QDialog):
10000
10575
  manual_session_name = self._resolve_manual_session_name_for_ingest()
10001
10576
 
10002
10577
  added = 0
10003
- for i, path in enumerate(paths, start=1):
10004
- if dlg.wasCanceled():
10005
- break
10006
- try:
10007
- base = os.path.basename(path)
10008
- dlg.setLabelText(f"{base} ({i}/{total})")
10009
- QCoreApplication.processEvents()
10578
+ tree.setUpdatesEnabled(False)
10579
+ tree.blockSignals(True)
10580
+ try:
10581
+ for i, path in enumerate(paths, start=1):
10582
+ if dlg.wasCanceled():
10583
+ break
10584
+ try:
10585
+ base = os.path.basename(path)
10586
+ dlg.setLabelText(f"{base} ({i}/{total})")
10587
+ QCoreApplication.processEvents()
10010
10588
 
10011
- self.process_fits_header(
10012
- path, tree, expected_type,
10013
- manual_session_name=manual_session_name
10014
- )
10015
- added += 1
10016
- except Exception:
10017
- pass
10589
+ self.process_fits_header(
10590
+ path, tree, expected_type,
10591
+ manual_session_name=manual_session_name
10592
+ )
10593
+ added += 1
10594
+ except Exception:
10595
+ pass
10596
+
10597
+ dlg.setValue(i)
10598
+ QCoreApplication.processEvents()
10599
+ finally:
10600
+ tree.blockSignals(False)
10601
+ tree.setUpdatesEnabled(True)
10602
+ tree.viewport().update()
10018
10603
 
10019
- dlg.setValue(i)
10020
- QCoreApplication.processEvents()
10021
10604
 
10022
10605
  dlg.setValue(total)
10023
10606
  QCoreApplication.processEvents()
@@ -10096,14 +10679,14 @@ class StackingSuiteDialog(QDialog):
10096
10679
  try:
10097
10680
  expected_type_u = (expected_type or "").upper()
10098
10681
 
10099
- # Ensure caches exist
10100
10682
  if not hasattr(self, "_mismatch_policy") or self._mismatch_policy is None:
10101
10683
  self._mismatch_policy = {}
10102
10684
  if not hasattr(self, "session_tags") or self.session_tags is None:
10103
10685
  self.session_tags = {}
10104
10686
 
10105
- # --- Read header only (fast) ---
10106
- header, _ = get_valid_header(path)
10687
+ header, ok = get_valid_header(path)
10688
+ if not ok or header is None:
10689
+ raise RuntimeError("Header read failed")
10107
10690
 
10108
10691
  # --- Basic image size ---
10109
10692
  try:
@@ -10111,7 +10694,9 @@ class StackingSuiteDialog(QDialog):
10111
10694
  height = int(header.get("NAXIS2", 0))
10112
10695
  image_size = f"{width}x{height}" if (width > 0 and height > 0) else "Unknown"
10113
10696
  except Exception as e:
10114
- self.update_status(self.tr(f"Warning: Could not read dimensions for {os.path.basename(path)}: {e}"))
10697
+ self.update_status(self.tr(
10698
+ f"Warning: Could not read dimensions for {os.path.basename(path)}: {e}"
10699
+ ))
10115
10700
  width = height = None
10116
10701
  image_size = "Unknown"
10117
10702
 
@@ -10128,7 +10713,6 @@ class StackingSuiteDialog(QDialog):
10128
10713
  exposure_text = f"{fexp:g}s"
10129
10714
  except Exception:
10130
10715
  exposure_text = str(exp_val)
10131
-
10132
10716
  # --- Mismatch prompt (redirect/keep/skip with 'apply to all') ---
10133
10717
  if expected_type_u == "DARK":
10134
10718
  forbidden = ["light", "flat"]
@@ -10201,58 +10785,116 @@ class StackingSuiteDialog(QDialog):
10201
10785
 
10202
10786
  # --- Resolve session tag (auto vs keyword-driven) ---
10203
10787
  auto_session = self.settings.value("stacking/auto_session", True, type=bool)
10204
-
10205
10788
  if auto_session:
10206
10789
  session_tag = self._auto_session_from_path(path, header) or "Default"
10207
10790
  else:
10208
- # NOTE: this is a keyword now, not a literal session name
10209
10791
  keyword = self.settings.value("stacking/session_keyword", "Default", type=str)
10210
10792
  session_tag = self._session_from_manual_keyword(path, keyword) or "Default"
10211
10793
 
10212
- # --- Filter name normalization ---
10213
- filter_name_raw = header.get("FILTER", "Unknown")
10214
- filter_name = self._sanitize_name(filter_name_raw)
10794
+ # --- Temperature (fast: header already loaded) ---
10795
+ ccd_temp = header.get("CCD-TEMP", None)
10796
+ set_temp = header.get("SET-TEMP", None)
10797
+
10798
+ def _to_float_temp(v):
10799
+ try:
10800
+ if v is None:
10801
+ return None
10802
+ if isinstance(v, (int, float)):
10803
+ return float(v)
10804
+ s = str(v).strip()
10805
+ s = s.replace("Β°", "").replace("C", "").replace("c", "").strip()
10806
+ return float(s)
10807
+ except Exception:
10808
+ return None
10809
+
10810
+ ccd_temp_f = _to_float_temp(ccd_temp)
10811
+ set_temp_f = _to_float_temp(set_temp)
10812
+ use_temp_f = ccd_temp_f if ccd_temp_f is not None else set_temp_f
10813
+
10814
+ # --- Common metadata string for leaf rows ---
10815
+ meta_text = f"Size: {image_size} | Session: {session_tag}"
10816
+ if use_temp_f is not None:
10817
+ meta_text += f" | Temp: {use_temp_f:.1f}C"
10818
+ if set_temp_f is not None:
10819
+ meta_text += f" (Set: {set_temp_f:.1f}C)"
10215
10820
 
10216
10821
  # --- Common metadata string for leaf rows ---
10217
10822
  meta_text = f"Size: {image_size} | Session: {session_tag}"
10218
10823
 
10219
10824
  # === DARKs ===
10220
10825
  if expected_type_u == "DARK":
10221
- key = f"{exposure_text} ({image_size})"
10222
- self.dark_files.setdefault(key, []).append(path)
10223
- self.session_tags[path] = session_tag # not strictly needed, but consistent
10826
+ # --- temperature for grouping (prefer CCD-TEMP else SET-TEMP) ---
10827
+ ccd_t = _get_key_float(header, "CCD-TEMP")
10828
+ set_t = _get_key_float(header, "SET-TEMP")
10829
+ chosen_t = ccd_t if ccd_t is not None else set_t
10224
10830
 
10225
- items = tree.findItems(key, Qt.MatchFlag.MatchExactly, 0)
10226
- exposure_item = items[0] if items else QTreeWidgetItem([key])
10227
- if not items:
10228
- tree.addTopLevelItem(exposure_item)
10831
+ temp_step = self.settings.value("stacking/temp_group_step", 1.0, type=float)
10832
+ temp_bucket = self._bucket_temp(chosen_t, step=temp_step)
10833
+ temp_label = self._temp_label(temp_bucket, step=temp_step)
10229
10834
 
10230
- leaf = QTreeWidgetItem([os.path.basename(path), meta_text])
10835
+ # --- tree grouping: exposure/size -> temp bucket -> files ---
10836
+ base_key = f"{exposure_text} ({image_size})"
10837
+
10838
+ # ensure caches exist
10839
+ if not hasattr(self, "_dark_group_item") or self._dark_group_item is None:
10840
+ self._dark_group_item = {}
10841
+ if not hasattr(self, "_dark_temp_item") or self._dark_temp_item is None:
10842
+ self._dark_temp_item = {} # (base_key, temp_label) -> QTreeWidgetItem
10843
+
10844
+ # top-level exposure group
10845
+ exposure_item = self._dark_group_item.get(base_key)
10846
+ if exposure_item is None:
10847
+ exposure_item = QTreeWidgetItem([base_key, ""])
10848
+ tree.addTopLevelItem(exposure_item)
10849
+ self._dark_group_item[base_key] = exposure_item
10850
+
10851
+ # second-level temp group under that exposure group
10852
+ temp_key = (base_key, temp_label)
10853
+ temp_item = self._dark_temp_item.get(temp_key)
10854
+ if temp_item is None:
10855
+ temp_item = QTreeWidgetItem([temp_label, ""])
10856
+ exposure_item.addChild(temp_item)
10857
+ self._dark_temp_item[temp_key] = temp_item
10858
+
10859
+ # --- store in dict for stacking ---
10860
+ # Key includes session + temp bucket so create_master_dark can split properly.
10861
+ # (We keep compatibility: your create_master_dark already handles tuple keys.)
10862
+ composite_key = (base_key, session_tag, temp_bucket)
10863
+ self.dark_files.setdefault(composite_key, []).append(path)
10864
+
10865
+ # --- leaf row ---
10866
+ # Also add temp info to metadata text so user can see it per file
10867
+ meta_text_dark = f"Size: {image_size} | Session: {session_tag} | {temp_label}"
10868
+ leaf = QTreeWidgetItem([os.path.basename(path), meta_text_dark])
10231
10869
  leaf.setData(0, Qt.ItemDataRole.UserRole, path)
10232
- leaf.setData(0, Qt.ItemDataRole.UserRole + 1, session_tag) # βœ… helpful later for retag/rekey
10233
- exposure_item.addChild(leaf)
10870
+ leaf.setData(0, Qt.ItemDataRole.UserRole + 1, session_tag)
10871
+ leaf.setData(0, Qt.ItemDataRole.UserRole + 2, temp_bucket) # handy later
10872
+ temp_item.addChild(leaf)
10234
10873
 
10235
10874
  # === FLATs ===
10236
10875
  elif expected_type_u == "FLAT":
10876
+ filter_name_raw = header.get("FILTER") or "Unknown"
10877
+ filter_name = self._sanitize_name(filter_name_raw)
10878
+
10237
10879
  flat_key = f"{filter_name} - {exposure_text} ({image_size})"
10238
10880
  composite_key = (flat_key, session_tag)
10239
10881
  self.flat_files.setdefault(composite_key, []).append(path)
10240
10882
  self.session_tags[path] = session_tag
10241
10883
 
10242
- filter_items = tree.findItems(filter_name, Qt.MatchFlag.MatchExactly, 0)
10243
- filter_item = filter_items[0] if filter_items else QTreeWidgetItem([filter_name])
10244
- if not filter_items:
10884
+ filter_item = self._flat_filter_item.get(filter_name)
10885
+ if filter_item is None:
10886
+ filter_item = QTreeWidgetItem([filter_name])
10245
10887
  tree.addTopLevelItem(filter_item)
10888
+ self._flat_filter_item[filter_name] = filter_item
10246
10889
 
10247
10890
  want_label = f"{exposure_text} ({image_size})"
10248
- exposure_item = None
10249
- for i in range(filter_item.childCount()):
10250
- if filter_item.child(i).text(0) == want_label:
10251
- exposure_item = filter_item.child(i)
10252
- break
10891
+ exp_key = (filter_name, want_label)
10892
+
10893
+ exposure_item = self._flat_exp_item.get(exp_key)
10253
10894
  if exposure_item is None:
10254
10895
  exposure_item = QTreeWidgetItem([want_label])
10255
10896
  filter_item.addChild(exposure_item)
10897
+ self._flat_exp_item[exp_key] = exposure_item
10256
10898
 
10257
10899
  leaf = QTreeWidgetItem([os.path.basename(path), meta_text])
10258
10900
  leaf.setData(0, Qt.ItemDataRole.UserRole, path)
@@ -10261,28 +10903,31 @@ class StackingSuiteDialog(QDialog):
10261
10903
 
10262
10904
  # === LIGHTs ===
10263
10905
  elif expected_type_u == "LIGHT":
10906
+ filter_name_raw = header.get("FILTER") or "Unknown"
10907
+ filter_name = self._sanitize_name(filter_name_raw)
10908
+
10264
10909
  light_key = f"{filter_name} - {exposure_text} ({image_size})"
10265
10910
  composite_key = (light_key, session_tag)
10266
10911
  self.light_files.setdefault(composite_key, []).append(path)
10267
10912
  self.session_tags[path] = session_tag
10268
10913
 
10269
- filter_items = tree.findItems(filter_name, Qt.MatchFlag.MatchExactly, 0)
10270
- filter_item = filter_items[0] if filter_items else QTreeWidgetItem([filter_name])
10271
- if not filter_items:
10914
+ filter_item = self._light_filter_item.get(filter_name)
10915
+ if filter_item is None:
10916
+ filter_item = QTreeWidgetItem([filter_name])
10272
10917
  tree.addTopLevelItem(filter_item)
10918
+ self._light_filter_item[filter_name] = filter_item
10273
10919
 
10274
10920
  want_label = f"{exposure_text} ({image_size})"
10275
- exposure_item = None
10276
- for i in range(filter_item.childCount()):
10277
- if filter_item.child(i).text(0) == want_label:
10278
- exposure_item = filter_item.child(i)
10279
- break
10921
+ exp_key = (filter_name, want_label)
10922
+
10923
+ exposure_item = self._light_exp_item.get(exp_key)
10280
10924
  if exposure_item is None:
10281
10925
  exposure_item = QTreeWidgetItem([want_label])
10282
10926
  filter_item.addChild(exposure_item)
10927
+ self._light_exp_item[exp_key] = exposure_item
10283
10928
 
10284
10929
  leaf = QTreeWidgetItem([os.path.basename(path), meta_text])
10285
- leaf.setData(0, Qt.ItemDataRole.UserRole, path) # βœ… needed for date-aware flat fallback
10930
+ leaf.setData(0, Qt.ItemDataRole.UserRole, path)
10286
10931
  leaf.setData(0, Qt.ItemDataRole.UserRole + 1, session_tag)
10287
10932
  exposure_item.addChild(leaf)
10288
10933
 
@@ -10302,7 +10947,7 @@ class StackingSuiteDialog(QDialog):
10302
10947
  for file_path in files:
10303
10948
  try:
10304
10949
  # Read only the FITS header (fast)
10305
- header = fits.getheader(file_path)
10950
+ header, _kind = get_valid_header(file_path)
10306
10951
 
10307
10952
  # Check for both EXPOSURE and EXPTIME
10308
10953
  exposure = header.get("EXPOSURE", header.get("EXPTIME", "Unknown"))
@@ -10318,7 +10963,13 @@ class StackingSuiteDialog(QDialog):
10318
10963
 
10319
10964
  # Construct key based on file type
10320
10965
  if file_type.upper() == "DARK":
10321
- key = f"{exposure}s ({image_size})"
10966
+ try:
10967
+ exposure_f = float(exposure)
10968
+ exposure_text = f"{exposure_f:g}s"
10969
+ except Exception:
10970
+ exposure_text = f"{exposure}s" if str(exposure).endswith("s") else str(exposure)
10971
+
10972
+ key = f"{exposure_text} ({image_size})"
10322
10973
  self.master_files[key] = file_path # Store master dark
10323
10974
  self.master_sizes[file_path] = image_size # Store size
10324
10975
  elif file_type.upper() == "FLAT":
@@ -10380,14 +11031,39 @@ class StackingSuiteDialog(QDialog):
10380
11031
  exposure_tolerance = self.exposure_tolerance_spinbox.value()
10381
11032
 
10382
11033
  # -------------------------------------------------------------------------
10383
- # Group darks by (exposure +/- tolerance, image size string, session)
10384
- # self.dark_files can be either:
10385
- # legacy: exposure_key -> [paths]
10386
- # session: (exposure_key, session) -> [paths]
11034
+ # Temp helpers
10387
11035
  # -------------------------------------------------------------------------
10388
- dark_files_by_group: dict[tuple[float, str, str], list[str]] = {} # (exp, size, session)->list
11036
+ def _bucket_temp(t: float | None, step: float = 3.0) -> float | None:
11037
+ """Round temperature to a stable bucket (e.g. -10.2 -> -10.0 if step=1.0)."""
11038
+ if t is None:
11039
+ return None
11040
+ try:
11041
+ return round(float(t) / step) * step
11042
+ except Exception:
11043
+ return None
11044
+
11045
+ def _read_temp_quick(path: str) -> tuple[float | None, float | None, float | None]:
11046
+ """Fast temp read (CCD, SET, chosen). Uses fits.getheader(memmap=True)."""
11047
+ try:
11048
+ hdr = fits.getheader(path, memmap=True)
11049
+ except Exception:
11050
+ return None, None, None
11051
+ ccd = _get_key_float(hdr, "CCD-TEMP")
11052
+ st = _get_key_float(hdr, "SET-TEMP")
11053
+ chosen = ccd if ccd is not None else st
11054
+ return ccd, st, chosen
11055
+
11056
+ # -------------------------------------------------------------------------
11057
+ # Group darks by (exposure +/- tolerance, image size, session, temp_bucket)
11058
+ # TEMP_STEP is the rounding bucket (1.0C default)
11059
+ # -------------------------------------------------------------------------
11060
+ TEMP_STEP = self.settings.value("stacking/temp_group_step", 1.0, type=float)
11061
+
11062
+ dark_files_by_group: dict[tuple[float, str, str, float | None], list[str]] = {} # (exp,size,session,temp)->list
10389
11063
 
10390
11064
  for key, file_list in (self.dark_files or {}).items():
11065
+ # Support both legacy dark_files (key=str) and newer tuple keys.
11066
+ # We DO NOT assume dark_files already contains temp in key β€” we re-bucket from headers anyway.
10391
11067
  if isinstance(key, tuple) and len(key) >= 2:
10392
11068
  exposure_key = str(key[0])
10393
11069
  session = str(key[1]) if str(key[1]).strip() else "Default"
@@ -10399,10 +11075,9 @@ class StackingSuiteDialog(QDialog):
10399
11075
  exposure_time_str, image_size = exposure_key.split(" (", 1)
10400
11076
  image_size = image_size.rstrip(")")
10401
11077
  except ValueError:
10402
- # If some malformed key got in, skip safely
10403
11078
  continue
10404
11079
 
10405
- if "Unknown" in exposure_time_str:
11080
+ if "Unknown" in (exposure_time_str or ""):
10406
11081
  exposure_time = 0.0
10407
11082
  else:
10408
11083
  try:
@@ -10410,21 +11085,31 @@ class StackingSuiteDialog(QDialog):
10410
11085
  except Exception:
10411
11086
  exposure_time = 0.0
10412
11087
 
10413
- matched_group = None
10414
- for (existing_exposure, existing_size, existing_session) in list(dark_files_by_group.keys()):
10415
- if (
10416
- existing_session == session
10417
- and existing_size == image_size
10418
- and abs(existing_exposure - exposure_time) <= exposure_tolerance
10419
- ):
10420
- matched_group = (existing_exposure, existing_size, existing_session)
10421
- break
11088
+ # Split the incoming list by temp bucket so mixed temps do not merge.
11089
+ bucketed: dict[float | None, list[str]] = {}
11090
+ for p in (file_list or []):
11091
+ _, _, chosen = _read_temp_quick(p)
11092
+ tb = _bucket_temp(chosen, step=TEMP_STEP)
11093
+ bucketed.setdefault(tb, []).append(p)
11094
+
11095
+ # Apply exposure tolerance grouping PER temp bucket
11096
+ for temp_bucket, paths_in_bucket in bucketed.items():
11097
+ matched_group = None
11098
+ for (existing_exposure, existing_size, existing_session, existing_temp) in list(dark_files_by_group.keys()):
11099
+ if (
11100
+ existing_session == session
11101
+ and existing_size == image_size
11102
+ and existing_temp == temp_bucket
11103
+ and abs(existing_exposure - exposure_time) <= exposure_tolerance
11104
+ ):
11105
+ matched_group = (existing_exposure, existing_size, existing_session, existing_temp)
11106
+ break
10422
11107
 
10423
- if matched_group is None:
10424
- matched_group = (exposure_time, image_size, session)
10425
- dark_files_by_group[matched_group] = []
11108
+ if matched_group is None:
11109
+ matched_group = (exposure_time, image_size, session, temp_bucket)
11110
+ dark_files_by_group[matched_group] = []
10426
11111
 
10427
- dark_files_by_group[matched_group].extend(file_list or [])
11112
+ dark_files_by_group[matched_group].extend(paths_in_bucket)
10428
11113
 
10429
11114
  master_dir = os.path.join(self.stacking_directory, "Master_Calibration_Files")
10430
11115
  os.makedirs(master_dir, exist_ok=True)
@@ -10433,11 +11118,11 @@ class StackingSuiteDialog(QDialog):
10433
11118
  # Informative status about discovery
10434
11119
  # -------------------------------------------------------------------------
10435
11120
  try:
10436
- n_groups = sum(1 for _, v in dark_files_by_group.items() if len(v) >= 2)
11121
+ n_groups_eligible = sum(1 for _, v in dark_files_by_group.items() if len(v) >= 2)
10437
11122
  total_files = sum(len(v) for v in dark_files_by_group.values())
10438
11123
  self.update_status(self.tr(
10439
11124
  f"πŸ”Ž Discovered {len(dark_files_by_group)} grouped exposures "
10440
- f"({n_groups} eligible to stack) β€” {total_files} files total."
11125
+ f"({n_groups_eligible} eligible to stack) β€” {total_files} files total."
10441
11126
  ))
10442
11127
  except Exception:
10443
11128
  pass
@@ -10447,12 +11132,12 @@ class StackingSuiteDialog(QDialog):
10447
11132
  # Pre-count tiles for progress bar (per-group safe chunk sizes)
10448
11133
  # -------------------------------------------------------------------------
10449
11134
  total_tiles = 0
10450
- group_shapes: dict[tuple[float, str, str], tuple[int, int, int, int, int]] = {} # (exp,size,session)->(H,W,C,ch,cw)
11135
+ group_shapes: dict[tuple[float, str, str, float | None], tuple[int, int, int, int, int]] = {}
10451
11136
  pref_chunk_h = self.chunk_height
10452
11137
  pref_chunk_w = self.chunk_width
10453
11138
  DTYPE = np.float32
10454
11139
 
10455
- for (exposure_time, image_size, session), file_list in dark_files_by_group.items():
11140
+ for (exposure_time, image_size, session, temp_bucket), file_list in dark_files_by_group.items():
10456
11141
  if len(file_list) < 2:
10457
11142
  continue
10458
11143
 
@@ -10470,7 +11155,8 @@ class StackingSuiteDialog(QDialog):
10470
11155
  except MemoryError:
10471
11156
  chunk_h, chunk_w = pref_chunk_h, pref_chunk_w
10472
11157
 
10473
- group_shapes[(exposure_time, image_size, session)] = (H, W, C, chunk_h, chunk_w)
11158
+ gk = (exposure_time, image_size, session, temp_bucket)
11159
+ group_shapes[gk] = (H, W, C, chunk_h, chunk_w)
10474
11160
  total_tiles += _count_tiles(H, W, chunk_h, chunk_w)
10475
11161
 
10476
11162
  if total_tiles == 0:
@@ -10483,7 +11169,7 @@ class StackingSuiteDialog(QDialog):
10483
11169
  QApplication.processEvents()
10484
11170
 
10485
11171
  # -------------------------------------------------------------------------
10486
- # Local CPU reducers (unchanged)
11172
+ # Local CPU reducers
10487
11173
  # -------------------------------------------------------------------------
10488
11174
  def _select_reducer(kind: str, N: int):
10489
11175
  if kind == "dark":
@@ -10527,10 +11213,10 @@ class StackingSuiteDialog(QDialog):
10527
11213
  # ---------------------------------------------------------------------
10528
11214
  # Per-group stacking loop
10529
11215
  # ---------------------------------------------------------------------
10530
- for (exposure_time, image_size, session), file_list in dark_files_by_group.items():
11216
+ for (exposure_time, image_size, session, temp_bucket), file_list in dark_files_by_group.items():
10531
11217
  if len(file_list) < 2:
10532
11218
  self.update_status(self.tr(
10533
- f"⚠️ Skipping {exposure_time}s ({image_size}) [{session}] - Not enough frames to stack."
11219
+ f"⚠️ Skipping {exposure_time:g}s ({image_size}) [{session}] - Not enough frames to stack."
10534
11220
  ))
10535
11221
  QApplication.processEvents()
10536
11222
  continue
@@ -10539,14 +11225,17 @@ class StackingSuiteDialog(QDialog):
10539
11225
  self.update_status(self.tr("β›” Master Dark creation cancelled."))
10540
11226
  break
10541
11227
 
11228
+ temp_txt = "Unknown" if temp_bucket is None else f"{float(temp_bucket):+.1f}C"
10542
11229
  self.update_status(self.tr(
10543
- f"🟒 Processing {len(file_list)} darks for {exposure_time}s ({image_size}) in session '{session}'…"
11230
+ f"🟒 Processing {len(file_list)} darks for {exposure_time:g}s ({image_size}) "
11231
+ f"in session '{session}' at {temp_txt}…"
10544
11232
  ))
10545
11233
  QApplication.processEvents()
10546
11234
 
10547
11235
  # --- reference shape and per-group chunk size ---
10548
- if (exposure_time, image_size, session) in group_shapes:
10549
- height, width, channels, chunk_height, chunk_width = group_shapes[(exposure_time, image_size, session)]
11236
+ gk = (exposure_time, image_size, session, temp_bucket)
11237
+ if gk in group_shapes:
11238
+ height, width, channels, chunk_height, chunk_width = group_shapes[gk]
10550
11239
  else:
10551
11240
  ref_data, _, _, _ = load_image(file_list[0])
10552
11241
  if ref_data is None:
@@ -10586,8 +11275,11 @@ class StackingSuiteDialog(QDialog):
10586
11275
  QApplication.processEvents()
10587
11276
  continue
10588
11277
 
10589
- # Include session to prevent collisions
10590
- memmap_path = os.path.join(master_dir, f"temp_dark_{session}_{exposure_time}_{image_size}.dat")
11278
+ # Create temp memmap (stem-safe normalization)
11279
+ tb_tag = "notemp" if temp_bucket is None else _temp_to_stem_tag(float(temp_bucket))
11280
+ memmap_base = f"temp_dark_{session}_{exposure_time:g}s_{image_size}_{tb_tag}.dat"
11281
+ memmap_base = self._normalize_master_stem(memmap_base)
11282
+ memmap_path = os.path.join(master_dir, memmap_base)
10591
11283
 
10592
11284
  self.update_status(self.tr(
10593
11285
  f"πŸ—‚οΈ Creating temp memmap: {os.path.basename(memmap_path)} "
@@ -10599,6 +11291,7 @@ class StackingSuiteDialog(QDialog):
10599
11291
 
10600
11292
  tiles = _tile_grid(height, width, chunk_height, chunk_width)
10601
11293
  total_tiles_group = len(tiles)
11294
+
10602
11295
  self.update_status(self.tr(
10603
11296
  f"πŸ“¦ {total_tiles_group} tiles to process for this group (chunk {chunk_height}Γ—{chunk_width})."
10604
11297
  ))
@@ -10640,7 +11333,7 @@ class StackingSuiteDialog(QDialog):
10640
11333
  fut = tp.submit(_read_tile_into, (buf1 if use0 else buf0), ny0, ny1, nx0, nx1)
10641
11334
 
10642
11335
  pd.set_label(
10643
- f"{int(exposure_time)}s ({image_size}) [{session}] β€” "
11336
+ f"{int(exposure_time)}s ({image_size}) [{session}] [{temp_txt}] β€” "
10644
11337
  f"tile {t_idx}/{total_tiles_group} y:{y0}-{y1} x:{x0}-{x1}"
10645
11338
  )
10646
11339
 
@@ -10670,6 +11363,7 @@ class StackingSuiteDialog(QDialog):
10670
11363
 
10671
11364
  if tile_result.ndim == 2:
10672
11365
  tile_result = tile_result[:, :, None]
11366
+
10673
11367
  expected_shape = (th, tw, channels)
10674
11368
  if tile_result.shape != expected_shape:
10675
11369
  if tile_result.shape[:2] == (th, tw):
@@ -10704,37 +11398,115 @@ class StackingSuiteDialog(QDialog):
10704
11398
  pass
10705
11399
  break
10706
11400
 
11401
+ # -------------------------------------------------------------
11402
+ # Materialize final memmap to ndarray for save
11403
+ # -------------------------------------------------------------
10707
11404
  master_dark_data = np.asarray(final_stacked, dtype=np.float32)
10708
- del final_stacked
11405
+ try:
11406
+ del final_stacked
11407
+ except Exception:
11408
+ pass
10709
11409
  gc.collect()
11410
+
10710
11411
  try:
10711
11412
  os.remove(memmap_path)
10712
11413
  except Exception:
10713
11414
  pass
10714
11415
 
10715
- # Include session in output name
10716
- master_dark_stem = f"MasterDark_{session}_{int(exposure_time)}s_{image_size}"
11416
+ # -------------------------------------------------------------
11417
+ # Collect temperature stats from input dark headers
11418
+ # -------------------------------------------------------------
11419
+ temp_info = {}
11420
+ try:
11421
+ temp_info = _collect_temp_stats(file_list) or {}
11422
+ except Exception:
11423
+ temp_info = {}
11424
+
11425
+ # -------------------------------------------------------------
11426
+ # Build output filename (include session + exposure + size + temp bucket tag)
11427
+ # -------------------------------------------------------------
11428
+ temp_tag = ""
11429
+ try:
11430
+ if temp_bucket is not None:
11431
+ temp_tag = "_" + _temp_to_stem_tag(float(temp_bucket))
11432
+ elif temp_info.get("ccd_med") is not None:
11433
+ temp_tag = "_" + _temp_to_stem_tag(float(temp_info["ccd_med"]))
11434
+ elif temp_info.get("set_med") is not None:
11435
+ temp_tag = "_" + _temp_to_stem_tag(float(temp_info["set_med"]), prefix="set")
11436
+ except Exception:
11437
+ temp_tag = ""
11438
+
11439
+ master_dark_stem = f"MasterDark_{session}_{int(exposure_time)}s_{image_size}{temp_tag}"
11440
+ master_dark_stem = self._normalize_master_stem(master_dark_stem)
10717
11441
  master_dark_path = self._build_out(master_dir, master_dark_stem, "fit")
10718
11442
 
11443
+ # -------------------------------------------------------------
11444
+ # Header
11445
+ # -------------------------------------------------------------
10719
11446
  master_header = fits.Header()
10720
11447
  master_header["IMAGETYP"] = "DARK"
10721
- master_header["EXPTIME"] = (exposure_time, "User-specified or from grouping")
10722
- master_header["SESSION"] = (session, "User session tag") # optional but useful
10723
- master_header["NAXIS"] = 3 if channels == 3 else 2
10724
- master_header["NAXIS1"] = master_dark_data.shape[1]
10725
- master_header["NAXIS2"] = master_dark_data.shape[0]
11448
+ master_header["EXPTIME"] = (float(exposure_time), "Exposure time (s)")
11449
+ master_header["SESSION"] = (str(session), "User session tag")
11450
+ master_header["NCOMBINE"] = (int(N), "Number of darks combined")
11451
+ master_header["NSTACK"] = (int(N), "Alias of NCOMBINE (SetiAstro)")
11452
+
11453
+ # Temperature provenance (only write keys that exist)
11454
+ if temp_info.get("ccd_med") is not None:
11455
+ master_header["CCD-TEMP"] = (float(temp_info["ccd_med"]), "Median CCD temp of input darks (C)")
11456
+ if temp_info.get("ccd_min") is not None:
11457
+ master_header["CCDTMIN"] = (float(temp_info["ccd_min"]), "Min CCD temp in input darks (C)")
11458
+ if temp_info.get("ccd_max") is not None:
11459
+ master_header["CCDTMAX"] = (float(temp_info["ccd_max"]), "Max CCD temp in input darks (C)")
11460
+ if temp_info.get("ccd_std") is not None:
11461
+ master_header["CCDTSTD"] = (float(temp_info["ccd_std"]), "Std CCD temp in input darks (C)")
11462
+ if temp_info.get("ccd_n") is not None:
11463
+ master_header["CCDTN"] = (int(temp_info["ccd_n"]), "Count of frames with CCD-TEMP")
11464
+
11465
+ if temp_info.get("set_med") is not None:
11466
+ master_header["SET-TEMP"] = (float(temp_info["set_med"]), "Median setpoint temp of input darks (C)")
11467
+ if temp_info.get("set_min") is not None:
11468
+ master_header["SETTMIN"] = (float(temp_info["set_min"]), "Min setpoint in input darks (C)")
11469
+ if temp_info.get("set_max") is not None:
11470
+ master_header["SETTMAX"] = (float(temp_info["set_max"]), "Max setpoint in input darks (C)")
11471
+ if temp_info.get("set_std") is not None:
11472
+ master_header["SETTSTD"] = (float(temp_info["set_std"]), "Std setpoint in input darks (C)")
11473
+ if temp_info.get("set_n") is not None:
11474
+ master_header["SETTN"] = (int(temp_info["set_n"]), "Count of frames with SET-TEMP")
11475
+
11476
+ # Dimensions (save_image usually writes these, but keep your existing behavior)
11477
+ master_header["NAXIS"] = 3 if channels == 3 else 2
11478
+ master_header["NAXIS1"] = int(master_dark_data.shape[1])
11479
+ master_header["NAXIS2"] = int(master_dark_data.shape[0])
10726
11480
  if channels == 3:
10727
11481
  master_header["NAXIS3"] = 3
10728
11482
 
10729
- save_image(master_dark_data, master_dark_path, "fit", "32-bit floating point", master_header, is_mono=(channels == 1))
11483
+ save_image(
11484
+ master_dark_data,
11485
+ master_dark_path,
11486
+ "fit",
11487
+ "32-bit floating point",
11488
+ master_header,
11489
+ is_mono=(channels == 1)
11490
+ )
11491
+
11492
+ # Tree label includes temp for visibility
11493
+ tree_label = f"{exposure_time:g}s ({image_size}) [{session}]"
11494
+ if temp_info.get("ccd_med") is not None:
11495
+ tree_label += f" [CCD {float(temp_info['ccd_med']):+.1f}C]"
11496
+ elif temp_info.get("set_med") is not None:
11497
+ tree_label += f" [SET {float(temp_info['set_med']):+.1f}C]"
11498
+ elif temp_bucket is not None:
11499
+ tree_label += f" [TEMP {float(temp_bucket):+.1f}C]"
10730
11500
 
10731
- self.add_master_dark_to_tree(f"{exposure_time}s ({image_size}) [{session}]", master_dark_path)
11501
+ self.add_master_dark_to_tree(tree_label, master_dark_path)
10732
11502
  self.update_status(self.tr(f"βœ… Master Dark saved: {master_dark_path}"))
10733
11503
  QApplication.processEvents()
10734
11504
 
11505
+ # Refresh assignments + persistence
10735
11506
  self.assign_best_master_files()
10736
11507
  self.save_master_paths_to_settings()
10737
11508
 
11509
+ # Post pass refresh (unchanged behavior)
10738
11510
  self.assign_best_master_dark()
10739
11511
  self.update_override_dark_combo()
10740
11512
  self.assign_best_master_files()
@@ -10747,7 +11519,6 @@ class StackingSuiteDialog(QDialog):
10747
11519
  logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
10748
11520
  pd.close()
10749
11521
 
10750
-
10751
11522
  def add_master_dark_to_tree(self, exposure_label: str, master_dark_path: str):
10752
11523
  """
10753
11524
  Adds the newly created Master Dark to the Master Dark TreeBox and updates the dropdown.
@@ -11147,22 +11918,17 @@ class StackingSuiteDialog(QDialog):
11147
11918
  dark_data: np.ndarray | None,
11148
11919
  pattern: str,
11149
11920
  ):
11150
- """
11151
- Returns scales shape (N,4): [R, G1, G2, B] where scale = frame_plane_median / group_plane_median.
11152
- """
11153
11921
  pat = (pattern or "RGGB").strip().upper()
11154
11922
  if pat not in ("RGGB", "BGGR", "GRBG", "GBRG"):
11155
11923
  pat = "RGGB"
11156
11924
 
11157
- # Central patch
11158
11925
  th = min(512, H); tw = min(512, W)
11159
11926
  y0 = (H - th) // 2; y1 = y0 + th
11160
11927
  x0 = (W - tw) // 2; x1 = x0 + tw
11161
11928
 
11162
11929
  N = len(file_list)
11163
- meds = np.empty((N, 4), dtype=np.float64) # R,G1,G2,B
11930
+ meds = np.empty((N, 4), dtype=np.float64)
11164
11931
 
11165
- # parity β†’ plane label
11166
11932
  if pat == "RGGB":
11167
11933
  m = {(0,0):"R", (0,1):"G1", (1,0):"G2", (1,1):"B"}
11168
11934
  elif pat == "BGGR":
@@ -11179,9 +11945,24 @@ class StackingSuiteDialog(QDialog):
11179
11945
  d = float(np.median(v))
11180
11946
  return d if np.isfinite(d) and d > 0 else 1.0
11181
11947
 
11948
+ # Make dark/bias subtractor into 2D for bayer mosaics (important for XISF HWC darks)
11949
+ dd2 = None
11950
+ if dark_data is not None:
11951
+ dd2 = dark_data
11952
+ if dd2.ndim == 3:
11953
+ # CHW -> HWC
11954
+ if dd2.shape[0] in (1, 3):
11955
+ dd2 = dd2.transpose(1, 2, 0)
11956
+ # HWC -> take first plane for mosaic subtraction
11957
+ dd2 = dd2[:, :, 0]
11958
+ dd2 = dd2.astype(np.float32, copy=False)
11959
+
11182
11960
  from concurrent.futures import ThreadPoolExecutor, as_completed
11183
11961
  with ThreadPoolExecutor(max_workers=min(os.cpu_count() or 4, 8)) as exe:
11184
- fut2i = {exe.submit(load_fits_tile, fp, y0, y1, x0, x1): i for i, fp in enumerate(file_list)}
11962
+ fut2i = {
11963
+ exe.submit(_read_center_patch_via_mmimage, fp, y0, y1, x0, x1): i
11964
+ for i, fp in enumerate(file_list)
11965
+ }
11185
11966
  for fut in as_completed(fut2i):
11186
11967
  i = fut2i[fut]
11187
11968
  sub = fut.result()
@@ -11190,16 +11971,14 @@ class StackingSuiteDialog(QDialog):
11190
11971
  continue
11191
11972
 
11192
11973
  # Ensure 2D mosaic
11193
- if sub.ndim == 3 and sub.shape[0] in (1, 3):
11194
- sub = sub[0] if sub.shape[0] == 1 else sub.transpose(1, 2, 0)[:, :, 0]
11974
+ if sub.ndim == 3:
11975
+ if sub.shape[0] in (1, 3): # CHW
11976
+ sub = sub.transpose(1, 2, 0)
11977
+ sub = sub[:, :, 0] # first plane
11195
11978
  sub = sub.astype(np.float32, copy=False)
11196
11979
 
11197
- # Dark subtract patch if present
11198
- if dark_data is not None:
11199
- dd = dark_data
11200
- if dd.ndim == 3 and dd.shape[0] in (1, 3):
11201
- dd = dd.transpose(1, 2, 0)[:, :, 0]
11202
- d_tile = dd[y0:y1, x0:x1].astype(np.float32, copy=False)
11980
+ if dd2 is not None:
11981
+ d_tile = dd2[y0:y1, x0:x1].astype(np.float32, copy=False)
11203
11982
  sub = sub - d_tile
11204
11983
 
11205
11984
  planes = {
@@ -11218,15 +11997,11 @@ class StackingSuiteDialog(QDialog):
11218
11997
  gmed = np.median(meds, axis=0)
11219
11998
  gmed = np.where(np.isfinite(gmed) & (gmed > 0), gmed, 1.0)
11220
11999
 
11221
- scales = meds / gmed # (N,4)
11222
- scales = np.clip(scales, 1e-3, 1e3).astype(np.float32)
11223
- return scales
12000
+ scales = meds / gmed
12001
+ return np.clip(scales, 1e-3, 1e3).astype(np.float32)
12002
+
11224
12003
 
11225
12004
  def _estimate_flat_scales(file_list: list[str], H: int, W: int, C: int, dark_data: np.ndarray | None):
11226
- """
11227
- Read one central patch (min(512, H/W)) from each frame, subtract dark (if present),
11228
- compute per-frame median, and normalize scales to overall median.
11229
- """
11230
12005
  th = min(512, H); tw = min(512, W)
11231
12006
  y0 = (H - th) // 2; y1 = y0 + th
11232
12007
  x0 = (W - tw) // 2; x1 = x0 + tw
@@ -11234,9 +12009,20 @@ class StackingSuiteDialog(QDialog):
11234
12009
  N = len(file_list)
11235
12010
  meds = np.empty((N,), dtype=np.float64)
11236
12011
 
12012
+ # Normalize subtractor to HWC or 2D
12013
+ dd = None
12014
+ if dark_data is not None:
12015
+ dd = dark_data
12016
+ if dd.ndim == 3 and dd.shape[0] in (1, 3): # CHW -> HWC
12017
+ dd = dd.transpose(1, 2, 0)
12018
+ dd = dd.astype(np.float32, copy=False)
12019
+
11237
12020
  from concurrent.futures import ThreadPoolExecutor, as_completed
11238
12021
  with ThreadPoolExecutor(max_workers=min(os.cpu_count() or 4, 8)) as exe:
11239
- fut2i = {exe.submit(load_fits_tile, fp, y0, y1, x0, x1): i for i, fp in enumerate(file_list)}
12022
+ fut2i = {
12023
+ exe.submit(_read_center_patch_via_mmimage, fp, y0, y1, x0, x1): i
12024
+ for i, fp in enumerate(file_list)
12025
+ }
11240
12026
  for fut in as_completed(fut2i):
11241
12027
  i = fut2i[fut]
11242
12028
  sub = fut.result()
@@ -11251,22 +12037,22 @@ class StackingSuiteDialog(QDialog):
11251
12037
  sub = sub.transpose(1, 2, 0)
11252
12038
  sub = sub.astype(np.float32, copy=False)
11253
12039
 
11254
- if dark_data is not None:
11255
- dd = dark_data
11256
- if dd.ndim == 3 and dd.shape[0] in (1, 3):
11257
- dd = dd.transpose(1, 2, 0)
11258
- d_tile = dd[y0:y1, x0:x1].astype(np.float32, copy=False)
12040
+ if dd is not None:
12041
+ d_tile = dd[y0:y1, x0:x1]
11259
12042
  if d_tile.ndim == 2 and sub.shape[2] == 3:
11260
12043
  d_tile = np.repeat(d_tile[..., None], 3, axis=2)
11261
- sub = sub - d_tile
12044
+ elif d_tile.ndim == 3 and sub.shape[2] == 1:
12045
+ d_tile = d_tile[:, :, :1]
12046
+ sub = sub - d_tile.astype(np.float32, copy=False)
11262
12047
 
11263
- meds[i] = np.median(sub, axis=(0, 1, 2))
12048
+ meds[i] = float(np.median(sub))
11264
12049
 
11265
- gmed = np.median(meds) if np.all(np.isfinite(meds)) else 1.0
11266
- gmed = 1.0 if gmed == 0.0 else gmed
12050
+ gmed = float(np.median(meds)) if np.all(np.isfinite(meds)) else 1.0
12051
+ if not np.isfinite(gmed) or gmed == 0.0:
12052
+ gmed = 1.0
11267
12053
  scales = meds / gmed
11268
- scales = np.clip(scales, 1e-3, 1e3).astype(np.float32)
11269
- return scales
12054
+ return np.clip(scales, 1e-3, 1e3).astype(np.float32)
12055
+
11270
12056
 
11271
12057
  def _apply_bayer_scales_stack_inplace(ts_np: np.ndarray, scales4: np.ndarray, pat: str, y0: int, x0: int):
11272
12058
  """
@@ -11780,6 +12566,140 @@ class StackingSuiteDialog(QDialog):
11780
12566
  master_item = QTreeWidgetItem([os.path.basename(master_flat_path)])
11781
12567
  filter_item.addChild(master_item)
11782
12568
 
12569
+ def _parse_float(self, v):
12570
+ try:
12571
+ if v is None:
12572
+ return None
12573
+ if isinstance(v, (int, float)):
12574
+ return float(v)
12575
+ s = str(v).strip()
12576
+ # handle " -10.0 C" or "-10.0C"
12577
+ s = s.replace("Β°", "").replace("C", "").replace("c", "").strip()
12578
+ return float(s)
12579
+ except Exception:
12580
+ return None
12581
+
12582
+
12583
+ def _read_ccd_set_temp_from_fits(self, path: str) -> tuple[float|None, float|None]:
12584
+ """Read CCD-TEMP and SET-TEMP from FITS header (primary HDU)."""
12585
+ try:
12586
+ with fits.open(path) as hdul:
12587
+ hdr = hdul[0].header
12588
+ ccd = self._parse_float(hdr.get("CCD-TEMP", None))
12589
+ st = self._parse_float(hdr.get("SET-TEMP", None))
12590
+ return ccd, st
12591
+ except Exception:
12592
+ return None, None
12593
+
12594
+
12595
+ def _temp_for_matching(self, ccd: float|None, st: float|None) -> float|None:
12596
+ """Prefer CCD-TEMP; else SET-TEMP; else None."""
12597
+ return ccd if ccd is not None else (st if st is not None else None)
12598
+
12599
+
12600
+ def _parse_masterdark_name(self, stem: str):
12601
+ """
12602
+ From filename like:
12603
+ MasterDark_Session_300s_4144x2822_m10p0C.fit
12604
+ Return dict fields; temp is optional.
12605
+ """
12606
+ out = {"session": None, "exp": None, "size": None, "temp": None}
12607
+
12608
+ base = os.path.basename(stem)
12609
+ base = os.path.splitext(base)[0]
12610
+
12611
+ # session is between MasterDark_ and _<exp>s_
12612
+ # exp is <num>s
12613
+ # size is <WxH> like 4144x2822
12614
+ m = re.match(r"^MasterDark_(?P<session>.+?)_(?P<exp>[\d._]+)s_(?P<size>\d+x\d+)(?:_(?P<temp>.*))?$", base)
12615
+ if not m:
12616
+ return out
12617
+
12618
+ out["session"] = (m.group("session") or "").strip()
12619
+ # exp might be "2_5" from _normalize_master_stem; convert back
12620
+ exp_txt = (m.group("exp") or "").replace("_", ".")
12621
+ try:
12622
+ out["exp"] = float(exp_txt)
12623
+ except Exception:
12624
+ out["exp"] = None
12625
+
12626
+ out["size"] = m.group("size")
12627
+
12628
+ # temp token like m10p0C / p5p0C / setm10p0C
12629
+ t = (m.group("temp") or "").strip()
12630
+ if t:
12631
+ # pick the first temp-ish token ending in C
12632
+ mt = re.search(r"(set)?([mp])(\d+)p(\d)C", t)
12633
+ if mt:
12634
+ sign = -1.0 if mt.group(2) == "m" else 1.0
12635
+ whole = float(mt.group(3))
12636
+ frac = float(mt.group(4)) / 10.0
12637
+ out["temp"] = sign * (whole + frac)
12638
+
12639
+ return out
12640
+
12641
+
12642
+ def _get_master_dark_meta(self, path: str) -> dict:
12643
+ """
12644
+ Cached metadata for a master dark.
12645
+ Prefers FITS header for temp; falls back to filename temp token.
12646
+ """
12647
+ if not hasattr(self, "_master_dark_meta_cache"):
12648
+ self._master_dark_meta_cache = {}
12649
+ cache = self._master_dark_meta_cache
12650
+
12651
+ p = os.path.normpath(path)
12652
+ if p in cache:
12653
+ return cache[p]
12654
+
12655
+ meta = {"path": p, "session": None, "exp": None, "size": None,
12656
+ "ccd": None, "set": None, "temp": None}
12657
+
12658
+ # filename parse (fast)
12659
+ fn = self._parse_masterdark_name(p)
12660
+ meta["session"] = fn.get("session") or None
12661
+ meta["exp"] = fn.get("exp")
12662
+ meta["size"] = fn.get("size")
12663
+ meta["temp"] = fn.get("temp")
12664
+
12665
+ # header parse (authoritative for temps)
12666
+ ccd, st = self._read_ccd_set_temp_from_fits(p)
12667
+ meta["ccd"] = ccd
12668
+ meta["set"] = st
12669
+ meta["temp"] = self._temp_for_matching(ccd, st) if (ccd is not None or st is not None) else meta["temp"]
12670
+
12671
+ # size from header if missing
12672
+ if not meta["size"]:
12673
+ try:
12674
+ with fits.open(p) as hdul:
12675
+ data = hdul[0].data
12676
+ if data is not None:
12677
+ meta["size"] = f"{data.shape[1]}x{data.shape[0]}"
12678
+ except Exception:
12679
+ pass
12680
+
12681
+ cache[p] = meta
12682
+ return meta
12683
+
12684
+
12685
+ def _get_light_temp(self, light_path: str) -> tuple[float|None, float|None, float|None]:
12686
+ """Return (ccd, set, chosen) with caching."""
12687
+ if not hasattr(self, "_light_temp_cache"):
12688
+ self._light_temp_cache = {}
12689
+ cache = self._light_temp_cache
12690
+
12691
+ p = os.path.normpath(light_path or "")
12692
+ if not p:
12693
+ return None, None, None
12694
+ if p in cache:
12695
+ return cache[p]
12696
+
12697
+ ccd, st = self._read_ccd_set_temp_from_fits(p)
12698
+ chosen = self._temp_for_matching(ccd, st)
12699
+ cache[p] = (ccd, st, chosen)
12700
+ return cache[p]
12701
+
12702
+
11783
12703
  def assign_best_master_files(self, fill_only: bool = True):
11784
12704
  """
11785
12705
  Assign best matching Master Dark and Flat to each Light leaf.
@@ -11839,32 +12759,57 @@ class StackingSuiteDialog(QDialog):
11839
12759
  if fill_only and curr_dark and curr_dark.lower() != "none":
11840
12760
  dark_choice = curr_dark
11841
12761
  else:
11842
- # 3) Auto-pick by size+closest exposure
11843
- best_dark_match = None
11844
- best_dark_diff = float("inf")
11845
- for master_key, master_path in self.master_files.items():
11846
- dmatch = re.match(r"^([\d.]+)s\b", master_key) # darks start with "<exp>s"
11847
- if not dmatch:
12762
+ # 3) Auto-pick by size + closest exposure + closest temperature (and prefer same session)
12763
+ light_path = leaf_item.data(0, Qt.ItemDataRole.UserRole)
12764
+ l_ccd, l_set, l_temp = self._get_light_temp(light_path)
12765
+
12766
+ best_path = None
12767
+ best_score = None
12768
+
12769
+ for mk, mp in (self.master_files or {}).items():
12770
+ if not mp:
11848
12771
  continue
11849
- master_dark_exposure_time = float(dmatch.group(1))
11850
12772
 
11851
- # Ensure size known/cached
11852
- md_size = master_sizes.get(master_path)
11853
- if not md_size:
11854
- try:
11855
- with fits.open(master_path) as hdul:
11856
- md_size = f"{hdul[0].data.shape[1]}x{hdul[0].data.shape[0]}"
11857
- except Exception:
11858
- md_size = "Unknown"
11859
- master_sizes[master_path] = md_size
12773
+ bn = os.path.basename(mp)
12774
+ # Only consider MasterDark_* files (cheap gate)
12775
+ if not bn.startswith("MasterDark_"):
12776
+ continue
12777
+
12778
+ md = self._get_master_dark_meta(mp)
12779
+ md_size = md.get("size") or "Unknown"
12780
+ if md_size != image_size:
12781
+ continue
11860
12782
 
11861
- if md_size == image_size:
11862
- diff = abs(master_dark_exposure_time - exposure_time)
11863
- if diff < best_dark_diff:
11864
- best_dark_diff = diff
11865
- best_dark_match = master_path
12783
+ md_exp = md.get("exp")
12784
+ if md_exp is None:
12785
+ continue
12786
+
12787
+ # exposure closeness
12788
+ exp_diff = abs(float(md_exp) - float(exposure_time))
12789
+
12790
+ # session preference: exact match beats mismatch
12791
+ md_sess = (md.get("session") or "Default").strip()
12792
+ sess_mismatch = 0 if md_sess == session_name else 1
12793
+
12794
+ # temperature closeness (if both known)
12795
+ md_temp = md.get("temp")
12796
+ if (l_temp is not None) and (md_temp is not None):
12797
+ temp_diff = abs(float(md_temp) - float(l_temp))
12798
+ temp_unknown = 0
12799
+ else:
12800
+ # if light has temp but dark doesn't (or vice versa), penalize
12801
+ temp_diff = 9999.0
12802
+ temp_unknown = 1
11866
12803
 
11867
- dark_choice = os.path.basename(best_dark_match) if best_dark_match else ("None" if not curr_dark else curr_dark)
12804
+ # Score tuple: lower is better
12805
+ # Priority: session match -> exposure diff -> temp availability -> temp diff
12806
+ score = (sess_mismatch, exp_diff, temp_unknown, temp_diff)
12807
+
12808
+ if best_score is None or score < best_score:
12809
+ best_score = score
12810
+ best_path = mp
12811
+
12812
+ dark_choice = os.path.basename(best_path) if best_path else ("None" if not curr_dark else curr_dark)
11868
12813
 
11869
12814
  # ---------- FLAT RESOLUTION ----------
11870
12815
  flat_key_full = f"{filter_name_raw} - {exposure_text}"
@@ -12000,22 +12945,57 @@ class StackingSuiteDialog(QDialog):
12000
12945
 
12001
12946
 
12002
12947
  def override_selected_master_dark(self):
12003
- """ Override Dark for selected Light exposure group or individual files. """
12948
+ """Override Dark for selected Light exposure group or individual files."""
12004
12949
  selected_items = self.light_tree.selectedItems()
12005
12950
  if not selected_items:
12006
12951
  print("⚠️ No light item selected for dark frame override.")
12007
12952
  return
12008
12953
 
12009
- file_path, _ = QFileDialog.getOpenFileName(self, "Select Master Dark", "", "FITS Files (*.fits *.fit)")
12954
+ # --- pick a good starting directory ---
12955
+ last_dir = self.settings.value("stacking/last_master_dark_dir", "", type=str) if hasattr(self, "settings") else ""
12956
+ if not last_dir:
12957
+ # try stacking dir
12958
+ last_dir = getattr(self, "stacking_directory", "") or ""
12959
+
12960
+ # try selected leaf path folder (best UX)
12961
+ try:
12962
+ it0 = selected_items[0]
12963
+ # leaf stores path in UserRole, groups do not
12964
+ p0 = it0.data(0, Qt.ItemDataRole.UserRole)
12965
+ if isinstance(p0, str) and os.path.exists(p0):
12966
+ last_dir = os.path.dirname(p0)
12967
+ except Exception:
12968
+ pass
12969
+
12970
+ if not last_dir:
12971
+ last_dir = os.path.expanduser("~")
12972
+
12973
+ file_path, _ = QFileDialog.getOpenFileName(
12974
+ self,
12975
+ "Select Master Dark",
12976
+ last_dir,
12977
+ "Master Calibration (*.fits *.fit *.xisf);;All Files (*)"
12978
+ )
12010
12979
  if not file_path:
12011
12980
  return
12012
12981
 
12982
+ # remember for next time
12983
+ try:
12984
+ if hasattr(self, "settings"):
12985
+ self.settings.setValue("stacking/last_master_dark_dir", os.path.dirname(file_path))
12986
+ except Exception:
12987
+ pass
12988
+
12989
+ # Ensure dict exists
12990
+ if not hasattr(self, "manual_dark_overrides") or self.manual_dark_overrides is None:
12991
+ self.manual_dark_overrides = {}
12992
+
12013
12993
  for item in selected_items:
12014
- # If the user clicked a group (exposure row), push override to all leaves:
12994
+ # If the user clicked an exposure row under a filter
12015
12995
  if item.parent() and item.childCount() > 0:
12016
- # exposure row under a filter
12017
12996
  filter_name = item.parent().text(0)
12018
12997
  exposure_text = item.text(0)
12998
+
12019
12999
  # store override under BOTH keys
12020
13000
  self.manual_dark_overrides[f"{filter_name} - {exposure_text}"] = file_path
12021
13001
  self.manual_dark_overrides[exposure_text] = file_path
@@ -12023,17 +13003,20 @@ class StackingSuiteDialog(QDialog):
12023
13003
  for i in range(item.childCount()):
12024
13004
  leaf = item.child(i)
12025
13005
  leaf.setText(2, os.path.basename(file_path))
12026
- # If the user clicked a leaf, just set that leaf and still store under both keys
13006
+
13007
+ # If the user clicked a leaf under an exposure row
12027
13008
  elif item.parent() and item.parent().parent():
12028
13009
  exposure_item = item.parent()
12029
13010
  filter_name = exposure_item.parent().text(0)
12030
13011
  exposure_text = exposure_item.text(0)
13012
+
12031
13013
  self.manual_dark_overrides[f"{filter_name} - {exposure_text}"] = file_path
12032
13014
  self.manual_dark_overrides[exposure_text] = file_path
12033
13015
  item.setText(2, os.path.basename(file_path))
12034
13016
 
12035
13017
  print("βœ… DEBUG: Light Dark override applied.")
12036
13018
 
13019
+
12037
13020
  def _auto_pick_master_dark(self, image_size: str, exposure_time: float):
12038
13021
  best_path, best_diff = None, float("inf")
12039
13022
  for key, path in self.master_files.items():
@@ -12416,6 +13399,7 @@ class StackingSuiteDialog(QDialog):
12416
13399
 
12417
13400
  # ---------- LOAD LIGHT ----------
12418
13401
  light_data, hdr, bit_depth, is_mono = load_image(light_file)
13402
+ #_print_stats("LIGHT raw", light_data, bit_depth=bit_depth, hdr=hdr)
12419
13403
  if light_data is None or hdr is None:
12420
13404
  self.update_status(self.tr(f"❌ ERROR: Failed to load {os.path.basename(light_file)}"))
12421
13405
  continue
@@ -12440,7 +13424,10 @@ class StackingSuiteDialog(QDialog):
12440
13424
 
12441
13425
  # ---------- APPLY DARK (if resolved) ----------
12442
13426
  if master_dark_path:
12443
- dark_data, _, _, dark_is_mono = load_image(master_dark_path)
13427
+ dark_data, _, dark_bit_depth, dark_is_mono = load_image(master_dark_path)
13428
+ #_print_stats("DARK raw", dark_data, bit_depth=dark_bit_depth)
13429
+ dark_data = _maybe_normalize_16bit_float(dark_data, name=os.path.basename(master_dark_path))
13430
+ #_print_stats("DARK normalized", dark_data, bit_depth=dark_bit_depth)
12444
13431
  if dark_data is not None:
12445
13432
  if not dark_is_mono and dark_data.ndim == 3 and dark_data.shape[-1] == 3:
12446
13433
  dark_data = dark_data.transpose(2, 0, 1) # HWC -> CHW
@@ -12456,7 +13443,10 @@ class StackingSuiteDialog(QDialog):
12456
13443
 
12457
13444
  # ---------- APPLY FLAT (if resolved) ----------
12458
13445
  if master_flat_path:
12459
- flat_data, _, _, flat_is_mono = load_image(master_flat_path)
13446
+ flat_data, _, flat_bit_depth, flat_is_mono = load_image(master_flat_path)
13447
+ #_print_stats("FLAT raw", flat_data, bit_depth=flat_bit_depth)
13448
+ flat_data = _maybe_normalize_16bit_float(flat_data, name=os.path.basename(master_flat_path))
13449
+ #_print_stats("FLAT normalized", flat_data, bit_depth=flat_bit_depth)
12460
13450
  if flat_data is not None:
12461
13451
 
12462
13452
  # Make flat layout match your working light layout:
@@ -12570,13 +13560,19 @@ class StackingSuiteDialog(QDialog):
12570
13560
  max_val = float(np.max(light_data))
12571
13561
  self.update_status(self.tr(f"Before saving: min = {min_val:.4f}, max = {max_val:.4f}"))
12572
13562
  print(f"Before saving: min = {min_val:.4f}, max = {max_val:.4f}")
13563
+
13564
+ _warn_if_units_mismatch(light_data, dark_data if master_dark_path else None, flat_data if master_flat_path else None)
13565
+ _print_stats("LIGHT final", light_data)
12573
13566
  QApplication.processEvents()
12574
-
12575
13567
  # Annotate header
12576
13568
  try:
12577
- hdr['HISTORY'] = 'Calibrated: bias/dark sub, flat division'
12578
- hdr['CALMIN'] = (min_val, 'Min pixel before save (float)')
12579
- hdr['CALMAX'] = (max_val, 'Max pixel before save (float)')
13569
+ if hasattr(hdr, "add_history"):
13570
+ hdr.add_history("Calibrated: bias/dark sub, flat division")
13571
+ else:
13572
+ hdr["HISTORY"] = "Calibrated: bias/dark sub, flat division"
13573
+
13574
+ hdr["CALMIN"] = (min_val, "Min pixel before save (float)")
13575
+ hdr["CALMAX"] = (max_val, "Max pixel before save (float)")
12580
13576
  except Exception:
12581
13577
  pass
12582
13578
 
@@ -12816,6 +13812,20 @@ class StackingSuiteDialog(QDialog):
12816
13812
  "drop": float(self.drizzle_drop_shrink_spin.value())
12817
13813
  }
12818
13814
 
13815
+ def _global_drizzle_state(self) -> dict:
13816
+ # UI is the source of truth at runtime
13817
+ enabled = bool(self.drizzle_checkbox.isChecked())
13818
+
13819
+ # Scale from combo text like "1x", "2x", "3x"
13820
+ try:
13821
+ scale = float(self.drizzle_scale_combo.currentText().replace("x", "", 1).strip())
13822
+ except Exception:
13823
+ scale = 1.0
13824
+
13825
+ drop = float(self.drizzle_drop_shrink_spin.value())
13826
+
13827
+ return {"enabled": enabled, "scale": scale, "drop": drop}
13828
+
12819
13829
  def _split_dual_band_osc(self, selected_groups=None):
12820
13830
  """
12821
13831
  Create mono Ha/SII/OIII frames from dual-band OSC files and
@@ -13547,23 +14557,23 @@ class StackingSuiteDialog(QDialog):
13547
14557
  self.update_status(self.tr("🧹 Doing a little tidying up..."))
13548
14558
  user_ref_locked = bool(getattr(self, "_user_ref_locked", False))
13549
14559
 
13550
- # Only clear derived geometry/maps when NOT locked
14560
+ # ALWAYS clear derived geometry/maps for this run (mapping is run-specific)
14561
+ self._norm_target_hw = None
14562
+ self._orig2norm = {}
14563
+
14564
+ # Only clear the UI reference label when NOT locked
13551
14565
  if not user_ref_locked:
13552
- self._norm_target_hw = None
13553
- self._orig2norm = {}
13554
14566
  try:
13555
14567
  if hasattr(self, "ref_frame_path") and self.ref_frame_path:
13556
14568
  self.ref_frame_path.setText("Auto (not set)")
13557
14569
  except Exception:
13558
14570
  pass
13559
14571
  else:
13560
- # Keep the UI showing the user’s chosen ref (basename for display)
13561
14572
  try:
13562
14573
  if hasattr(self, "ref_frame_path") and self.ref_frame_path and self.reference_frame:
13563
14574
  self.ref_frame_path.setText(os.path.basename(self.reference_frame))
13564
14575
  except Exception:
13565
14576
  pass
13566
-
13567
14577
  # 🚫 Do NOT remove persisted user ref here; that defeats locking.
13568
14578
  # (No settings.remove() and no reference_frame = None if locked)
13569
14579
 
@@ -13578,6 +14588,24 @@ class StackingSuiteDialog(QDialog):
13578
14588
  self.update_status(self.tr("πŸ”„ Image Registration Started..."))
13579
14589
  self.extract_light_files_from_tree(debug=True)
13580
14590
 
14591
+ # --- Apply "removed from Registration tab" exclusions (session-level) ---
14592
+ dead = set()
14593
+ if hasattr(self, "deleted_calibrated_files") and self.deleted_calibrated_files:
14594
+ dead = set(self.deleted_calibrated_files)
14595
+
14596
+ if dead:
14597
+ for g in list(self.light_files.keys()):
14598
+ self.light_files[g] = [
14599
+ p for p in self.light_files[g]
14600
+ if os.path.normcase(os.path.abspath(p)) not in dead
14601
+ ]
14602
+ if not self.light_files[g]:
14603
+ del self.light_files[g]
14604
+
14605
+ self.update_status(self.tr(f"🚫 Excluding {len(dead)} removed frame(s) from registration/stacking."))
14606
+ QApplication.processEvents()
14607
+
14608
+
13581
14609
  comet_mode = bool(getattr(self, "comet_cb", None) and self.comet_cb.isChecked())
13582
14610
  if comet_mode:
13583
14611
  self.update_status(self.tr("🌠 Comet mode: please click the comet center to continue…"))
@@ -14430,7 +15458,28 @@ class StackingSuiteDialog(QDialog):
14430
15458
 
14431
15459
  from os import path
14432
15460
  ref_path = path.normpath(self.reference_frame)
14433
- self.update_status(self.tr(f"πŸ“Œ Reference for alignment (verbatim): {ref_path}"))
15461
+ from os import path
15462
+
15463
+ # Prefer the normalized FIT reference if we produced one
15464
+ ref_key = path.normcase(path.normpath(self.reference_frame))
15465
+ ref_norm = self._orig2norm.get(ref_key)
15466
+
15467
+ # If mapping missing, attempt the predictable filename in norm_dir
15468
+ if not ref_norm:
15469
+ base = os.path.basename(self.reference_frame)
15470
+ if base.lower().endswith(".fits"):
15471
+ n_name = base[:-5] + "_n.fit"
15472
+ elif base.lower().endswith(".fit"):
15473
+ n_name = base[:-4] + "_n.fit"
15474
+ else:
15475
+ n_name = base + "_n.fit"
15476
+ candidate = path.normpath(path.join(norm_dir, n_name))
15477
+ if path.exists(candidate):
15478
+ ref_norm = candidate
15479
+
15480
+ ref_path = path.normpath(ref_norm or self.reference_frame)
15481
+
15482
+ self.update_status(self.tr(f"πŸ“Œ Reference for alignment: {ref_path}"))
14434
15483
  if not path.exists(ref_path):
14435
15484
  self.update_status(self.tr(f"🚨 Reference file does not exist: {ref_path}"))
14436
15485
  return
@@ -14446,6 +15495,14 @@ class StackingSuiteDialog(QDialog):
14446
15495
 
14447
15496
  normalized_files = [path.normpath(p) for p in normalized_files]
14448
15497
 
15498
+ ref_key = path.normcase(path.normpath(self.reference_frame))
15499
+ ref_path = self._orig2norm.get(ref_key, path.normpath(self.reference_frame))
15500
+
15501
+ self.update_status(self.tr(f"πŸ“Œ Reference for alignment (normalized if available): {ref_path}"))
15502
+ if not path.exists(ref_path):
15503
+ self.update_status(self.tr(f"🚨 Reference file does not exist: {ref_path}"))
15504
+ return
15505
+
14449
15506
  self.alignment_thread = StarRegistrationThread(
14450
15507
  ref_path,
14451
15508
  normalized_files,
@@ -15051,6 +16108,41 @@ class StackingSuiteDialog(QDialog):
15051
16108
  # Threshold is only used in normal mode
15052
16109
  accept_thresh = float(self.settings.value("stacking/accept_shift_px", 2.0, type=float))
15053
16110
 
16111
+ def _mf_ref_path_for_masks() -> str | None:
16112
+ """
16113
+ Return the best reference path for MFDeconv star masks:
16114
+ aligned FITS if possible, else normalized FITS, else original.
16115
+ """
16116
+ if not getattr(self, "reference_frame", None):
16117
+ return None
16118
+
16119
+ from os import path
16120
+ ref_orig = path.normpath(self.reference_frame)
16121
+ ref_key = path.normcase(ref_orig)
16122
+
16123
+ # original -> normalized
16124
+ ref_norm = self._orig2norm.get(ref_key)
16125
+
16126
+ # normalized -> aligned
16127
+ ref_aligned = None
16128
+ if ref_norm:
16129
+ ref_aligned = self.valid_transforms.get(path.normpath(ref_norm))
16130
+
16131
+ # If we couldn’t map via orig->norm (e.g. user picked a normalized path already)
16132
+ if not ref_norm and ref_orig in self.valid_transforms:
16133
+ ref_norm = ref_orig
16134
+ ref_aligned = self.valid_transforms.get(ref_norm)
16135
+
16136
+ # Prefer aligned if it exists on disk
16137
+ if ref_aligned and path.exists(ref_aligned):
16138
+ return ref_aligned
16139
+ if ref_norm and path.exists(ref_norm):
16140
+ return ref_norm
16141
+ if path.exists(ref_orig):
16142
+ return ref_orig
16143
+ return None
16144
+
16145
+
15054
16146
  def _accept(k: str) -> bool:
15055
16147
  """Accept criteria for a frame."""
15056
16148
  if all_transforms.get(k) is None:
@@ -15190,6 +16282,14 @@ class StackingSuiteDialog(QDialog):
15190
16282
  # Snapshot UI-dependent settings (your existing code)
15191
16283
  # ----------------------------
15192
16284
  drizzle_dict = self.gather_drizzle_settings_from_tree()
16285
+ try:
16286
+ self.update_status(self.tr(
16287
+ "🧾 Drizzle dict: " + ", ".join(f"{k}:{'ON' if v.get('drizzle_enabled') else 'off'}"
16288
+ for k, v in drizzle_dict.items())
16289
+ ))
16290
+ except Exception:
16291
+ pass
16292
+ QApplication.processEvents()
15193
16293
  try:
15194
16294
  autocrop_enabled = self.autocrop_cb.isChecked()
15195
16295
  autocrop_pct = float(self.autocrop_pct.value())
@@ -15426,7 +16526,9 @@ class StackingSuiteDialog(QDialog):
15426
16526
  }
15427
16527
 
15428
16528
  self._mf_thread = QThread(self)
15429
- star_mask_ref = self.reference_frame if use_star_masks else None
16529
+ star_mask_ref = _mf_ref_path_for_masks() if use_star_masks else None
16530
+ if use_star_masks:
16531
+ self.update_status(self.tr(f"🌟 MFDeconv star-mask reference β†’ {star_mask_ref or '(none)'}"))
15430
16532
 
15431
16533
  # ── choose engine plainly (Normal / cuDNN-free / High Octane) ─────────────
15432
16534
  # Expect a setting saved by your radio buttons: "normal" | "cudnn" | "sport"
@@ -15604,6 +16706,22 @@ class StackingSuiteDialog(QDialog):
15604
16706
 
15605
16707
  self._set_registration_busy(False)
15606
16708
 
16709
+ def _on_after_align_finished(self, success: bool, message: str):
16710
+ # Stop thread/progress UI first (whatever you already do)
16711
+
16712
+ if success:
16713
+ QMessageBox.information(
16714
+ self,
16715
+ self.tr("Stacking Complete"),
16716
+ message
16717
+ )
16718
+ else:
16719
+ QMessageBox.critical(
16720
+ self,
16721
+ self.tr("Stacking Failed"),
16722
+ message
16723
+ )
16724
+
15607
16725
  def _on_mf_progress(self, s: str):
15608
16726
  # Mirror non-token messages
15609
16727
  if not s.startswith("__PROGRESS__"):
@@ -15632,25 +16750,48 @@ class StackingSuiteDialog(QDialog):
15632
16750
 
15633
16751
  @pyqtSlot(bool, str)
15634
16752
  def _on_post_pipeline_finished(self, ok: bool, message: str):
16753
+ # ---- close progress dialog ----
15635
16754
  try:
15636
- if getattr(self, "post_progress", None):
16755
+ if getattr(self, "post_progress", None) is not None:
15637
16756
  self.post_progress.close()
16757
+ self.post_progress.deleteLater()
15638
16758
  self.post_progress = None
15639
16759
  except Exception:
15640
16760
  pass
15641
16761
 
16762
+ # ---- stop thread ----
15642
16763
  try:
15643
- self.post_thread.quit()
15644
- self.post_thread.wait()
16764
+ if getattr(self, "post_thread", None) is not None:
16765
+ self.post_thread.quit()
16766
+ self.post_thread.wait()
15645
16767
  except Exception:
15646
16768
  pass
16769
+
16770
+ # ---- cleanup objects ----
15647
16771
  try:
15648
- self.post_worker.deleteLater()
15649
- self.post_thread.deleteLater()
16772
+ if getattr(self, "post_worker", None) is not None:
16773
+ self.post_worker.deleteLater()
16774
+ self.post_worker = None
16775
+ if getattr(self, "post_thread", None) is not None:
16776
+ self.post_thread.deleteLater()
16777
+ self.post_thread = None
15650
16778
  except Exception:
15651
16779
  pass
15652
16780
 
15653
- self.update_status(self.tr(message))
16781
+ # ---- update status (keep this behavior) ----
16782
+ try:
16783
+ # message already includes "Post-alignment complete..." text
16784
+ self.update_status(self.tr(message))
16785
+ except Exception:
16786
+ pass
16787
+
16788
+ # ---- popup summary ----
16789
+ # (Do this after progress dialog is gone so it doesn't hide behind it)
16790
+ if ok:
16791
+ QMessageBox.information(self, self.tr("Post-Alignment Complete"), message)
16792
+ else:
16793
+ QMessageBox.critical(self, self.tr("Post-Alignment Failed"), message)
16794
+
15654
16795
  self._cfa_for_this_run = None
15655
16796
  QApplication.processEvents()
15656
16797
 
@@ -15757,6 +16898,8 @@ class StackingSuiteDialog(QDialog):
15757
16898
  log(f"πŸ“ Post-align: {n_groups} group(s), {n_frames} aligned frame(s).")
15758
16899
  QApplication.processEvents()
15759
16900
 
16901
+ drizzle_enabled_global = self._get_drizzle_enabled()
16902
+
15760
16903
  # Precompute a single global crop rect if enabled (pure computation, no UI).
15761
16904
  global_rect = None
15762
16905
  if autocrop_enabled:
@@ -15840,6 +16983,10 @@ class StackingSuiteDialog(QDialog):
15840
16983
  hdr_orig["CREATOR"] = "SetiAstroSuite"
15841
16984
  hdr_orig["DATE-OBS"] = datetime.utcnow().isoformat()
15842
16985
 
16986
+ n_frames_group = len(file_list)
16987
+ hdr_orig["NCOMBINE"] = (int(n_frames_group), "Number of frames combined")
16988
+ hdr_orig["NSTACK"] = (int(n_frames_group), "Alias of NCOMBINE (SetiAstro)")
16989
+
15843
16990
  is_mono_orig = (integrated_image.ndim == 2)
15844
16991
  if is_mono_orig:
15845
16992
  hdr_orig["NAXIS"] = 2
@@ -15959,6 +17106,8 @@ class StackingSuiteDialog(QDialog):
15959
17106
  scale=1.0,
15960
17107
  rect_override=group_rect if group_rect is not None else global_rect
15961
17108
  )
17109
+ hdr_crop["NCOMBINE"] = (int(n_frames_group), "Number of frames combined")
17110
+ hdr_crop["NSTACK"] = (int(n_frames_group), "Alias of NCOMBINE (SetiAstro)")
15962
17111
  is_mono_crop = (cropped_img.ndim == 2)
15963
17112
  Hc, Wc = (cropped_img.shape[:2] if cropped_img.ndim >= 2 else (H, W))
15964
17113
  display_group_crop = self._label_with_dims(group_key, Wc, Hc)
@@ -16102,6 +17251,12 @@ class StackingSuiteDialog(QDialog):
16102
17251
  algo_override=COMET_ALGO # << comet-friendly reducer
16103
17252
  )
16104
17253
 
17254
+ n_usable = int(len(usable))
17255
+ ref_header_c = ref_header_c or ref_header or fits.Header()
17256
+ ref_header_c["NCOMBINE"] = (n_usable, "Number of frames combined (comet)")
17257
+ ref_header_c["NSTACK"] = (n_usable, "Alias of NCOMBINE (SetiAstro)")
17258
+ ref_header_c["COMETFR"] = (n_usable, "Frames used for comet-aligned stack")
17259
+
16105
17260
  # Save CometOnly
16106
17261
  Hc, Wc = comet_only.shape[:2]
16107
17262
  display_group_c = self._label_with_dims(group_key, Wc, Hc)
@@ -16126,6 +17281,10 @@ class StackingSuiteDialog(QDialog):
16126
17281
  scale=1.0,
16127
17282
  rect_override=group_rect if group_rect is not None else global_rect
16128
17283
  )
17284
+ comet_only_crop, hdr_c_crop = self._apply_autocrop(...)
17285
+ hdr_c_crop["NCOMBINE"] = (n_usable, "Number of frames combined (comet)")
17286
+ hdr_c_crop["NSTACK"] = (n_usable, "Alias of NCOMBINE (SetiAstro)")
17287
+ hdr_c_crop["COMETFR"] = (n_usable, "Frames used for comet-aligned stack")
16129
17288
  Hcc, Wcc = comet_only_crop.shape[:2]
16130
17289
  display_group_cc = self._label_with_dims(group_key, Wcc, Hcc)
16131
17290
  comet_path_crop = self._build_out(
@@ -16190,8 +17349,7 @@ class StackingSuiteDialog(QDialog):
16190
17349
  log(f"βœ‚οΈ Saved CometBlend (auto-cropped) β†’ {blend_path_crop}")
16191
17350
 
16192
17351
  # ---- Drizzle bookkeeping for this group ----
16193
- dconf = drizzle_dict.get(group_key, {})
16194
- if dconf.get("drizzle_enabled", False):
17352
+ if drizzle_enabled_global:
16195
17353
  sasr_path = os.path.join(self.stacking_directory, f"{group_key}_rejections.sasr")
16196
17354
  self.save_rejection_map_sasr(rejection_map, sasr_path)
16197
17355
  log(f"βœ… Saved rejection map to {sasr_path}")
@@ -16223,17 +17381,17 @@ class StackingSuiteDialog(QDialog):
16223
17381
  originals_by_group[group] = orig_list
16224
17382
  # ---- Drizzle pass (only for groups with drizzle enabled) ----
16225
17383
  for group_key, file_list in grouped_files.items():
16226
- dconf = drizzle_dict.get(group_key)
16227
- if not (dconf and dconf.get("drizzle_enabled", False)):
16228
- log(f"βœ… Group '{group_key}' not set for drizzle. Integrated image already saved.")
17384
+ if not drizzle_enabled_global:
17385
+ log(f"βœ… Drizzle disabled (checkbox off). Group '{group_key}' integrated image already saved.")
16229
17386
  continue
16230
17387
 
17388
+ # Use your existing getters (they can read UI/settings)
16231
17389
  scale_factor = self._get_drizzle_scale()
16232
17390
  drop_shrink = self._get_drizzle_pixfrac()
16233
17391
 
16234
- # Optional: also read kernel for logging/branching
16235
17392
  kernel = (self.settings.value("stacking/drizzle_kernel", "square", type=str) or "square").lower()
16236
- status_cb(f"Drizzle cfg β†’ scale={scale_factor}Γ—, pixfrac={drop_shrink:.3f}, kernel={kernel}")
17393
+ log(f"Drizzle cfg β†’ scale={scale_factor}Γ—, pixfrac={drop_shrink:.3f}, kernel={kernel}")
17394
+
16237
17395
  rejections_for_group = group_integration_data[group_key]["rejection_map"]
16238
17396
  n_frames_group = group_integration_data[group_key]["n_frames"]
16239
17397
 
@@ -16241,8 +17399,8 @@ class StackingSuiteDialog(QDialog):
16241
17399
 
16242
17400
  self.drizzle_stack_one_group(
16243
17401
  group_key=group_key,
16244
- file_list=file_list, # registered (for headers/labels)
16245
- original_list=originals_by_group.get(group_key, []), # <-- NEW
17402
+ file_list=file_list,
17403
+ original_list=originals_by_group.get(group_key, []),
16246
17404
  transforms_dict=transforms_dict,
16247
17405
  frame_weights=frame_weights,
16248
17406
  scale_factor=scale_factor,
@@ -16714,246 +17872,6 @@ class StackingSuiteDialog(QDialog):
16714
17872
  views[p] = np.load(npy, mmap_mode="r") # returns numpy.memmap
16715
17873
  return views
16716
17874
 
16717
-
16718
- def stack_registered_images_chunked(
16719
- self,
16720
- grouped_files,
16721
- frame_weights,
16722
- chunk_height=2048,
16723
- chunk_width=2048
16724
- ):
16725
- self.update_status(self.tr(f"βœ… Chunked stacking {len(grouped_files)} group(s)..."))
16726
- QApplication.processEvents()
16727
-
16728
- all_rejection_coords = []
16729
-
16730
- for group_key, file_list in grouped_files.items():
16731
- num_files = len(file_list)
16732
- self.update_status(self.tr(f"πŸ“Š Group '{group_key}' has {num_files} aligned file(s)."))
16733
- QApplication.processEvents()
16734
- if num_files < 2:
16735
- self.update_status(self.tr(f"⚠️ Group '{group_key}' does not have enough frames to stack."))
16736
- continue
16737
-
16738
- # Reference shape/header (unchanged)
16739
- ref_file = file_list[0]
16740
- if not os.path.exists(ref_file):
16741
- self.update_status(self.tr(f"⚠️ Reference file '{ref_file}' not found, skipping group."))
16742
- continue
16743
-
16744
- ref_data, ref_header, _, _ = load_image(ref_file)
16745
- if ref_data is None:
16746
- self.update_status(self.tr(f"⚠️ Could not load reference '{ref_file}', skipping group."))
16747
- continue
16748
-
16749
- is_color = (ref_data.ndim == 3 and ref_data.shape[2] == 3)
16750
- height, width = ref_data.shape[:2]
16751
- channels = 3 if is_color else 1
16752
-
16753
- # Final output memmap (unchanged)
16754
- memmap_path = self._build_out(self.stacking_directory, f"chunked_{group_key}", "dat")
16755
- final_stacked = np.memmap(memmap_path, dtype=np.float32, mode='w+', shape=(height, width, channels))
16756
-
16757
- # Valid files + weights
16758
- aligned_paths, weights_list = [], []
16759
- for fpath in file_list:
16760
- if os.path.exists(fpath):
16761
- aligned_paths.append(fpath)
16762
- weights_list.append(frame_weights.get(fpath, 1.0))
16763
- else:
16764
- self.update_status(self.tr(f"⚠️ File not found: {fpath}, skipping."))
16765
- if len(aligned_paths) < 2:
16766
- self.update_status(self.tr(f"⚠️ Not enough valid frames in group '{group_key}' to stack."))
16767
- continue
16768
-
16769
- weights_list = np.array(weights_list, dtype=np.float32)
16770
-
16771
- # ⬇️ NEW: open read-only memmaps for all aligned frames (float32 [0..1], HxWxC)
16772
- mm_views = self._open_memmaps_readonly(aligned_paths)
16773
-
16774
- self.update_status(self.tr(f"πŸ“Š Stacking group '{group_key}' with {self.rejection_algorithm}"))
16775
- QApplication.processEvents()
16776
-
16777
- rejection_coords = []
16778
- N = len(aligned_paths)
16779
- DTYPE = self._dtype()
16780
- pref_h = self.chunk_height
16781
- pref_w = self.chunk_width
16782
-
16783
- try:
16784
- chunk_h, chunk_w = compute_safe_chunk(height, width, N, channels, DTYPE, pref_h, pref_w)
16785
- self.update_status(self.tr(f"πŸ”§ Using chunk size {chunk_h}Γ—{chunk_w} for {self._dtype()}"))
16786
- except MemoryError as e:
16787
- self.update_status(self.tr(f"⚠️ {e}"))
16788
- return None, {}, None
16789
-
16790
- # Tile loop (same structure, but tile loading reads from memmaps)
16791
- from concurrent.futures import ThreadPoolExecutor, as_completed
16792
- LOADER_WORKERS = min(max(2, (os.cpu_count() or 4) // 2), 8) # tuned for memory bw
16793
-
16794
- for y_start in range(0, height, chunk_h):
16795
- y_end = min(y_start + chunk_h, height)
16796
- tile_h = y_end - y_start
16797
-
16798
- for x_start in range(0, width, chunk_w):
16799
- x_end = min(x_start + chunk_w, width)
16800
- tile_w = x_end - x_start
16801
-
16802
- # Preallocate tile stack
16803
- tile_stack = np.empty((N, tile_h, tile_w, channels), dtype=np.float32)
16804
-
16805
- # ⬇️ NEW: fill tile_stack from the memmaps (parallel copy)
16806
- def _copy_one(i, path):
16807
- v = mm_views[path][y_start:y_end, x_start:x_end] # view on disk
16808
- if v.ndim == 2:
16809
- # mono memmap stored as (H,W,1); but if legacy mono npy exists as (H,W),
16810
- # make it (H,W,1) here:
16811
- vv = v[..., None]
16812
- else:
16813
- vv = v
16814
- if vv.shape[2] == 1 and channels == 3:
16815
- vv = np.repeat(vv, 3, axis=2)
16816
- tile_stack[i] = vv
16817
-
16818
- with ThreadPoolExecutor(max_workers=LOADER_WORKERS) as exe:
16819
- futs = {exe.submit(_copy_one, i, p): i for i, p in enumerate(aligned_paths)}
16820
- for _ in as_completed(futs):
16821
- pass
16822
-
16823
- # Rejection (unchanged – uses your Numba kernels)
16824
- algo = self.rejection_algorithm
16825
- if algo == "Simple Median (No Rejection)":
16826
- tile_result = np.median(tile_stack, axis=0)
16827
- tile_rej_map = np.zeros(tile_stack.shape[1:3], dtype=np.bool_)
16828
- elif algo == "Simple Average (No Rejection)":
16829
- tile_result = np.average(tile_stack, axis=0, weights=weights_list)
16830
- tile_rej_map = np.zeros(tile_stack.shape[1:3], dtype=np.bool_)
16831
- elif algo == "Weighted Windsorized Sigma Clipping":
16832
- tile_result, tile_rej_map = windsorized_sigma_clip_weighted(
16833
- tile_stack, weights_list, lower=self.sigma_low, upper=self.sigma_high
16834
- )
16835
- elif algo == "Kappa-Sigma Clipping":
16836
- tile_result, tile_rej_map = kappa_sigma_clip_weighted(
16837
- tile_stack, weights_list, kappa=self.kappa, iterations=self.iterations
16838
- )
16839
- elif algo == "Trimmed Mean":
16840
- tile_result, tile_rej_map = trimmed_mean_weighted(
16841
- tile_stack, weights_list, trim_fraction=self.trim_fraction
16842
- )
16843
- elif algo == "Extreme Studentized Deviate (ESD)":
16844
- tile_result, tile_rej_map = esd_clip_weighted(
16845
- tile_stack, weights_list, threshold=self.esd_threshold
16846
- )
16847
- elif algo == "Biweight Estimator":
16848
- tile_result, tile_rej_map = biweight_location_weighted(
16849
- tile_stack, weights_list, tuning_constant=self.biweight_constant
16850
- )
16851
- elif algo == "Modified Z-Score Clipping":
16852
- tile_result, tile_rej_map = modified_zscore_clip_weighted(
16853
- tile_stack, weights_list, threshold=self.modz_threshold
16854
- )
16855
- elif algo == "Max Value":
16856
- tile_result, tile_rej_map = max_value_stack(
16857
- tile_stack, weights_list
16858
- )
16859
- else:
16860
- tile_result, tile_rej_map = windsorized_sigma_clip_weighted(
16861
- tile_stack, weights_list, lower=self.sigma_low, upper=self.sigma_high
16862
- )
16863
-
16864
- # Ensure tile_result has correct shape
16865
- if tile_result.ndim == 2:
16866
- tile_result = tile_result[:, :, None]
16867
- expected_shape = (tile_h, tile_w, channels)
16868
- if tile_result.shape != expected_shape:
16869
- if tile_result.shape[2] == 0:
16870
- tile_result = np.zeros(expected_shape, dtype=np.float32)
16871
- elif tile_result.shape[:2] == (tile_h, tile_w):
16872
- if tile_result.shape[2] > channels:
16873
- tile_result = tile_result[:, :, :channels]
16874
- else:
16875
- tile_result = np.repeat(tile_result, channels, axis=2)[:, :, :channels]
16876
-
16877
- # Commit tile
16878
- final_stacked[y_start:y_end, x_start:x_end, :] = tile_result
16879
-
16880
- # Collect per-tile rejection coords (unchanged logic)
16881
- if tile_rej_map.ndim == 3: # (N, tile_h, tile_w)
16882
- combined_rej = np.any(tile_rej_map, axis=0)
16883
- elif tile_rej_map.ndim == 4: # (N, tile_h, tile_w, C)
16884
- combined_rej = np.any(tile_rej_map, axis=0)
16885
- combined_rej = np.any(combined_rej, axis=-1)
16886
- else:
16887
- combined_rej = np.zeros((tile_h, tile_w), dtype=np.bool_)
16888
-
16889
- ys_tile, xs_tile = np.where(combined_rej)
16890
- for dy, dx in zip(ys_tile, xs_tile):
16891
- rejection_coords.append((x_start + dx, y_start + dy))
16892
-
16893
- # Finish/save (unchanged from your version) …
16894
- final_array = np.array(final_stacked)
16895
- del final_stacked
16896
-
16897
- final_array = self._normalize_stack_01(final_array)
16898
-
16899
- if final_array.ndim == 3 and final_array.shape[-1] == 1:
16900
- final_array = final_array[..., 0]
16901
- is_mono = (final_array.ndim == 2)
16902
-
16903
- if ref_header is None:
16904
- ref_header = fits.Header()
16905
- ref_header["IMAGETYP"] = "MASTER STACK"
16906
- ref_header["BITPIX"] = -32
16907
- ref_header["STACKED"] = (True, "Stacked using chunked approach")
16908
- ref_header["CREATOR"] = "SetiAstroSuite"
16909
- ref_header["DATE-OBS"] = datetime.utcnow().isoformat()
16910
- if is_mono:
16911
- ref_header["NAXIS"] = 2
16912
- ref_header["NAXIS1"] = final_array.shape[1]
16913
- ref_header["NAXIS2"] = final_array.shape[0]
16914
- if "NAXIS3" in ref_header: del ref_header["NAXIS3"]
16915
- else:
16916
- ref_header["NAXIS"] = 3
16917
- ref_header["NAXIS1"] = final_array.shape[1]
16918
- ref_header["NAXIS2"] = final_array.shape[0]
16919
- ref_header["NAXIS3"] = 3
16920
-
16921
- output_stem = f"MasterLight_{group_key}_{len(aligned_paths)}stacked"
16922
- output_path = self._build_out(self.stacking_directory, output_stem, "fit")
16923
-
16924
- save_image(
16925
- img_array=final_array,
16926
- filename=output_path,
16927
- original_format="fit",
16928
- bit_depth="32-bit floating point",
16929
- original_header=ref_header,
16930
- is_mono=is_mono
16931
- )
16932
-
16933
- self.update_status(self.tr(f"βœ… Group '{group_key}' stacked {len(aligned_paths)} frame(s)! Saved: {output_path}"))
16934
-
16935
- print(f"βœ… Master Light saved for group '{group_key}': {output_path}")
16936
-
16937
- # Optionally, you might want to store or log 'rejection_coords' (here appended to all_rejection_coords)
16938
- all_rejection_coords.extend(rejection_coords)
16939
-
16940
- # Clean up memmap file
16941
- try:
16942
- os.remove(memmap_path)
16943
- except OSError:
16944
- pass
16945
-
16946
- QMessageBox.information(
16947
- self,
16948
- "Stacking Complete",
16949
- f"All stacking finished successfully.\n"
16950
- f"Frames per group:\n" +
16951
- "\n".join([f"{group_key}: {len(files)} frame(s)" for group_key, files in grouped_files.items()])
16952
- )
16953
-
16954
- # Optionally, you could return the global rejection coordinate list.
16955
- return all_rejection_coords
16956
-
16957
17875
  def _start_after_align_worker(self, aligned_light_files: dict[str, list[str]]):
16958
17876
  # Snapshot UI settings
16959
17877
  if getattr(self, "_suppress_normal_integration_once", False):
@@ -17127,7 +18045,37 @@ class StackingSuiteDialog(QDialog):
17127
18045
 
17128
18046
  # Thread + worker
17129
18047
  self._mf_thread = QThread(self)
17130
- star_mask_ref = self.reference_frame if use_star_masks else None
18048
+
18049
+ def _pick_mf_ref_from_frames(frames: list[str]) -> str | None:
18050
+ """Pick a reference path for MFDeconv masks from the aligned frames list."""
18051
+ from os import path
18052
+ if not frames:
18053
+ return None
18054
+
18055
+ # Prefer the weighted-best frame if weights exist
18056
+ w = getattr(self, "frame_weights", None) or {}
18057
+ best = None
18058
+ bestw = -1.0
18059
+ for p in frames:
18060
+ pn = path.normpath(p)
18061
+ if not path.exists(pn):
18062
+ continue
18063
+ ww = float(w.get(pn, w.get(p, 0.0)) or 0.0)
18064
+ if ww > bestw:
18065
+ bestw, best = ww, pn
18066
+
18067
+ # Otherwise fall back to first existing frame
18068
+ if best:
18069
+ return best
18070
+ for p in frames:
18071
+ pn = path.normpath(p)
18072
+ if path.exists(pn):
18073
+ return pn
18074
+ return None
18075
+
18076
+ star_mask_ref = _pick_mf_ref_from_frames(frames) if use_star_masks else None
18077
+ if use_star_masks:
18078
+ self.update_status(self.tr(f"🌟 MFDeconv star-mask reference β†’ {star_mask_ref or '(none)'}"))
17131
18079
 
17132
18080
  # ── choose engine plainly (Normal / cuDNN-free / High Octane) ─────────────
17133
18081
  # Expect a setting saved by your radio buttons: "normal" | "cudnn" | "sport"
@@ -17272,6 +18220,87 @@ class StackingSuiteDialog(QDialog):
17272
18220
 
17273
18221
  self.update_status(self.tr(f"πŸ“Š Found {len(cand)} aligned/normalized frames. Measuring in parallel previews…"))
17274
18222
 
18223
+ # ─────────────────────────────────────────────────────────────────────
18224
+ # XISF safety: convert any .xisf to float32 FITS once up-front so the
18225
+ # downstream integration pipeline is guaranteed to be FITS-based.
18226
+ # ─────────────────────────────────────────────────────────────────────
18227
+ prep_dir = os.path.join(self.stacking_directory, "Prepared_Registered")
18228
+ os.makedirs(prep_dir, exist_ok=True)
18229
+
18230
+ orig2prep = {} # optional, for debugging or later mapping
18231
+
18232
+ def _prep_path_for(fp: str) -> str:
18233
+ base = os.path.basename(fp)
18234
+ stem, _ext = os.path.splitext(base)
18235
+ return os.path.normpath(os.path.join(prep_dir, stem + "_prep.fit"))
18236
+
18237
+ prepared = []
18238
+ for fp in cand:
18239
+ ext = os.path.splitext(fp)[1].lower()
18240
+ if ext != ".xisf":
18241
+ prepared.append(fp)
18242
+ continue
18243
+
18244
+ outp = _prep_path_for(fp)
18245
+
18246
+ # reuse if already created this run
18247
+ if os.path.exists(outp):
18248
+ orig2prep[os.path.normcase(os.path.normpath(fp))] = outp
18249
+ prepared.append(outp)
18250
+ continue
18251
+
18252
+ try:
18253
+ img, hdr = self._load_image_any(fp) # must support XISF
18254
+ if img is None:
18255
+ self.update_status(self.tr(f"⚠️ Could not read XISF: {fp}"))
18256
+ continue
18257
+
18258
+ img = _to_writable_f32(img)
18259
+ if img.ndim == 3 and img.shape[-1] == 1:
18260
+ img = np.squeeze(img, axis=-1)
18261
+
18262
+ # Minimal header: keep what you can if hdr is a fits.Header
18263
+ try:
18264
+ h = hdr if isinstance(hdr, fits.Header) else fits.Header()
18265
+ except Exception:
18266
+ h = fits.Header()
18267
+
18268
+ h["SAS_PREP"] = (True, "Prepared from XISF for integration")
18269
+ h["SRCFILE"] = (os.path.basename(fp), "Original source filename")
18270
+ if isinstance(img, np.ndarray) and img.ndim == 3 and img.shape[-1] == 3:
18271
+ h["DEBAYERED"] = (True, "Color frame")
18272
+ else:
18273
+ h["DEBAYERED"] = (False, "Mono frame")
18274
+
18275
+ fits.PrimaryHDU(data=img.astype(np.float32), header=h).writeto(outp, overwrite=True)
18276
+
18277
+ orig2prep[os.path.normcase(os.path.normpath(fp))] = outp
18278
+ prepared.append(outp)
18279
+
18280
+ except Exception as e:
18281
+ self.update_status(self.tr(f"⚠️ XISFβ†’FITS prepare failed for {fp}: {e}"))
18282
+
18283
+ # Swap cand to prepared paths
18284
+ cand = prepared
18285
+
18286
+ # Also update light_files to match these prepared paths so the rest of the
18287
+ # pipeline only ever sees FITS paths.
18288
+ prep_map = orig2prep
18289
+ new_light_files = {}
18290
+ for g, lst in self.light_files.items():
18291
+ out = []
18292
+ for p in lst:
18293
+ k = os.path.normcase(os.path.normpath(p))
18294
+ out.append(prep_map.get(k, p))
18295
+ new_light_files[g] = out
18296
+ self.light_files = new_light_files
18297
+
18298
+ # If reference_frame was set and is XISF, redirect it too
18299
+ if getattr(self, "reference_frame", None):
18300
+ k = os.path.normcase(os.path.normpath(self.reference_frame))
18301
+ if k in prep_map:
18302
+ self.reference_frame = prep_map[k]
18303
+
17275
18304
  # 2) Chunked preview measurement (mean + star count/ecc)
17276
18305
  self.frame_weights = {}
17277
18306
  mean_values = {}
@@ -17301,7 +18330,8 @@ class StackingSuiteDialog(QDialog):
17301
18330
  paths_ok = []
17302
18331
 
17303
18332
  def _preview_job(fp: str):
17304
- return _quick_preview_from_path(fp, target_xbin=1, target_ybin=1)
18333
+ # Use the unified reader (FITS/XISF/TIFF/etc) like registration does
18334
+ return self._quick_preview_any(fp, target_xbin=1, target_ybin=1)
17305
18335
 
17306
18336
  with ThreadPoolExecutor(max_workers=max_workers) as ex:
17307
18337
  futs = {ex.submit(_preview_job, fp): fp for fp in chunk}
@@ -17856,6 +18886,10 @@ class StackingSuiteDialog(QDialog):
17856
18886
  hdr_orig["CREATOR"] = "SetiAstroSuite"
17857
18887
  hdr_orig["DATE-OBS"] = datetime.utcnow().isoformat()
17858
18888
 
18889
+ n_frames = int(len(file_list))
18890
+ hdr_orig["NCOMBINE"] = (n_frames, "Number of frames combined")
18891
+ hdr_orig["NSTACK"] = (n_frames, "Alias of NCOMBINE (SetiAstro)")
18892
+
17859
18893
  if final_drizzle.ndim == 2:
17860
18894
  hdr_orig["NAXIS"] = 2
17861
18895
  hdr_orig["NAXIS1"] = final_drizzle.shape[1]
@@ -17885,10 +18919,12 @@ class StackingSuiteDialog(QDialog):
17885
18919
  cropped_drizzle, hdr_crop = self._apply_autocrop(
17886
18920
  final_drizzle,
17887
18921
  file_list,
17888
- hdr.copy() if hdr is not None else fits.Header(),
18922
+ hdr_orig.copy(),
17889
18923
  scale=float(scale_factor),
17890
18924
  rect_override=rect_override
17891
18925
  )
18926
+ hdr_crop["NCOMBINE"] = (n_frames, "Number of frames combined")
18927
+ hdr_crop["NSTACK"] = (n_frames, "Alias of NCOMBINE (SetiAstro)")
17892
18928
  is_mono_crop = (cropped_drizzle.ndim == 2)
17893
18929
  display_group_driz_crop = self._label_with_dims(group_key, cropped_drizzle.shape[1], cropped_drizzle.shape[0])
17894
18930
  base_crop = f"MasterLight_{display_group_driz_crop}_{len(file_list)}stacked_drizzle_autocrop"