setiastrosuitepro 1.6.4__py3-none-any.whl β†’ 1.6.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/cosmic.svg +40 -0
  4. setiastro/images/cosmicsat.svg +24 -0
  5. setiastro/images/first_quarter.png +0 -0
  6. setiastro/images/full_moon.png +0 -0
  7. setiastro/images/graxpert.svg +19 -0
  8. setiastro/images/last_quarter.png +0 -0
  9. setiastro/images/linearfit.svg +32 -0
  10. setiastro/images/new_moon.png +0 -0
  11. setiastro/images/pixelmath.svg +42 -0
  12. setiastro/images/waning_crescent_1.png +0 -0
  13. setiastro/images/waning_crescent_2.png +0 -0
  14. setiastro/images/waning_crescent_3.png +0 -0
  15. setiastro/images/waning_crescent_4.png +0 -0
  16. setiastro/images/waning_crescent_5.png +0 -0
  17. setiastro/images/waning_gibbous_1.png +0 -0
  18. setiastro/images/waning_gibbous_2.png +0 -0
  19. setiastro/images/waning_gibbous_3.png +0 -0
  20. setiastro/images/waning_gibbous_4.png +0 -0
  21. setiastro/images/waning_gibbous_5.png +0 -0
  22. setiastro/images/waxing_crescent_1.png +0 -0
  23. setiastro/images/waxing_crescent_2.png +0 -0
  24. setiastro/images/waxing_crescent_3.png +0 -0
  25. setiastro/images/waxing_crescent_4.png +0 -0
  26. setiastro/images/waxing_crescent_5.png +0 -0
  27. setiastro/images/waxing_gibbous_1.png +0 -0
  28. setiastro/images/waxing_gibbous_2.png +0 -0
  29. setiastro/images/waxing_gibbous_3.png +0 -0
  30. setiastro/images/waxing_gibbous_4.png +0 -0
  31. setiastro/images/waxing_gibbous_5.png +0 -0
  32. setiastro/qml/ResourceMonitor.qml +84 -82
  33. setiastro/saspro/__main__.py +19 -0
  34. setiastro/saspro/_generated/build_info.py +2 -2
  35. setiastro/saspro/abe.py +37 -4
  36. setiastro/saspro/aberration_ai.py +237 -21
  37. setiastro/saspro/acv_exporter.py +379 -0
  38. setiastro/saspro/add_stars.py +33 -6
  39. setiastro/saspro/backgroundneutral.py +35 -7
  40. setiastro/saspro/blemish_blaster.py +4 -1
  41. setiastro/saspro/blink_comparator_pro.py +74 -24
  42. setiastro/saspro/clahe.py +4 -1
  43. setiastro/saspro/continuum_subtract.py +4 -1
  44. setiastro/saspro/convo.py +4 -1
  45. setiastro/saspro/cosmicclarity.py +129 -18
  46. setiastro/saspro/crop_dialog_pro.py +123 -7
  47. setiastro/saspro/curve_editor_pro.py +109 -42
  48. setiastro/saspro/doc_manager.py +67 -4
  49. setiastro/saspro/exoplanet_detector.py +120 -28
  50. setiastro/saspro/frequency_separation.py +1158 -204
  51. setiastro/saspro/ghs_dialog_pro.py +81 -16
  52. setiastro/saspro/graxpert.py +1 -0
  53. setiastro/saspro/gui/main_window.py +393 -204
  54. setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
  55. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  56. setiastro/saspro/gui/mixins/toolbar_mixin.py +356 -12
  57. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  58. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  59. setiastro/saspro/halobgon.py +4 -0
  60. setiastro/saspro/histogram.py +5 -1
  61. setiastro/saspro/image_combine.py +4 -0
  62. setiastro/saspro/image_peeker_pro.py +4 -0
  63. setiastro/saspro/imageops/stretch.py +531 -62
  64. setiastro/saspro/isophote.py +4 -0
  65. setiastro/saspro/layers.py +13 -9
  66. setiastro/saspro/layers_dock.py +183 -3
  67. setiastro/saspro/legacy/image_manager.py +154 -20
  68. setiastro/saspro/legacy/numba_utils.py +43 -0
  69. setiastro/saspro/legacy/xisf.py +240 -98
  70. setiastro/saspro/live_stacking.py +180 -79
  71. setiastro/saspro/luminancerecombine.py +228 -27
  72. setiastro/saspro/mask_creation.py +174 -15
  73. setiastro/saspro/mfdeconv.py +113 -35
  74. setiastro/saspro/mfdeconvcudnn.py +119 -70
  75. setiastro/saspro/mfdeconvsport.py +112 -35
  76. setiastro/saspro/morphology.py +4 -0
  77. setiastro/saspro/multiscale_decomp.py +51 -12
  78. setiastro/saspro/numba_utils.py +72 -2
  79. setiastro/saspro/ops/commands.py +18 -18
  80. setiastro/saspro/ops/script_editor.py +5 -2
  81. setiastro/saspro/ops/scripts.py +3 -0
  82. setiastro/saspro/perfect_palette_picker.py +37 -3
  83. setiastro/saspro/plate_solver.py +84 -49
  84. setiastro/saspro/psf_viewer.py +119 -37
  85. setiastro/saspro/resources.py +67 -0
  86. setiastro/saspro/rgbalign.py +4 -0
  87. setiastro/saspro/selective_color.py +4 -1
  88. setiastro/saspro/sfcc.py +60 -2
  89. setiastro/saspro/shortcuts.py +142 -23
  90. setiastro/saspro/signature_insert.py +692 -33
  91. setiastro/saspro/stacking_suite.py +1017 -400
  92. setiastro/saspro/star_alignment.py +4 -1
  93. setiastro/saspro/star_spikes.py +4 -0
  94. setiastro/saspro/star_stretch.py +38 -3
  95. setiastro/saspro/stat_stretch.py +702 -128
  96. setiastro/saspro/subwindow.py +786 -360
  97. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  98. setiastro/saspro/wavescale_hdr.py +4 -1
  99. setiastro/saspro/wavescalede.py +4 -1
  100. setiastro/saspro/whitebalance.py +60 -12
  101. setiastro/saspro/widgets/common_utilities.py +28 -21
  102. setiastro/saspro/widgets/resource_monitor.py +109 -59
  103. setiastro/saspro/widgets/spinboxes.py +10 -13
  104. setiastro/saspro/wimi.py +27 -656
  105. setiastro/saspro/wims.py +13 -3
  106. setiastro/saspro/xisf.py +101 -11
  107. {setiastrosuitepro-1.6.4.dist-info β†’ setiastrosuitepro-1.6.10.dist-info}/METADATA +2 -1
  108. {setiastrosuitepro-1.6.4.dist-info β†’ setiastrosuitepro-1.6.10.dist-info}/RECORD +112 -80
  109. {setiastrosuitepro-1.6.4.dist-info β†’ setiastrosuitepro-1.6.10.dist-info}/WHEEL +0 -0
  110. {setiastrosuitepro-1.6.4.dist-info β†’ setiastrosuitepro-1.6.10.dist-info}/entry_points.txt +0 -0
  111. {setiastrosuitepro-1.6.4.dist-info β†’ setiastrosuitepro-1.6.10.dist-info}/licenses/LICENSE +0 -0
  112. {setiastrosuitepro-1.6.4.dist-info β†’ setiastrosuitepro-1.6.10.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,17 +153,168 @@ _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
- Fast header-only FITS peek with a targeted fallback:
267
+ Fast header-only peek with targeted fallback.
158
268
 
159
- 1) Header-only scan (lazy_load_hdus=True, never touches .data)
160
- 2) If NAXIS1/2 still missing/invalid, fallback to reading ONE image HDU's data
161
- to get shape, then patch NAXIS/NAXIS1/NAXIS2.
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.
162
273
 
163
- Returns: (hdr, ok_bool)
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
164
281
  """
165
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
+ # ---------------------------
166
318
  from astropy.io import fits
167
319
 
168
320
  def _is_good_dim(v):
@@ -211,18 +363,16 @@ def get_valid_header(path: str):
211
363
  if not _is_good_dim(hdr.get("NAXIS2")) and _is_good_dim(hdr.get("ZNAXIS2")):
212
364
  hdr["NAXIS2"] = int(hdr["ZNAXIS2"])
213
365
 
214
- # If we already have good dims, we are done (FAST PATH)
366
+ # FAST PATH
215
367
  if _is_good_dim(hdr.get("NAXIS1")) and _is_good_dim(hdr.get("NAXIS2")):
216
368
  return hdr, True
217
369
 
218
370
  # ---------------------------
219
371
  # Pass 2: slow fallback (ONLY if needed)
220
372
  # ---------------------------
221
- # Re-open without lazy semantics and read ONE image-like HDU's data to infer shape.
222
373
  with fits.open(path, mode="readonly", memmap=False) as hdul:
223
374
  target_hdu = None
224
375
  for hdu in hdul:
225
- # data access is expensive; try to choose wisely by header first
226
376
  naxis = hdu.header.get("NAXIS", 0)
227
377
  znaxis = hdu.header.get("ZNAXIS", 0)
228
378
 
@@ -236,10 +386,9 @@ def get_valid_header(path: str):
236
386
  if target_hdu is None:
237
387
  target_hdu = hdul[0]
238
388
 
239
- # Now (and only now) touch data
240
389
  data = getattr(target_hdu, "data", None)
241
-
242
390
  hdr2 = target_hdu.header.copy()
391
+
243
392
  if data is not None and getattr(data, "ndim", 0) >= 2:
244
393
  try:
245
394
  ny, nx = data.shape[-2], data.shape[-1]
@@ -250,12 +399,12 @@ def get_valid_header(path: str):
250
399
  except Exception:
251
400
  pass
252
401
 
253
- # If still unknown, return header anyway (caller can show "Unknown")
254
402
  return hdr2, True
255
403
 
256
404
  except Exception:
257
405
  return None, False
258
406
 
407
+
259
408
  def _read_tile_stack(file_list, y0, y1, x0, x1, channels, out_buf):
260
409
  """
261
410
  Fill `out_buf` with the tile stack for (y0:y1, x0:x1).
@@ -3948,6 +4097,85 @@ def _bias_to_match_light(light_data, master_bias):
3948
4097
  return b[:, :, 0][None, :, :] # (H,W,1) -> (1,H,W)
3949
4098
  return b
3950
4099
 
4100
+ def _read_center_patch_via_mmimage(path: str, y0: int, y1: int, x0: int, x1: int):
4101
+ src = _MMImage(path)
4102
+ try:
4103
+ sub = src.read_tile(y0, y1, x0, x1)
4104
+ return sub
4105
+ finally:
4106
+ try:
4107
+ src.close()
4108
+ except Exception:
4109
+ pass
4110
+
4111
+ def _get_key_float(hdr: fits.Header, key: str):
4112
+ try:
4113
+ v = hdr.get(key, None)
4114
+ if v is None:
4115
+ return None
4116
+ # handle strings like "-10.0" or "-10 C"
4117
+ if isinstance(v, str):
4118
+ v = v.strip().replace("C", "").replace("Β°", "").strip()
4119
+ return float(v)
4120
+ except Exception:
4121
+ return None
4122
+
4123
+ def _collect_temp_stats(file_list: list[str]):
4124
+ ccd = []
4125
+ setp = []
4126
+ n_ccd = 0
4127
+ n_set = 0
4128
+
4129
+ for p in file_list:
4130
+ try:
4131
+ hdr = fits.getheader(p, memmap=True)
4132
+ except Exception:
4133
+ continue
4134
+
4135
+ v1 = _get_key_float(hdr, "CCD-TEMP")
4136
+ v2 = _get_key_float(hdr, "SET-TEMP")
4137
+
4138
+ if v1 is not None:
4139
+ ccd.append(v1); n_ccd += 1
4140
+ if v2 is not None:
4141
+ setp.append(v2); n_set += 1
4142
+
4143
+ def _stats(arr):
4144
+ if not arr:
4145
+ return None, None, None, None
4146
+ a = np.asarray(arr, dtype=np.float32)
4147
+ return float(np.median(a)), float(np.min(a)), float(np.max(a)), float(np.std(a))
4148
+
4149
+ c_med, c_min, c_max, c_std = _stats(ccd)
4150
+ s_med, s_min, s_max, s_std = _stats(setp)
4151
+
4152
+ return {
4153
+ "ccd_med": c_med, "ccd_min": c_min, "ccd_max": c_max, "ccd_std": c_std, "ccd_n": n_ccd,
4154
+ "set_med": s_med, "set_min": s_min, "set_max": s_max, "set_std": s_std, "set_n": n_set,
4155
+ "n_files": len(file_list),
4156
+ }
4157
+
4158
+ def _temp_to_stem_tag(temp_c: float, *, prefix: str = "") -> str:
4159
+ """
4160
+ Filename-safe temperature token:
4161
+ -10.0 -> 'm10p0C'
4162
+ +5.25 -> 'p5p3C' (rounded to 0.1C if you pass that in)
4163
+ Uses:
4164
+ m = minus, p = plus/decimal separator
4165
+ Never produces '_-' which your _normalize_master_stem would collapse.
4166
+ """
4167
+ try:
4168
+ t = float(temp_c)
4169
+ except Exception:
4170
+ return ""
4171
+
4172
+ sign = "m" if t < 0 else "p"
4173
+ t_abs = abs(t)
4174
+
4175
+ # keep one decimal place (match your earlier plan)
4176
+ s = f"{t_abs:.1f}" # e.g. "10.0"
4177
+ s = s.replace(".", "p") # e.g. "10p0"
4178
+ return f"{prefix}{sign}{s}C"
3951
4179
 
3952
4180
  class StackingSuiteDialog(QDialog):
3953
4181
  requestRelaunch = pyqtSignal(str, str) # old_dir, new_dir
@@ -4087,7 +4315,7 @@ class StackingSuiteDialog(QDialog):
4087
4315
  self.image_integration_tab = self.create_image_registration_tab()
4088
4316
 
4089
4317
  # Add tabs
4090
- self.tabs.addTab(self.conversion_tab, self.tr("Convert Non-FITS Formats"))
4318
+ self.tabs.addTab(self.conversion_tab, self.tr("Convert Camera RAW/TIFF Formats"))
4091
4319
  self.tabs.addTab(self.dark_tab, self.tr("Darks"))
4092
4320
  self.tabs.addTab(self.flat_tab, self.tr("Flats"))
4093
4321
  self.tabs.addTab(self.light_tab, self.tr("Lights"))
@@ -6500,6 +6728,22 @@ class StackingSuiteDialog(QDialog):
6500
6728
 
6501
6729
  return tab
6502
6730
 
6731
+ def _bucket_temp(self, t: float | None, step: float = 3.0) -> float | None:
6732
+ """Round to stable bucket. Example: -10.2 -> -10.0 when step=1.0"""
6733
+ if t is None:
6734
+ return None
6735
+ try:
6736
+ return round(float(t) / float(step)) * float(step)
6737
+ except Exception:
6738
+ return None
6739
+
6740
+ def _temp_label(self, t: float | None, step: float = 1.0) -> str:
6741
+ if t is None:
6742
+ return "Temp: Unknown"
6743
+ # show fewer decimals if step is 1.0
6744
+ return f"Temp: {t:+.0f}C" if step >= 1.0 else f"Temp: {t:+.1f}C"
6745
+
6746
+
6503
6747
  def _tree_for_type(self, t: str):
6504
6748
  t = (t or "").upper()
6505
6749
  if t == "LIGHT": return getattr(self, "light_tree", None)
@@ -8162,13 +8406,18 @@ class StackingSuiteDialog(QDialog):
8162
8406
  mf_row3.addWidget(self.mf_Huber_hint)
8163
8407
 
8164
8408
  mf_row3.addSpacing(16)
8409
+
8165
8410
  self.mf_use_star_mask_cb = QCheckBox(self.tr("Auto Star Mask"))
8166
8411
  self.mf_use_noise_map_cb = QCheckBox(self.tr("Auto Noise Map"))
8167
- self.mf_use_star_mask_cb.setChecked(self.settings.value("stacking/mfdeconv/use_star_masks", False, type=bool))
8168
- self.mf_use_noise_map_cb.setChecked(self.settings.value("stacking/mfdeconv/use_noise_maps", False, type=bool))
8412
+
8413
+ # Always ON by default (session-only toggles)
8414
+ self.mf_use_star_mask_cb.setChecked(True)
8415
+ self.mf_use_noise_map_cb.setChecked(True)
8416
+
8169
8417
  mf_row3.addWidget(self.mf_use_star_mask_cb)
8170
8418
  mf_row3.addWidget(self.mf_use_noise_map_cb)
8171
8419
  mf_row3.addStretch(1)
8420
+
8172
8421
  mf_v.addLayout(mf_row3)
8173
8422
 
8174
8423
  # persist
@@ -9653,7 +9902,10 @@ class StackingSuiteDialog(QDialog):
9653
9902
  def load_master_dark(self):
9654
9903
  """ Loads a Master Dark and updates the UI. """
9655
9904
  last_dir = self.settings.value("last_opened_folder", "", type=str) # Get last folder
9656
- files, _ = QFileDialog.getOpenFileNames(self, "Select Master Dark", last_dir, "FITS Files (*.fits *.fit)")
9905
+ files, _ = QFileDialog.getOpenFileNames(
9906
+ self, "Select Master Dark", last_dir,
9907
+ "Master Calibration (*.fits *.fit *.xisf);;All Files (*)"
9908
+ )
9657
9909
 
9658
9910
  if files:
9659
9911
  self.settings.setValue("last_opened_folder", os.path.dirname(files[0])) # Save last used folder
@@ -9668,7 +9920,10 @@ class StackingSuiteDialog(QDialog):
9668
9920
 
9669
9921
  def load_master_flat(self):
9670
9922
  last_dir = self.settings.value("last_opened_folder", "", type=str)
9671
- files, _ = QFileDialog.getOpenFileNames(self, "Select Master Flat", last_dir, "FITS Files (*.fits *.fit)")
9923
+ files, _ = QFileDialog.getOpenFileNames(
9924
+ self, "Select Master Flat", last_dir,
9925
+ "Master Calibration (*.fits *.fit *.xisf);;All Files (*)"
9926
+ )
9672
9927
 
9673
9928
  if files:
9674
9929
  self.settings.setValue("last_opened_folder", os.path.dirname(files[0]))
@@ -9681,7 +9936,7 @@ class StackingSuiteDialog(QDialog):
9681
9936
  last_dir = self.settings.value("last_opened_folder", "", type=str)
9682
9937
  files, _ = QFileDialog.getOpenFileNames(
9683
9938
  self, title, last_dir,
9684
- "FITS Files (*.fits *.fit *.fts *.fits.gz *.fit.gz *.fz)"
9939
+ "Images (*.fits *.fit *.fts *.fits.gz *.fit.gz *.fz *.xisf);;All Files (*)"
9685
9940
  )
9686
9941
  if not files:
9687
9942
  return
@@ -9760,7 +10015,7 @@ class StackingSuiteDialog(QDialog):
9760
10015
 
9761
10016
  # --- Directory walking ---------------------------------------------------------
9762
10017
  def _collect_fits_paths(self, root: str, recursive: bool = True) -> list[str]:
9763
- exts = (".fits", ".fit", ".fts", ".fits.gz", ".fit.gz", ".fz")
10018
+ exts = (".fits", ".fit", ".fts", ".fits.gz", ".fit.gz", ".fz", ".xisf")
9764
10019
  paths = []
9765
10020
  if recursive:
9766
10021
  for d, _subdirs, files in os.walk(root):
@@ -10203,14 +10458,14 @@ class StackingSuiteDialog(QDialog):
10203
10458
  try:
10204
10459
  expected_type_u = (expected_type or "").upper()
10205
10460
 
10206
- # Ensure caches exist
10207
10461
  if not hasattr(self, "_mismatch_policy") or self._mismatch_policy is None:
10208
10462
  self._mismatch_policy = {}
10209
10463
  if not hasattr(self, "session_tags") or self.session_tags is None:
10210
10464
  self.session_tags = {}
10211
10465
 
10212
- # --- Read header only (fast) ---
10213
- header, _ = get_valid_header(path)
10466
+ header, ok = get_valid_header(path)
10467
+ if not ok or header is None:
10468
+ raise RuntimeError("Header read failed")
10214
10469
 
10215
10470
  # --- Basic image size ---
10216
10471
  try:
@@ -10218,7 +10473,9 @@ class StackingSuiteDialog(QDialog):
10218
10473
  height = int(header.get("NAXIS2", 0))
10219
10474
  image_size = f"{width}x{height}" if (width > 0 and height > 0) else "Unknown"
10220
10475
  except Exception as e:
10221
- self.update_status(self.tr(f"Warning: Could not read dimensions for {os.path.basename(path)}: {e}"))
10476
+ self.update_status(self.tr(
10477
+ f"Warning: Could not read dimensions for {os.path.basename(path)}: {e}"
10478
+ ))
10222
10479
  width = height = None
10223
10480
  image_size = "Unknown"
10224
10481
 
@@ -10235,7 +10492,6 @@ class StackingSuiteDialog(QDialog):
10235
10492
  exposure_text = f"{fexp:g}s"
10236
10493
  except Exception:
10237
10494
  exposure_text = str(exp_val)
10238
-
10239
10495
  # --- Mismatch prompt (redirect/keep/skip with 'apply to all') ---
10240
10496
  if expected_type_u == "DARK":
10241
10497
  forbidden = ["light", "flat"]
@@ -10308,39 +10564,97 @@ class StackingSuiteDialog(QDialog):
10308
10564
 
10309
10565
  # --- Resolve session tag (auto vs keyword-driven) ---
10310
10566
  auto_session = self.settings.value("stacking/auto_session", True, type=bool)
10311
-
10312
10567
  if auto_session:
10313
10568
  session_tag = self._auto_session_from_path(path, header) or "Default"
10314
10569
  else:
10315
- # NOTE: this is a keyword now, not a literal session name
10316
10570
  keyword = self.settings.value("stacking/session_keyword", "Default", type=str)
10317
10571
  session_tag = self._session_from_manual_keyword(path, keyword) or "Default"
10318
10572
 
10319
- # --- Filter name normalization ---
10320
- filter_name_raw = header.get("FILTER", "Unknown")
10321
- filter_name = self._sanitize_name(filter_name_raw)
10573
+ # --- Temperature (fast: header already loaded) ---
10574
+ ccd_temp = header.get("CCD-TEMP", None)
10575
+ set_temp = header.get("SET-TEMP", None)
10576
+
10577
+ def _to_float_temp(v):
10578
+ try:
10579
+ if v is None:
10580
+ return None
10581
+ if isinstance(v, (int, float)):
10582
+ return float(v)
10583
+ s = str(v).strip()
10584
+ s = s.replace("Β°", "").replace("C", "").replace("c", "").strip()
10585
+ return float(s)
10586
+ except Exception:
10587
+ return None
10588
+
10589
+ ccd_temp_f = _to_float_temp(ccd_temp)
10590
+ set_temp_f = _to_float_temp(set_temp)
10591
+ use_temp_f = ccd_temp_f if ccd_temp_f is not None else set_temp_f
10592
+
10593
+ # --- Common metadata string for leaf rows ---
10594
+ meta_text = f"Size: {image_size} | Session: {session_tag}"
10595
+ if use_temp_f is not None:
10596
+ meta_text += f" | Temp: {use_temp_f:.1f}C"
10597
+ if set_temp_f is not None:
10598
+ meta_text += f" (Set: {set_temp_f:.1f}C)"
10322
10599
 
10323
10600
  # --- Common metadata string for leaf rows ---
10324
10601
  meta_text = f"Size: {image_size} | Session: {session_tag}"
10325
10602
 
10326
10603
  # === DARKs ===
10327
10604
  if expected_type_u == "DARK":
10328
- key = f"{exposure_text} ({image_size})"
10329
- self.dark_files.setdefault(key, []).append(path)
10605
+ # --- temperature for grouping (prefer CCD-TEMP else SET-TEMP) ---
10606
+ ccd_t = _get_key_float(header, "CCD-TEMP")
10607
+ set_t = _get_key_float(header, "SET-TEMP")
10608
+ chosen_t = ccd_t if ccd_t is not None else set_t
10609
+
10610
+ temp_step = self.settings.value("stacking/temp_group_step", 1.0, type=float)
10611
+ temp_bucket = self._bucket_temp(chosen_t, step=temp_step)
10612
+ temp_label = self._temp_label(temp_bucket, step=temp_step)
10613
+
10614
+ # --- tree grouping: exposure/size -> temp bucket -> files ---
10615
+ base_key = f"{exposure_text} ({image_size})"
10616
+
10617
+ # ensure caches exist
10618
+ if not hasattr(self, "_dark_group_item") or self._dark_group_item is None:
10619
+ self._dark_group_item = {}
10620
+ if not hasattr(self, "_dark_temp_item") or self._dark_temp_item is None:
10621
+ self._dark_temp_item = {} # (base_key, temp_label) -> QTreeWidgetItem
10330
10622
 
10331
- exposure_item = self._dark_group_item.get(key)
10623
+ # top-level exposure group
10624
+ exposure_item = self._dark_group_item.get(base_key)
10332
10625
  if exposure_item is None:
10333
- exposure_item = QTreeWidgetItem([key])
10626
+ exposure_item = QTreeWidgetItem([base_key, ""])
10334
10627
  tree.addTopLevelItem(exposure_item)
10335
- self._dark_group_item[key] = exposure_item
10336
-
10337
- leaf = QTreeWidgetItem([os.path.basename(path), meta_text])
10628
+ self._dark_group_item[base_key] = exposure_item
10629
+
10630
+ # second-level temp group under that exposure group
10631
+ temp_key = (base_key, temp_label)
10632
+ temp_item = self._dark_temp_item.get(temp_key)
10633
+ if temp_item is None:
10634
+ temp_item = QTreeWidgetItem([temp_label, ""])
10635
+ exposure_item.addChild(temp_item)
10636
+ self._dark_temp_item[temp_key] = temp_item
10637
+
10638
+ # --- store in dict for stacking ---
10639
+ # Key includes session + temp bucket so create_master_dark can split properly.
10640
+ # (We keep compatibility: your create_master_dark already handles tuple keys.)
10641
+ composite_key = (base_key, session_tag, temp_bucket)
10642
+ self.dark_files.setdefault(composite_key, []).append(path)
10643
+
10644
+ # --- leaf row ---
10645
+ # Also add temp info to metadata text so user can see it per file
10646
+ meta_text_dark = f"Size: {image_size} | Session: {session_tag} | {temp_label}"
10647
+ leaf = QTreeWidgetItem([os.path.basename(path), meta_text_dark])
10338
10648
  leaf.setData(0, Qt.ItemDataRole.UserRole, path)
10339
10649
  leaf.setData(0, Qt.ItemDataRole.UserRole + 1, session_tag)
10340
- exposure_item.addChild(leaf)
10650
+ leaf.setData(0, Qt.ItemDataRole.UserRole + 2, temp_bucket) # handy later
10651
+ temp_item.addChild(leaf)
10341
10652
 
10342
10653
  # === FLATs ===
10343
10654
  elif expected_type_u == "FLAT":
10655
+ filter_name_raw = header.get("FILTER") or "Unknown"
10656
+ filter_name = self._sanitize_name(filter_name_raw)
10657
+
10344
10658
  flat_key = f"{filter_name} - {exposure_text} ({image_size})"
10345
10659
  composite_key = (flat_key, session_tag)
10346
10660
  self.flat_files.setdefault(composite_key, []).append(path)
@@ -10368,12 +10682,14 @@ class StackingSuiteDialog(QDialog):
10368
10682
 
10369
10683
  # === LIGHTs ===
10370
10684
  elif expected_type_u == "LIGHT":
10685
+ filter_name_raw = header.get("FILTER") or "Unknown"
10686
+ filter_name = self._sanitize_name(filter_name_raw)
10687
+
10371
10688
  light_key = f"{filter_name} - {exposure_text} ({image_size})"
10372
10689
  composite_key = (light_key, session_tag)
10373
10690
  self.light_files.setdefault(composite_key, []).append(path)
10374
10691
  self.session_tags[path] = session_tag
10375
10692
 
10376
- # Cached filter item
10377
10693
  filter_item = self._light_filter_item.get(filter_name)
10378
10694
  if filter_item is None:
10379
10695
  filter_item = QTreeWidgetItem([filter_name])
@@ -10383,7 +10699,6 @@ class StackingSuiteDialog(QDialog):
10383
10699
  want_label = f"{exposure_text} ({image_size})"
10384
10700
  exp_key = (filter_name, want_label)
10385
10701
 
10386
- # Cached exposure item
10387
10702
  exposure_item = self._light_exp_item.get(exp_key)
10388
10703
  if exposure_item is None:
10389
10704
  exposure_item = QTreeWidgetItem([want_label])
@@ -10391,7 +10706,7 @@ class StackingSuiteDialog(QDialog):
10391
10706
  self._light_exp_item[exp_key] = exposure_item
10392
10707
 
10393
10708
  leaf = QTreeWidgetItem([os.path.basename(path), meta_text])
10394
- leaf.setData(0, Qt.ItemDataRole.UserRole, path) # βœ… keep this
10709
+ leaf.setData(0, Qt.ItemDataRole.UserRole, path)
10395
10710
  leaf.setData(0, Qt.ItemDataRole.UserRole + 1, session_tag)
10396
10711
  exposure_item.addChild(leaf)
10397
10712
 
@@ -10411,7 +10726,7 @@ class StackingSuiteDialog(QDialog):
10411
10726
  for file_path in files:
10412
10727
  try:
10413
10728
  # Read only the FITS header (fast)
10414
- header = fits.getheader(file_path)
10729
+ header, _kind = get_valid_header(file_path)
10415
10730
 
10416
10731
  # Check for both EXPOSURE and EXPTIME
10417
10732
  exposure = header.get("EXPOSURE", header.get("EXPTIME", "Unknown"))
@@ -10427,7 +10742,13 @@ class StackingSuiteDialog(QDialog):
10427
10742
 
10428
10743
  # Construct key based on file type
10429
10744
  if file_type.upper() == "DARK":
10430
- key = f"{exposure}s ({image_size})"
10745
+ try:
10746
+ exposure_f = float(exposure)
10747
+ exposure_text = f"{exposure_f:g}s"
10748
+ except Exception:
10749
+ exposure_text = f"{exposure}s" if str(exposure).endswith("s") else str(exposure)
10750
+
10751
+ key = f"{exposure_text} ({image_size})"
10431
10752
  self.master_files[key] = file_path # Store master dark
10432
10753
  self.master_sizes[file_path] = image_size # Store size
10433
10754
  elif file_type.upper() == "FLAT":
@@ -10489,14 +10810,39 @@ class StackingSuiteDialog(QDialog):
10489
10810
  exposure_tolerance = self.exposure_tolerance_spinbox.value()
10490
10811
 
10491
10812
  # -------------------------------------------------------------------------
10492
- # Group darks by (exposure +/- tolerance, image size string, session)
10493
- # self.dark_files can be either:
10494
- # legacy: exposure_key -> [paths]
10495
- # session: (exposure_key, session) -> [paths]
10813
+ # Temp helpers
10496
10814
  # -------------------------------------------------------------------------
10497
- dark_files_by_group: dict[tuple[float, str, str], list[str]] = {} # (exp, size, session)->list
10815
+ def _bucket_temp(t: float | None, step: float = 3.0) -> float | None:
10816
+ """Round temperature to a stable bucket (e.g. -10.2 -> -10.0 if step=1.0)."""
10817
+ if t is None:
10818
+ return None
10819
+ try:
10820
+ return round(float(t) / step) * step
10821
+ except Exception:
10822
+ return None
10823
+
10824
+ def _read_temp_quick(path: str) -> tuple[float | None, float | None, float | None]:
10825
+ """Fast temp read (CCD, SET, chosen). Uses fits.getheader(memmap=True)."""
10826
+ try:
10827
+ hdr = fits.getheader(path, memmap=True)
10828
+ except Exception:
10829
+ return None, None, None
10830
+ ccd = _get_key_float(hdr, "CCD-TEMP")
10831
+ st = _get_key_float(hdr, "SET-TEMP")
10832
+ chosen = ccd if ccd is not None else st
10833
+ return ccd, st, chosen
10834
+
10835
+ # -------------------------------------------------------------------------
10836
+ # Group darks by (exposure +/- tolerance, image size, session, temp_bucket)
10837
+ # TEMP_STEP is the rounding bucket (1.0C default)
10838
+ # -------------------------------------------------------------------------
10839
+ TEMP_STEP = self.settings.value("stacking/temp_group_step", 1.0, type=float)
10840
+
10841
+ dark_files_by_group: dict[tuple[float, str, str, float | None], list[str]] = {} # (exp,size,session,temp)->list
10498
10842
 
10499
10843
  for key, file_list in (self.dark_files or {}).items():
10844
+ # Support both legacy dark_files (key=str) and newer tuple keys.
10845
+ # We DO NOT assume dark_files already contains temp in key β€” we re-bucket from headers anyway.
10500
10846
  if isinstance(key, tuple) and len(key) >= 2:
10501
10847
  exposure_key = str(key[0])
10502
10848
  session = str(key[1]) if str(key[1]).strip() else "Default"
@@ -10508,10 +10854,9 @@ class StackingSuiteDialog(QDialog):
10508
10854
  exposure_time_str, image_size = exposure_key.split(" (", 1)
10509
10855
  image_size = image_size.rstrip(")")
10510
10856
  except ValueError:
10511
- # If some malformed key got in, skip safely
10512
10857
  continue
10513
10858
 
10514
- if "Unknown" in exposure_time_str:
10859
+ if "Unknown" in (exposure_time_str or ""):
10515
10860
  exposure_time = 0.0
10516
10861
  else:
10517
10862
  try:
@@ -10519,21 +10864,31 @@ class StackingSuiteDialog(QDialog):
10519
10864
  except Exception:
10520
10865
  exposure_time = 0.0
10521
10866
 
10522
- matched_group = None
10523
- for (existing_exposure, existing_size, existing_session) in list(dark_files_by_group.keys()):
10524
- if (
10525
- existing_session == session
10526
- and existing_size == image_size
10527
- and abs(existing_exposure - exposure_time) <= exposure_tolerance
10528
- ):
10529
- matched_group = (existing_exposure, existing_size, existing_session)
10530
- break
10867
+ # Split the incoming list by temp bucket so mixed temps do not merge.
10868
+ bucketed: dict[float | None, list[str]] = {}
10869
+ for p in (file_list or []):
10870
+ _, _, chosen = _read_temp_quick(p)
10871
+ tb = _bucket_temp(chosen, step=TEMP_STEP)
10872
+ bucketed.setdefault(tb, []).append(p)
10873
+
10874
+ # Apply exposure tolerance grouping PER temp bucket
10875
+ for temp_bucket, paths_in_bucket in bucketed.items():
10876
+ matched_group = None
10877
+ for (existing_exposure, existing_size, existing_session, existing_temp) in list(dark_files_by_group.keys()):
10878
+ if (
10879
+ existing_session == session
10880
+ and existing_size == image_size
10881
+ and existing_temp == temp_bucket
10882
+ and abs(existing_exposure - exposure_time) <= exposure_tolerance
10883
+ ):
10884
+ matched_group = (existing_exposure, existing_size, existing_session, existing_temp)
10885
+ break
10531
10886
 
10532
- if matched_group is None:
10533
- matched_group = (exposure_time, image_size, session)
10534
- dark_files_by_group[matched_group] = []
10887
+ if matched_group is None:
10888
+ matched_group = (exposure_time, image_size, session, temp_bucket)
10889
+ dark_files_by_group[matched_group] = []
10535
10890
 
10536
- dark_files_by_group[matched_group].extend(file_list or [])
10891
+ dark_files_by_group[matched_group].extend(paths_in_bucket)
10537
10892
 
10538
10893
  master_dir = os.path.join(self.stacking_directory, "Master_Calibration_Files")
10539
10894
  os.makedirs(master_dir, exist_ok=True)
@@ -10542,11 +10897,11 @@ class StackingSuiteDialog(QDialog):
10542
10897
  # Informative status about discovery
10543
10898
  # -------------------------------------------------------------------------
10544
10899
  try:
10545
- n_groups = sum(1 for _, v in dark_files_by_group.items() if len(v) >= 2)
10900
+ n_groups_eligible = sum(1 for _, v in dark_files_by_group.items() if len(v) >= 2)
10546
10901
  total_files = sum(len(v) for v in dark_files_by_group.values())
10547
10902
  self.update_status(self.tr(
10548
10903
  f"πŸ”Ž Discovered {len(dark_files_by_group)} grouped exposures "
10549
- f"({n_groups} eligible to stack) β€” {total_files} files total."
10904
+ f"({n_groups_eligible} eligible to stack) β€” {total_files} files total."
10550
10905
  ))
10551
10906
  except Exception:
10552
10907
  pass
@@ -10556,12 +10911,12 @@ class StackingSuiteDialog(QDialog):
10556
10911
  # Pre-count tiles for progress bar (per-group safe chunk sizes)
10557
10912
  # -------------------------------------------------------------------------
10558
10913
  total_tiles = 0
10559
- group_shapes: dict[tuple[float, str, str], tuple[int, int, int, int, int]] = {} # (exp,size,session)->(H,W,C,ch,cw)
10914
+ group_shapes: dict[tuple[float, str, str, float | None], tuple[int, int, int, int, int]] = {}
10560
10915
  pref_chunk_h = self.chunk_height
10561
10916
  pref_chunk_w = self.chunk_width
10562
10917
  DTYPE = np.float32
10563
10918
 
10564
- for (exposure_time, image_size, session), file_list in dark_files_by_group.items():
10919
+ for (exposure_time, image_size, session, temp_bucket), file_list in dark_files_by_group.items():
10565
10920
  if len(file_list) < 2:
10566
10921
  continue
10567
10922
 
@@ -10579,7 +10934,8 @@ class StackingSuiteDialog(QDialog):
10579
10934
  except MemoryError:
10580
10935
  chunk_h, chunk_w = pref_chunk_h, pref_chunk_w
10581
10936
 
10582
- group_shapes[(exposure_time, image_size, session)] = (H, W, C, chunk_h, chunk_w)
10937
+ gk = (exposure_time, image_size, session, temp_bucket)
10938
+ group_shapes[gk] = (H, W, C, chunk_h, chunk_w)
10583
10939
  total_tiles += _count_tiles(H, W, chunk_h, chunk_w)
10584
10940
 
10585
10941
  if total_tiles == 0:
@@ -10592,7 +10948,7 @@ class StackingSuiteDialog(QDialog):
10592
10948
  QApplication.processEvents()
10593
10949
 
10594
10950
  # -------------------------------------------------------------------------
10595
- # Local CPU reducers (unchanged)
10951
+ # Local CPU reducers
10596
10952
  # -------------------------------------------------------------------------
10597
10953
  def _select_reducer(kind: str, N: int):
10598
10954
  if kind == "dark":
@@ -10636,10 +10992,10 @@ class StackingSuiteDialog(QDialog):
10636
10992
  # ---------------------------------------------------------------------
10637
10993
  # Per-group stacking loop
10638
10994
  # ---------------------------------------------------------------------
10639
- for (exposure_time, image_size, session), file_list in dark_files_by_group.items():
10995
+ for (exposure_time, image_size, session, temp_bucket), file_list in dark_files_by_group.items():
10640
10996
  if len(file_list) < 2:
10641
10997
  self.update_status(self.tr(
10642
- f"⚠️ Skipping {exposure_time}s ({image_size}) [{session}] - Not enough frames to stack."
10998
+ f"⚠️ Skipping {exposure_time:g}s ({image_size}) [{session}] - Not enough frames to stack."
10643
10999
  ))
10644
11000
  QApplication.processEvents()
10645
11001
  continue
@@ -10648,14 +11004,17 @@ class StackingSuiteDialog(QDialog):
10648
11004
  self.update_status(self.tr("β›” Master Dark creation cancelled."))
10649
11005
  break
10650
11006
 
11007
+ temp_txt = "Unknown" if temp_bucket is None else f"{float(temp_bucket):+.1f}C"
10651
11008
  self.update_status(self.tr(
10652
- f"🟒 Processing {len(file_list)} darks for {exposure_time}s ({image_size}) in session '{session}'…"
11009
+ f"🟒 Processing {len(file_list)} darks for {exposure_time:g}s ({image_size}) "
11010
+ f"in session '{session}' at {temp_txt}…"
10653
11011
  ))
10654
11012
  QApplication.processEvents()
10655
11013
 
10656
11014
  # --- reference shape and per-group chunk size ---
10657
- if (exposure_time, image_size, session) in group_shapes:
10658
- height, width, channels, chunk_height, chunk_width = group_shapes[(exposure_time, image_size, session)]
11015
+ gk = (exposure_time, image_size, session, temp_bucket)
11016
+ if gk in group_shapes:
11017
+ height, width, channels, chunk_height, chunk_width = group_shapes[gk]
10659
11018
  else:
10660
11019
  ref_data, _, _, _ = load_image(file_list[0])
10661
11020
  if ref_data is None:
@@ -10695,8 +11054,11 @@ class StackingSuiteDialog(QDialog):
10695
11054
  QApplication.processEvents()
10696
11055
  continue
10697
11056
 
10698
- # Include session to prevent collisions
10699
- memmap_path = os.path.join(master_dir, f"temp_dark_{session}_{exposure_time}_{image_size}.dat")
11057
+ # Create temp memmap (stem-safe normalization)
11058
+ tb_tag = "notemp" if temp_bucket is None else _temp_to_stem_tag(float(temp_bucket))
11059
+ memmap_base = f"temp_dark_{session}_{exposure_time:g}s_{image_size}_{tb_tag}.dat"
11060
+ memmap_base = self._normalize_master_stem(memmap_base)
11061
+ memmap_path = os.path.join(master_dir, memmap_base)
10700
11062
 
10701
11063
  self.update_status(self.tr(
10702
11064
  f"πŸ—‚οΈ Creating temp memmap: {os.path.basename(memmap_path)} "
@@ -10708,6 +11070,7 @@ class StackingSuiteDialog(QDialog):
10708
11070
 
10709
11071
  tiles = _tile_grid(height, width, chunk_height, chunk_width)
10710
11072
  total_tiles_group = len(tiles)
11073
+
10711
11074
  self.update_status(self.tr(
10712
11075
  f"πŸ“¦ {total_tiles_group} tiles to process for this group (chunk {chunk_height}Γ—{chunk_width})."
10713
11076
  ))
@@ -10749,7 +11112,7 @@ class StackingSuiteDialog(QDialog):
10749
11112
  fut = tp.submit(_read_tile_into, (buf1 if use0 else buf0), ny0, ny1, nx0, nx1)
10750
11113
 
10751
11114
  pd.set_label(
10752
- f"{int(exposure_time)}s ({image_size}) [{session}] β€” "
11115
+ f"{int(exposure_time)}s ({image_size}) [{session}] [{temp_txt}] β€” "
10753
11116
  f"tile {t_idx}/{total_tiles_group} y:{y0}-{y1} x:{x0}-{x1}"
10754
11117
  )
10755
11118
 
@@ -10779,6 +11142,7 @@ class StackingSuiteDialog(QDialog):
10779
11142
 
10780
11143
  if tile_result.ndim == 2:
10781
11144
  tile_result = tile_result[:, :, None]
11145
+
10782
11146
  expected_shape = (th, tw, channels)
10783
11147
  if tile_result.shape != expected_shape:
10784
11148
  if tile_result.shape[:2] == (th, tw):
@@ -10813,37 +11177,115 @@ class StackingSuiteDialog(QDialog):
10813
11177
  pass
10814
11178
  break
10815
11179
 
11180
+ # -------------------------------------------------------------
11181
+ # Materialize final memmap to ndarray for save
11182
+ # -------------------------------------------------------------
10816
11183
  master_dark_data = np.asarray(final_stacked, dtype=np.float32)
10817
- del final_stacked
11184
+ try:
11185
+ del final_stacked
11186
+ except Exception:
11187
+ pass
10818
11188
  gc.collect()
11189
+
10819
11190
  try:
10820
11191
  os.remove(memmap_path)
10821
11192
  except Exception:
10822
11193
  pass
10823
11194
 
10824
- # Include session in output name
10825
- master_dark_stem = f"MasterDark_{session}_{int(exposure_time)}s_{image_size}"
11195
+ # -------------------------------------------------------------
11196
+ # Collect temperature stats from input dark headers
11197
+ # -------------------------------------------------------------
11198
+ temp_info = {}
11199
+ try:
11200
+ temp_info = _collect_temp_stats(file_list) or {}
11201
+ except Exception:
11202
+ temp_info = {}
11203
+
11204
+ # -------------------------------------------------------------
11205
+ # Build output filename (include session + exposure + size + temp bucket tag)
11206
+ # -------------------------------------------------------------
11207
+ temp_tag = ""
11208
+ try:
11209
+ if temp_bucket is not None:
11210
+ temp_tag = "_" + _temp_to_stem_tag(float(temp_bucket))
11211
+ elif temp_info.get("ccd_med") is not None:
11212
+ temp_tag = "_" + _temp_to_stem_tag(float(temp_info["ccd_med"]))
11213
+ elif temp_info.get("set_med") is not None:
11214
+ temp_tag = "_" + _temp_to_stem_tag(float(temp_info["set_med"]), prefix="set")
11215
+ except Exception:
11216
+ temp_tag = ""
11217
+
11218
+ master_dark_stem = f"MasterDark_{session}_{int(exposure_time)}s_{image_size}{temp_tag}"
11219
+ master_dark_stem = self._normalize_master_stem(master_dark_stem)
10826
11220
  master_dark_path = self._build_out(master_dir, master_dark_stem, "fit")
10827
11221
 
11222
+ # -------------------------------------------------------------
11223
+ # Header
11224
+ # -------------------------------------------------------------
10828
11225
  master_header = fits.Header()
10829
11226
  master_header["IMAGETYP"] = "DARK"
10830
- master_header["EXPTIME"] = (exposure_time, "User-specified or from grouping")
10831
- master_header["SESSION"] = (session, "User session tag") # optional but useful
10832
- master_header["NAXIS"] = 3 if channels == 3 else 2
10833
- master_header["NAXIS1"] = master_dark_data.shape[1]
10834
- master_header["NAXIS2"] = master_dark_data.shape[0]
11227
+ master_header["EXPTIME"] = (float(exposure_time), "Exposure time (s)")
11228
+ master_header["SESSION"] = (str(session), "User session tag")
11229
+ master_header["NCOMBINE"] = (int(N), "Number of darks combined")
11230
+ master_header["NSTACK"] = (int(N), "Alias of NCOMBINE (SetiAstro)")
11231
+
11232
+ # Temperature provenance (only write keys that exist)
11233
+ if temp_info.get("ccd_med") is not None:
11234
+ master_header["CCD-TEMP"] = (float(temp_info["ccd_med"]), "Median CCD temp of input darks (C)")
11235
+ if temp_info.get("ccd_min") is not None:
11236
+ master_header["CCDTMIN"] = (float(temp_info["ccd_min"]), "Min CCD temp in input darks (C)")
11237
+ if temp_info.get("ccd_max") is not None:
11238
+ master_header["CCDTMAX"] = (float(temp_info["ccd_max"]), "Max CCD temp in input darks (C)")
11239
+ if temp_info.get("ccd_std") is not None:
11240
+ master_header["CCDTSTD"] = (float(temp_info["ccd_std"]), "Std CCD temp in input darks (C)")
11241
+ if temp_info.get("ccd_n") is not None:
11242
+ master_header["CCDTN"] = (int(temp_info["ccd_n"]), "Count of frames with CCD-TEMP")
11243
+
11244
+ if temp_info.get("set_med") is not None:
11245
+ master_header["SET-TEMP"] = (float(temp_info["set_med"]), "Median setpoint temp of input darks (C)")
11246
+ if temp_info.get("set_min") is not None:
11247
+ master_header["SETTMIN"] = (float(temp_info["set_min"]), "Min setpoint in input darks (C)")
11248
+ if temp_info.get("set_max") is not None:
11249
+ master_header["SETTMAX"] = (float(temp_info["set_max"]), "Max setpoint in input darks (C)")
11250
+ if temp_info.get("set_std") is not None:
11251
+ master_header["SETTSTD"] = (float(temp_info["set_std"]), "Std setpoint in input darks (C)")
11252
+ if temp_info.get("set_n") is not None:
11253
+ master_header["SETTN"] = (int(temp_info["set_n"]), "Count of frames with SET-TEMP")
11254
+
11255
+ # Dimensions (save_image usually writes these, but keep your existing behavior)
11256
+ master_header["NAXIS"] = 3 if channels == 3 else 2
11257
+ master_header["NAXIS1"] = int(master_dark_data.shape[1])
11258
+ master_header["NAXIS2"] = int(master_dark_data.shape[0])
10835
11259
  if channels == 3:
10836
11260
  master_header["NAXIS3"] = 3
10837
11261
 
10838
- save_image(master_dark_data, master_dark_path, "fit", "32-bit floating point", master_header, is_mono=(channels == 1))
11262
+ save_image(
11263
+ master_dark_data,
11264
+ master_dark_path,
11265
+ "fit",
11266
+ "32-bit floating point",
11267
+ master_header,
11268
+ is_mono=(channels == 1)
11269
+ )
11270
+
11271
+ # Tree label includes temp for visibility
11272
+ tree_label = f"{exposure_time:g}s ({image_size}) [{session}]"
11273
+ if temp_info.get("ccd_med") is not None:
11274
+ tree_label += f" [CCD {float(temp_info['ccd_med']):+.1f}C]"
11275
+ elif temp_info.get("set_med") is not None:
11276
+ tree_label += f" [SET {float(temp_info['set_med']):+.1f}C]"
11277
+ elif temp_bucket is not None:
11278
+ tree_label += f" [TEMP {float(temp_bucket):+.1f}C]"
10839
11279
 
10840
- self.add_master_dark_to_tree(f"{exposure_time}s ({image_size}) [{session}]", master_dark_path)
11280
+ self.add_master_dark_to_tree(tree_label, master_dark_path)
10841
11281
  self.update_status(self.tr(f"βœ… Master Dark saved: {master_dark_path}"))
10842
11282
  QApplication.processEvents()
10843
11283
 
11284
+ # Refresh assignments + persistence
10844
11285
  self.assign_best_master_files()
10845
11286
  self.save_master_paths_to_settings()
10846
11287
 
11288
+ # Post pass refresh (unchanged behavior)
10847
11289
  self.assign_best_master_dark()
10848
11290
  self.update_override_dark_combo()
10849
11291
  self.assign_best_master_files()
@@ -10856,7 +11298,6 @@ class StackingSuiteDialog(QDialog):
10856
11298
  logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
10857
11299
  pd.close()
10858
11300
 
10859
-
10860
11301
  def add_master_dark_to_tree(self, exposure_label: str, master_dark_path: str):
10861
11302
  """
10862
11303
  Adds the newly created Master Dark to the Master Dark TreeBox and updates the dropdown.
@@ -11256,22 +11697,17 @@ class StackingSuiteDialog(QDialog):
11256
11697
  dark_data: np.ndarray | None,
11257
11698
  pattern: str,
11258
11699
  ):
11259
- """
11260
- Returns scales shape (N,4): [R, G1, G2, B] where scale = frame_plane_median / group_plane_median.
11261
- """
11262
11700
  pat = (pattern or "RGGB").strip().upper()
11263
11701
  if pat not in ("RGGB", "BGGR", "GRBG", "GBRG"):
11264
11702
  pat = "RGGB"
11265
11703
 
11266
- # Central patch
11267
11704
  th = min(512, H); tw = min(512, W)
11268
11705
  y0 = (H - th) // 2; y1 = y0 + th
11269
11706
  x0 = (W - tw) // 2; x1 = x0 + tw
11270
11707
 
11271
11708
  N = len(file_list)
11272
- meds = np.empty((N, 4), dtype=np.float64) # R,G1,G2,B
11709
+ meds = np.empty((N, 4), dtype=np.float64)
11273
11710
 
11274
- # parity β†’ plane label
11275
11711
  if pat == "RGGB":
11276
11712
  m = {(0,0):"R", (0,1):"G1", (1,0):"G2", (1,1):"B"}
11277
11713
  elif pat == "BGGR":
@@ -11288,9 +11724,24 @@ class StackingSuiteDialog(QDialog):
11288
11724
  d = float(np.median(v))
11289
11725
  return d if np.isfinite(d) and d > 0 else 1.0
11290
11726
 
11727
+ # Make dark/bias subtractor into 2D for bayer mosaics (important for XISF HWC darks)
11728
+ dd2 = None
11729
+ if dark_data is not None:
11730
+ dd2 = dark_data
11731
+ if dd2.ndim == 3:
11732
+ # CHW -> HWC
11733
+ if dd2.shape[0] in (1, 3):
11734
+ dd2 = dd2.transpose(1, 2, 0)
11735
+ # HWC -> take first plane for mosaic subtraction
11736
+ dd2 = dd2[:, :, 0]
11737
+ dd2 = dd2.astype(np.float32, copy=False)
11738
+
11291
11739
  from concurrent.futures import ThreadPoolExecutor, as_completed
11292
11740
  with ThreadPoolExecutor(max_workers=min(os.cpu_count() or 4, 8)) as exe:
11293
- fut2i = {exe.submit(load_fits_tile, fp, y0, y1, x0, x1): i for i, fp in enumerate(file_list)}
11741
+ fut2i = {
11742
+ exe.submit(_read_center_patch_via_mmimage, fp, y0, y1, x0, x1): i
11743
+ for i, fp in enumerate(file_list)
11744
+ }
11294
11745
  for fut in as_completed(fut2i):
11295
11746
  i = fut2i[fut]
11296
11747
  sub = fut.result()
@@ -11299,16 +11750,14 @@ class StackingSuiteDialog(QDialog):
11299
11750
  continue
11300
11751
 
11301
11752
  # Ensure 2D mosaic
11302
- if sub.ndim == 3 and sub.shape[0] in (1, 3):
11303
- sub = sub[0] if sub.shape[0] == 1 else sub.transpose(1, 2, 0)[:, :, 0]
11753
+ if sub.ndim == 3:
11754
+ if sub.shape[0] in (1, 3): # CHW
11755
+ sub = sub.transpose(1, 2, 0)
11756
+ sub = sub[:, :, 0] # first plane
11304
11757
  sub = sub.astype(np.float32, copy=False)
11305
11758
 
11306
- # Dark subtract patch if present
11307
- if dark_data is not None:
11308
- dd = dark_data
11309
- if dd.ndim == 3 and dd.shape[0] in (1, 3):
11310
- dd = dd.transpose(1, 2, 0)[:, :, 0]
11311
- d_tile = dd[y0:y1, x0:x1].astype(np.float32, copy=False)
11759
+ if dd2 is not None:
11760
+ d_tile = dd2[y0:y1, x0:x1].astype(np.float32, copy=False)
11312
11761
  sub = sub - d_tile
11313
11762
 
11314
11763
  planes = {
@@ -11327,15 +11776,11 @@ class StackingSuiteDialog(QDialog):
11327
11776
  gmed = np.median(meds, axis=0)
11328
11777
  gmed = np.where(np.isfinite(gmed) & (gmed > 0), gmed, 1.0)
11329
11778
 
11330
- scales = meds / gmed # (N,4)
11331
- scales = np.clip(scales, 1e-3, 1e3).astype(np.float32)
11332
- return scales
11779
+ scales = meds / gmed
11780
+ return np.clip(scales, 1e-3, 1e3).astype(np.float32)
11781
+
11333
11782
 
11334
11783
  def _estimate_flat_scales(file_list: list[str], H: int, W: int, C: int, dark_data: np.ndarray | None):
11335
- """
11336
- Read one central patch (min(512, H/W)) from each frame, subtract dark (if present),
11337
- compute per-frame median, and normalize scales to overall median.
11338
- """
11339
11784
  th = min(512, H); tw = min(512, W)
11340
11785
  y0 = (H - th) // 2; y1 = y0 + th
11341
11786
  x0 = (W - tw) // 2; x1 = x0 + tw
@@ -11343,9 +11788,20 @@ class StackingSuiteDialog(QDialog):
11343
11788
  N = len(file_list)
11344
11789
  meds = np.empty((N,), dtype=np.float64)
11345
11790
 
11791
+ # Normalize subtractor to HWC or 2D
11792
+ dd = None
11793
+ if dark_data is not None:
11794
+ dd = dark_data
11795
+ if dd.ndim == 3 and dd.shape[0] in (1, 3): # CHW -> HWC
11796
+ dd = dd.transpose(1, 2, 0)
11797
+ dd = dd.astype(np.float32, copy=False)
11798
+
11346
11799
  from concurrent.futures import ThreadPoolExecutor, as_completed
11347
11800
  with ThreadPoolExecutor(max_workers=min(os.cpu_count() or 4, 8)) as exe:
11348
- fut2i = {exe.submit(load_fits_tile, fp, y0, y1, x0, x1): i for i, fp in enumerate(file_list)}
11801
+ fut2i = {
11802
+ exe.submit(_read_center_patch_via_mmimage, fp, y0, y1, x0, x1): i
11803
+ for i, fp in enumerate(file_list)
11804
+ }
11349
11805
  for fut in as_completed(fut2i):
11350
11806
  i = fut2i[fut]
11351
11807
  sub = fut.result()
@@ -11360,22 +11816,22 @@ class StackingSuiteDialog(QDialog):
11360
11816
  sub = sub.transpose(1, 2, 0)
11361
11817
  sub = sub.astype(np.float32, copy=False)
11362
11818
 
11363
- if dark_data is not None:
11364
- dd = dark_data
11365
- if dd.ndim == 3 and dd.shape[0] in (1, 3):
11366
- dd = dd.transpose(1, 2, 0)
11367
- d_tile = dd[y0:y1, x0:x1].astype(np.float32, copy=False)
11819
+ if dd is not None:
11820
+ d_tile = dd[y0:y1, x0:x1]
11368
11821
  if d_tile.ndim == 2 and sub.shape[2] == 3:
11369
11822
  d_tile = np.repeat(d_tile[..., None], 3, axis=2)
11370
- sub = sub - d_tile
11823
+ elif d_tile.ndim == 3 and sub.shape[2] == 1:
11824
+ d_tile = d_tile[:, :, :1]
11825
+ sub = sub - d_tile.astype(np.float32, copy=False)
11371
11826
 
11372
- meds[i] = np.median(sub, axis=(0, 1, 2))
11827
+ meds[i] = float(np.median(sub))
11373
11828
 
11374
- gmed = np.median(meds) if np.all(np.isfinite(meds)) else 1.0
11375
- gmed = 1.0 if gmed == 0.0 else gmed
11829
+ gmed = float(np.median(meds)) if np.all(np.isfinite(meds)) else 1.0
11830
+ if not np.isfinite(gmed) or gmed == 0.0:
11831
+ gmed = 1.0
11376
11832
  scales = meds / gmed
11377
- scales = np.clip(scales, 1e-3, 1e3).astype(np.float32)
11378
- return scales
11833
+ return np.clip(scales, 1e-3, 1e3).astype(np.float32)
11834
+
11379
11835
 
11380
11836
  def _apply_bayer_scales_stack_inplace(ts_np: np.ndarray, scales4: np.ndarray, pat: str, y0: int, x0: int):
11381
11837
  """
@@ -11889,6 +12345,140 @@ class StackingSuiteDialog(QDialog):
11889
12345
  master_item = QTreeWidgetItem([os.path.basename(master_flat_path)])
11890
12346
  filter_item.addChild(master_item)
11891
12347
 
12348
+ def _parse_float(self, v):
12349
+ try:
12350
+ if v is None:
12351
+ return None
12352
+ if isinstance(v, (int, float)):
12353
+ return float(v)
12354
+ s = str(v).strip()
12355
+ # handle " -10.0 C" or "-10.0C"
12356
+ s = s.replace("Β°", "").replace("C", "").replace("c", "").strip()
12357
+ return float(s)
12358
+ except Exception:
12359
+ return None
12360
+
12361
+
12362
+ def _read_ccd_set_temp_from_fits(self, path: str) -> tuple[float|None, float|None]:
12363
+ """Read CCD-TEMP and SET-TEMP from FITS header (primary HDU)."""
12364
+ try:
12365
+ with fits.open(path) as hdul:
12366
+ hdr = hdul[0].header
12367
+ ccd = self._parse_float(hdr.get("CCD-TEMP", None))
12368
+ st = self._parse_float(hdr.get("SET-TEMP", None))
12369
+ return ccd, st
12370
+ except Exception:
12371
+ return None, None
12372
+
12373
+
12374
+ def _temp_for_matching(self, ccd: float|None, st: float|None) -> float|None:
12375
+ """Prefer CCD-TEMP; else SET-TEMP; else None."""
12376
+ return ccd if ccd is not None else (st if st is not None else None)
12377
+
12378
+
12379
+ def _parse_masterdark_name(self, stem: str):
12380
+ """
12381
+ From filename like:
12382
+ MasterDark_Session_300s_4144x2822_m10p0C.fit
12383
+ Return dict fields; temp is optional.
12384
+ """
12385
+ out = {"session": None, "exp": None, "size": None, "temp": None}
12386
+
12387
+ base = os.path.basename(stem)
12388
+ base = os.path.splitext(base)[0]
12389
+
12390
+ # session is between MasterDark_ and _<exp>s_
12391
+ # exp is <num>s
12392
+ # size is <WxH> like 4144x2822
12393
+ m = re.match(r"^MasterDark_(?P<session>.+?)_(?P<exp>[\d._]+)s_(?P<size>\d+x\d+)(?:_(?P<temp>.*))?$", base)
12394
+ if not m:
12395
+ return out
12396
+
12397
+ out["session"] = (m.group("session") or "").strip()
12398
+ # exp might be "2_5" from _normalize_master_stem; convert back
12399
+ exp_txt = (m.group("exp") or "").replace("_", ".")
12400
+ try:
12401
+ out["exp"] = float(exp_txt)
12402
+ except Exception:
12403
+ out["exp"] = None
12404
+
12405
+ out["size"] = m.group("size")
12406
+
12407
+ # temp token like m10p0C / p5p0C / setm10p0C
12408
+ t = (m.group("temp") or "").strip()
12409
+ if t:
12410
+ # pick the first temp-ish token ending in C
12411
+ mt = re.search(r"(set)?([mp])(\d+)p(\d)C", t)
12412
+ if mt:
12413
+ sign = -1.0 if mt.group(2) == "m" else 1.0
12414
+ whole = float(mt.group(3))
12415
+ frac = float(mt.group(4)) / 10.0
12416
+ out["temp"] = sign * (whole + frac)
12417
+
12418
+ return out
12419
+
12420
+
12421
+ def _get_master_dark_meta(self, path: str) -> dict:
12422
+ """
12423
+ Cached metadata for a master dark.
12424
+ Prefers FITS header for temp; falls back to filename temp token.
12425
+ """
12426
+ if not hasattr(self, "_master_dark_meta_cache"):
12427
+ self._master_dark_meta_cache = {}
12428
+ cache = self._master_dark_meta_cache
12429
+
12430
+ p = os.path.normpath(path)
12431
+ if p in cache:
12432
+ return cache[p]
12433
+
12434
+ meta = {"path": p, "session": None, "exp": None, "size": None,
12435
+ "ccd": None, "set": None, "temp": None}
12436
+
12437
+ # filename parse (fast)
12438
+ fn = self._parse_masterdark_name(p)
12439
+ meta["session"] = fn.get("session") or None
12440
+ meta["exp"] = fn.get("exp")
12441
+ meta["size"] = fn.get("size")
12442
+ meta["temp"] = fn.get("temp")
12443
+
12444
+ # header parse (authoritative for temps)
12445
+ ccd, st = self._read_ccd_set_temp_from_fits(p)
12446
+ meta["ccd"] = ccd
12447
+ meta["set"] = st
12448
+ meta["temp"] = self._temp_for_matching(ccd, st) if (ccd is not None or st is not None) else meta["temp"]
12449
+
12450
+ # size from header if missing
12451
+ if not meta["size"]:
12452
+ try:
12453
+ with fits.open(p) as hdul:
12454
+ data = hdul[0].data
12455
+ if data is not None:
12456
+ meta["size"] = f"{data.shape[1]}x{data.shape[0]}"
12457
+ except Exception:
12458
+ pass
12459
+
12460
+ cache[p] = meta
12461
+ return meta
12462
+
12463
+
12464
+ def _get_light_temp(self, light_path: str) -> tuple[float|None, float|None, float|None]:
12465
+ """Return (ccd, set, chosen) with caching."""
12466
+ if not hasattr(self, "_light_temp_cache"):
12467
+ self._light_temp_cache = {}
12468
+ cache = self._light_temp_cache
12469
+
12470
+ p = os.path.normpath(light_path or "")
12471
+ if not p:
12472
+ return None, None, None
12473
+ if p in cache:
12474
+ return cache[p]
12475
+
12476
+ ccd, st = self._read_ccd_set_temp_from_fits(p)
12477
+ chosen = self._temp_for_matching(ccd, st)
12478
+ cache[p] = (ccd, st, chosen)
12479
+ return cache[p]
12480
+
12481
+
11892
12482
  def assign_best_master_files(self, fill_only: bool = True):
11893
12483
  """
11894
12484
  Assign best matching Master Dark and Flat to each Light leaf.
@@ -11948,32 +12538,57 @@ class StackingSuiteDialog(QDialog):
11948
12538
  if fill_only and curr_dark and curr_dark.lower() != "none":
11949
12539
  dark_choice = curr_dark
11950
12540
  else:
11951
- # 3) Auto-pick by size+closest exposure
11952
- best_dark_match = None
11953
- best_dark_diff = float("inf")
11954
- for master_key, master_path in self.master_files.items():
11955
- dmatch = re.match(r"^([\d.]+)s\b", master_key) # darks start with "<exp>s"
11956
- if not dmatch:
12541
+ # 3) Auto-pick by size + closest exposure + closest temperature (and prefer same session)
12542
+ light_path = leaf_item.data(0, Qt.ItemDataRole.UserRole)
12543
+ l_ccd, l_set, l_temp = self._get_light_temp(light_path)
12544
+
12545
+ best_path = None
12546
+ best_score = None
12547
+
12548
+ for mk, mp in (self.master_files or {}).items():
12549
+ if not mp:
11957
12550
  continue
11958
- master_dark_exposure_time = float(dmatch.group(1))
11959
12551
 
11960
- # Ensure size known/cached
11961
- md_size = master_sizes.get(master_path)
11962
- if not md_size:
11963
- try:
11964
- with fits.open(master_path) as hdul:
11965
- md_size = f"{hdul[0].data.shape[1]}x{hdul[0].data.shape[0]}"
11966
- except Exception:
11967
- md_size = "Unknown"
11968
- master_sizes[master_path] = md_size
12552
+ bn = os.path.basename(mp)
12553
+ # Only consider MasterDark_* files (cheap gate)
12554
+ if not bn.startswith("MasterDark_"):
12555
+ continue
12556
+
12557
+ md = self._get_master_dark_meta(mp)
12558
+ md_size = md.get("size") or "Unknown"
12559
+ if md_size != image_size:
12560
+ continue
12561
+
12562
+ md_exp = md.get("exp")
12563
+ if md_exp is None:
12564
+ continue
12565
+
12566
+ # exposure closeness
12567
+ exp_diff = abs(float(md_exp) - float(exposure_time))
11969
12568
 
11970
- if md_size == image_size:
11971
- diff = abs(master_dark_exposure_time - exposure_time)
11972
- if diff < best_dark_diff:
11973
- best_dark_diff = diff
11974
- best_dark_match = master_path
12569
+ # session preference: exact match beats mismatch
12570
+ md_sess = (md.get("session") or "Default").strip()
12571
+ sess_mismatch = 0 if md_sess == session_name else 1
11975
12572
 
11976
- dark_choice = os.path.basename(best_dark_match) if best_dark_match else ("None" if not curr_dark else curr_dark)
12573
+ # temperature closeness (if both known)
12574
+ md_temp = md.get("temp")
12575
+ if (l_temp is not None) and (md_temp is not None):
12576
+ temp_diff = abs(float(md_temp) - float(l_temp))
12577
+ temp_unknown = 0
12578
+ else:
12579
+ # if light has temp but dark doesn't (or vice versa), penalize
12580
+ temp_diff = 9999.0
12581
+ temp_unknown = 1
12582
+
12583
+ # Score tuple: lower is better
12584
+ # Priority: session match -> exposure diff -> temp availability -> temp diff
12585
+ score = (sess_mismatch, exp_diff, temp_unknown, temp_diff)
12586
+
12587
+ if best_score is None or score < best_score:
12588
+ best_score = score
12589
+ best_path = mp
12590
+
12591
+ dark_choice = os.path.basename(best_path) if best_path else ("None" if not curr_dark else curr_dark)
11977
12592
 
11978
12593
  # ---------- FLAT RESOLUTION ----------
11979
12594
  flat_key_full = f"{filter_name_raw} - {exposure_text}"
@@ -12109,22 +12724,57 @@ class StackingSuiteDialog(QDialog):
12109
12724
 
12110
12725
 
12111
12726
  def override_selected_master_dark(self):
12112
- """ Override Dark for selected Light exposure group or individual files. """
12727
+ """Override Dark for selected Light exposure group or individual files."""
12113
12728
  selected_items = self.light_tree.selectedItems()
12114
12729
  if not selected_items:
12115
12730
  print("⚠️ No light item selected for dark frame override.")
12116
12731
  return
12117
12732
 
12118
- file_path, _ = QFileDialog.getOpenFileName(self, "Select Master Dark", "", "FITS Files (*.fits *.fit)")
12733
+ # --- pick a good starting directory ---
12734
+ last_dir = self.settings.value("stacking/last_master_dark_dir", "", type=str) if hasattr(self, "settings") else ""
12735
+ if not last_dir:
12736
+ # try stacking dir
12737
+ last_dir = getattr(self, "stacking_directory", "") or ""
12738
+
12739
+ # try selected leaf path folder (best UX)
12740
+ try:
12741
+ it0 = selected_items[0]
12742
+ # leaf stores path in UserRole, groups do not
12743
+ p0 = it0.data(0, Qt.ItemDataRole.UserRole)
12744
+ if isinstance(p0, str) and os.path.exists(p0):
12745
+ last_dir = os.path.dirname(p0)
12746
+ except Exception:
12747
+ pass
12748
+
12749
+ if not last_dir:
12750
+ last_dir = os.path.expanduser("~")
12751
+
12752
+ file_path, _ = QFileDialog.getOpenFileName(
12753
+ self,
12754
+ "Select Master Dark",
12755
+ last_dir,
12756
+ "Master Calibration (*.fits *.fit *.xisf);;All Files (*)"
12757
+ )
12119
12758
  if not file_path:
12120
12759
  return
12121
12760
 
12761
+ # remember for next time
12762
+ try:
12763
+ if hasattr(self, "settings"):
12764
+ self.settings.setValue("stacking/last_master_dark_dir", os.path.dirname(file_path))
12765
+ except Exception:
12766
+ pass
12767
+
12768
+ # Ensure dict exists
12769
+ if not hasattr(self, "manual_dark_overrides") or self.manual_dark_overrides is None:
12770
+ self.manual_dark_overrides = {}
12771
+
12122
12772
  for item in selected_items:
12123
- # If the user clicked a group (exposure row), push override to all leaves:
12773
+ # If the user clicked an exposure row under a filter
12124
12774
  if item.parent() and item.childCount() > 0:
12125
- # exposure row under a filter
12126
12775
  filter_name = item.parent().text(0)
12127
12776
  exposure_text = item.text(0)
12777
+
12128
12778
  # store override under BOTH keys
12129
12779
  self.manual_dark_overrides[f"{filter_name} - {exposure_text}"] = file_path
12130
12780
  self.manual_dark_overrides[exposure_text] = file_path
@@ -12132,17 +12782,20 @@ class StackingSuiteDialog(QDialog):
12132
12782
  for i in range(item.childCount()):
12133
12783
  leaf = item.child(i)
12134
12784
  leaf.setText(2, os.path.basename(file_path))
12135
- # If the user clicked a leaf, just set that leaf and still store under both keys
12785
+
12786
+ # If the user clicked a leaf under an exposure row
12136
12787
  elif item.parent() and item.parent().parent():
12137
12788
  exposure_item = item.parent()
12138
12789
  filter_name = exposure_item.parent().text(0)
12139
12790
  exposure_text = exposure_item.text(0)
12791
+
12140
12792
  self.manual_dark_overrides[f"{filter_name} - {exposure_text}"] = file_path
12141
12793
  self.manual_dark_overrides[exposure_text] = file_path
12142
12794
  item.setText(2, os.path.basename(file_path))
12143
12795
 
12144
12796
  print("βœ… DEBUG: Light Dark override applied.")
12145
12797
 
12798
+
12146
12799
  def _auto_pick_master_dark(self, image_size: str, exposure_time: float):
12147
12800
  best_path, best_diff = None, float("inf")
12148
12801
  for key, path in self.master_files.items():
@@ -12683,9 +13336,13 @@ class StackingSuiteDialog(QDialog):
12683
13336
 
12684
13337
  # Annotate header
12685
13338
  try:
12686
- hdr['HISTORY'] = 'Calibrated: bias/dark sub, flat division'
12687
- hdr['CALMIN'] = (min_val, 'Min pixel before save (float)')
12688
- hdr['CALMAX'] = (max_val, 'Max pixel before save (float)')
13339
+ if hasattr(hdr, "add_history"):
13340
+ hdr.add_history("Calibrated: bias/dark sub, flat division")
13341
+ else:
13342
+ hdr["HISTORY"] = "Calibrated: bias/dark sub, flat division"
13343
+
13344
+ hdr["CALMIN"] = (min_val, "Min pixel before save (float)")
13345
+ hdr["CALMAX"] = (max_val, "Max pixel before save (float)")
12689
13346
  except Exception:
12690
13347
  pass
12691
13348
 
@@ -13670,23 +14327,23 @@ class StackingSuiteDialog(QDialog):
13670
14327
  self.update_status(self.tr("🧹 Doing a little tidying up..."))
13671
14328
  user_ref_locked = bool(getattr(self, "_user_ref_locked", False))
13672
14329
 
13673
- # Only clear derived geometry/maps when NOT locked
14330
+ # ALWAYS clear derived geometry/maps for this run (mapping is run-specific)
14331
+ self._norm_target_hw = None
14332
+ self._orig2norm = {}
14333
+
14334
+ # Only clear the UI reference label when NOT locked
13674
14335
  if not user_ref_locked:
13675
- self._norm_target_hw = None
13676
- self._orig2norm = {}
13677
14336
  try:
13678
14337
  if hasattr(self, "ref_frame_path") and self.ref_frame_path:
13679
14338
  self.ref_frame_path.setText("Auto (not set)")
13680
14339
  except Exception:
13681
14340
  pass
13682
14341
  else:
13683
- # Keep the UI showing the user’s chosen ref (basename for display)
13684
14342
  try:
13685
14343
  if hasattr(self, "ref_frame_path") and self.ref_frame_path and self.reference_frame:
13686
14344
  self.ref_frame_path.setText(os.path.basename(self.reference_frame))
13687
14345
  except Exception:
13688
14346
  pass
13689
-
13690
14347
  # 🚫 Do NOT remove persisted user ref here; that defeats locking.
13691
14348
  # (No settings.remove() and no reference_frame = None if locked)
13692
14349
 
@@ -14571,7 +15228,28 @@ class StackingSuiteDialog(QDialog):
14571
15228
 
14572
15229
  from os import path
14573
15230
  ref_path = path.normpath(self.reference_frame)
14574
- self.update_status(self.tr(f"πŸ“Œ Reference for alignment (verbatim): {ref_path}"))
15231
+ from os import path
15232
+
15233
+ # Prefer the normalized FIT reference if we produced one
15234
+ ref_key = path.normcase(path.normpath(self.reference_frame))
15235
+ ref_norm = self._orig2norm.get(ref_key)
15236
+
15237
+ # If mapping missing, attempt the predictable filename in norm_dir
15238
+ if not ref_norm:
15239
+ base = os.path.basename(self.reference_frame)
15240
+ if base.lower().endswith(".fits"):
15241
+ n_name = base[:-5] + "_n.fit"
15242
+ elif base.lower().endswith(".fit"):
15243
+ n_name = base[:-4] + "_n.fit"
15244
+ else:
15245
+ n_name = base + "_n.fit"
15246
+ candidate = path.normpath(path.join(norm_dir, n_name))
15247
+ if path.exists(candidate):
15248
+ ref_norm = candidate
15249
+
15250
+ ref_path = path.normpath(ref_norm or self.reference_frame)
15251
+
15252
+ self.update_status(self.tr(f"πŸ“Œ Reference for alignment: {ref_path}"))
14575
15253
  if not path.exists(ref_path):
14576
15254
  self.update_status(self.tr(f"🚨 Reference file does not exist: {ref_path}"))
14577
15255
  return
@@ -14587,6 +15265,14 @@ class StackingSuiteDialog(QDialog):
14587
15265
 
14588
15266
  normalized_files = [path.normpath(p) for p in normalized_files]
14589
15267
 
15268
+ ref_key = path.normcase(path.normpath(self.reference_frame))
15269
+ ref_path = self._orig2norm.get(ref_key, path.normpath(self.reference_frame))
15270
+
15271
+ self.update_status(self.tr(f"πŸ“Œ Reference for alignment (normalized if available): {ref_path}"))
15272
+ if not path.exists(ref_path):
15273
+ self.update_status(self.tr(f"🚨 Reference file does not exist: {ref_path}"))
15274
+ return
15275
+
14590
15276
  self.alignment_thread = StarRegistrationThread(
14591
15277
  ref_path,
14592
15278
  normalized_files,
@@ -15192,6 +15878,41 @@ class StackingSuiteDialog(QDialog):
15192
15878
  # Threshold is only used in normal mode
15193
15879
  accept_thresh = float(self.settings.value("stacking/accept_shift_px", 2.0, type=float))
15194
15880
 
15881
+ def _mf_ref_path_for_masks() -> str | None:
15882
+ """
15883
+ Return the best reference path for MFDeconv star masks:
15884
+ aligned FITS if possible, else normalized FITS, else original.
15885
+ """
15886
+ if not getattr(self, "reference_frame", None):
15887
+ return None
15888
+
15889
+ from os import path
15890
+ ref_orig = path.normpath(self.reference_frame)
15891
+ ref_key = path.normcase(ref_orig)
15892
+
15893
+ # original -> normalized
15894
+ ref_norm = self._orig2norm.get(ref_key)
15895
+
15896
+ # normalized -> aligned
15897
+ ref_aligned = None
15898
+ if ref_norm:
15899
+ ref_aligned = self.valid_transforms.get(path.normpath(ref_norm))
15900
+
15901
+ # If we couldn’t map via orig->norm (e.g. user picked a normalized path already)
15902
+ if not ref_norm and ref_orig in self.valid_transforms:
15903
+ ref_norm = ref_orig
15904
+ ref_aligned = self.valid_transforms.get(ref_norm)
15905
+
15906
+ # Prefer aligned if it exists on disk
15907
+ if ref_aligned and path.exists(ref_aligned):
15908
+ return ref_aligned
15909
+ if ref_norm and path.exists(ref_norm):
15910
+ return ref_norm
15911
+ if path.exists(ref_orig):
15912
+ return ref_orig
15913
+ return None
15914
+
15915
+
15195
15916
  def _accept(k: str) -> bool:
15196
15917
  """Accept criteria for a frame."""
15197
15918
  if all_transforms.get(k) is None:
@@ -15575,7 +16296,9 @@ class StackingSuiteDialog(QDialog):
15575
16296
  }
15576
16297
 
15577
16298
  self._mf_thread = QThread(self)
15578
- star_mask_ref = self.reference_frame if use_star_masks else None
16299
+ star_mask_ref = _mf_ref_path_for_masks() if use_star_masks else None
16300
+ if use_star_masks:
16301
+ self.update_status(self.tr(f"🌟 MFDeconv star-mask reference β†’ {star_mask_ref or '(none)'}"))
15579
16302
 
15580
16303
  # ── choose engine plainly (Normal / cuDNN-free / High Octane) ─────────────
15581
16304
  # Expect a setting saved by your radio buttons: "normal" | "cudnn" | "sport"
@@ -16030,6 +16753,10 @@ class StackingSuiteDialog(QDialog):
16030
16753
  hdr_orig["CREATOR"] = "SetiAstroSuite"
16031
16754
  hdr_orig["DATE-OBS"] = datetime.utcnow().isoformat()
16032
16755
 
16756
+ n_frames_group = len(file_list)
16757
+ hdr_orig["NCOMBINE"] = (int(n_frames_group), "Number of frames combined")
16758
+ hdr_orig["NSTACK"] = (int(n_frames_group), "Alias of NCOMBINE (SetiAstro)")
16759
+
16033
16760
  is_mono_orig = (integrated_image.ndim == 2)
16034
16761
  if is_mono_orig:
16035
16762
  hdr_orig["NAXIS"] = 2
@@ -16149,6 +16876,8 @@ class StackingSuiteDialog(QDialog):
16149
16876
  scale=1.0,
16150
16877
  rect_override=group_rect if group_rect is not None else global_rect
16151
16878
  )
16879
+ hdr_crop["NCOMBINE"] = (int(n_frames_group), "Number of frames combined")
16880
+ hdr_crop["NSTACK"] = (int(n_frames_group), "Alias of NCOMBINE (SetiAstro)")
16152
16881
  is_mono_crop = (cropped_img.ndim == 2)
16153
16882
  Hc, Wc = (cropped_img.shape[:2] if cropped_img.ndim >= 2 else (H, W))
16154
16883
  display_group_crop = self._label_with_dims(group_key, Wc, Hc)
@@ -16292,6 +17021,12 @@ class StackingSuiteDialog(QDialog):
16292
17021
  algo_override=COMET_ALGO # << comet-friendly reducer
16293
17022
  )
16294
17023
 
17024
+ n_usable = int(len(usable))
17025
+ ref_header_c = ref_header_c or ref_header or fits.Header()
17026
+ ref_header_c["NCOMBINE"] = (n_usable, "Number of frames combined (comet)")
17027
+ ref_header_c["NSTACK"] = (n_usable, "Alias of NCOMBINE (SetiAstro)")
17028
+ ref_header_c["COMETFR"] = (n_usable, "Frames used for comet-aligned stack")
17029
+
16295
17030
  # Save CometOnly
16296
17031
  Hc, Wc = comet_only.shape[:2]
16297
17032
  display_group_c = self._label_with_dims(group_key, Wc, Hc)
@@ -16316,6 +17051,10 @@ class StackingSuiteDialog(QDialog):
16316
17051
  scale=1.0,
16317
17052
  rect_override=group_rect if group_rect is not None else global_rect
16318
17053
  )
17054
+ comet_only_crop, hdr_c_crop = self._apply_autocrop(...)
17055
+ hdr_c_crop["NCOMBINE"] = (n_usable, "Number of frames combined (comet)")
17056
+ hdr_c_crop["NSTACK"] = (n_usable, "Alias of NCOMBINE (SetiAstro)")
17057
+ hdr_c_crop["COMETFR"] = (n_usable, "Frames used for comet-aligned stack")
16319
17058
  Hcc, Wcc = comet_only_crop.shape[:2]
16320
17059
  display_group_cc = self._label_with_dims(group_key, Wcc, Hcc)
16321
17060
  comet_path_crop = self._build_out(
@@ -16903,246 +17642,6 @@ class StackingSuiteDialog(QDialog):
16903
17642
  views[p] = np.load(npy, mmap_mode="r") # returns numpy.memmap
16904
17643
  return views
16905
17644
 
16906
-
16907
- def stack_registered_images_chunked(
16908
- self,
16909
- grouped_files,
16910
- frame_weights,
16911
- chunk_height=2048,
16912
- chunk_width=2048
16913
- ):
16914
- self.update_status(self.tr(f"βœ… Chunked stacking {len(grouped_files)} group(s)..."))
16915
- QApplication.processEvents()
16916
-
16917
- all_rejection_coords = []
16918
-
16919
- for group_key, file_list in grouped_files.items():
16920
- num_files = len(file_list)
16921
- self.update_status(self.tr(f"πŸ“Š Group '{group_key}' has {num_files} aligned file(s)."))
16922
- QApplication.processEvents()
16923
- if num_files < 2:
16924
- self.update_status(self.tr(f"⚠️ Group '{group_key}' does not have enough frames to stack."))
16925
- continue
16926
-
16927
- # Reference shape/header (unchanged)
16928
- ref_file = file_list[0]
16929
- if not os.path.exists(ref_file):
16930
- self.update_status(self.tr(f"⚠️ Reference file '{ref_file}' not found, skipping group."))
16931
- continue
16932
-
16933
- ref_data, ref_header, _, _ = load_image(ref_file)
16934
- if ref_data is None:
16935
- self.update_status(self.tr(f"⚠️ Could not load reference '{ref_file}', skipping group."))
16936
- continue
16937
-
16938
- is_color = (ref_data.ndim == 3 and ref_data.shape[2] == 3)
16939
- height, width = ref_data.shape[:2]
16940
- channels = 3 if is_color else 1
16941
-
16942
- # Final output memmap (unchanged)
16943
- memmap_path = self._build_out(self.stacking_directory, f"chunked_{group_key}", "dat")
16944
- final_stacked = np.memmap(memmap_path, dtype=np.float32, mode='w+', shape=(height, width, channels))
16945
-
16946
- # Valid files + weights
16947
- aligned_paths, weights_list = [], []
16948
- for fpath in file_list:
16949
- if os.path.exists(fpath):
16950
- aligned_paths.append(fpath)
16951
- weights_list.append(frame_weights.get(fpath, 1.0))
16952
- else:
16953
- self.update_status(self.tr(f"⚠️ File not found: {fpath}, skipping."))
16954
- if len(aligned_paths) < 2:
16955
- self.update_status(self.tr(f"⚠️ Not enough valid frames in group '{group_key}' to stack."))
16956
- continue
16957
-
16958
- weights_list = np.array(weights_list, dtype=np.float32)
16959
-
16960
- # ⬇️ NEW: open read-only memmaps for all aligned frames (float32 [0..1], HxWxC)
16961
- mm_views = self._open_memmaps_readonly(aligned_paths)
16962
-
16963
- self.update_status(self.tr(f"πŸ“Š Stacking group '{group_key}' with {self.rejection_algorithm}"))
16964
- QApplication.processEvents()
16965
-
16966
- rejection_coords = []
16967
- N = len(aligned_paths)
16968
- DTYPE = self._dtype()
16969
- pref_h = self.chunk_height
16970
- pref_w = self.chunk_width
16971
-
16972
- try:
16973
- chunk_h, chunk_w = compute_safe_chunk(height, width, N, channels, DTYPE, pref_h, pref_w)
16974
- self.update_status(self.tr(f"πŸ”§ Using chunk size {chunk_h}Γ—{chunk_w} for {self._dtype()}"))
16975
- except MemoryError as e:
16976
- self.update_status(self.tr(f"⚠️ {e}"))
16977
- return None, {}, None
16978
-
16979
- # Tile loop (same structure, but tile loading reads from memmaps)
16980
- from concurrent.futures import ThreadPoolExecutor, as_completed
16981
- LOADER_WORKERS = min(max(2, (os.cpu_count() or 4) // 2), 8) # tuned for memory bw
16982
-
16983
- for y_start in range(0, height, chunk_h):
16984
- y_end = min(y_start + chunk_h, height)
16985
- tile_h = y_end - y_start
16986
-
16987
- for x_start in range(0, width, chunk_w):
16988
- x_end = min(x_start + chunk_w, width)
16989
- tile_w = x_end - x_start
16990
-
16991
- # Preallocate tile stack
16992
- tile_stack = np.empty((N, tile_h, tile_w, channels), dtype=np.float32)
16993
-
16994
- # ⬇️ NEW: fill tile_stack from the memmaps (parallel copy)
16995
- def _copy_one(i, path):
16996
- v = mm_views[path][y_start:y_end, x_start:x_end] # view on disk
16997
- if v.ndim == 2:
16998
- # mono memmap stored as (H,W,1); but if legacy mono npy exists as (H,W),
16999
- # make it (H,W,1) here:
17000
- vv = v[..., None]
17001
- else:
17002
- vv = v
17003
- if vv.shape[2] == 1 and channels == 3:
17004
- vv = np.repeat(vv, 3, axis=2)
17005
- tile_stack[i] = vv
17006
-
17007
- with ThreadPoolExecutor(max_workers=LOADER_WORKERS) as exe:
17008
- futs = {exe.submit(_copy_one, i, p): i for i, p in enumerate(aligned_paths)}
17009
- for _ in as_completed(futs):
17010
- pass
17011
-
17012
- # Rejection (unchanged – uses your Numba kernels)
17013
- algo = self.rejection_algorithm
17014
- if algo == "Simple Median (No Rejection)":
17015
- tile_result = np.median(tile_stack, axis=0)
17016
- tile_rej_map = np.zeros(tile_stack.shape[1:3], dtype=np.bool_)
17017
- elif algo == "Simple Average (No Rejection)":
17018
- tile_result = np.average(tile_stack, axis=0, weights=weights_list)
17019
- tile_rej_map = np.zeros(tile_stack.shape[1:3], dtype=np.bool_)
17020
- elif algo == "Weighted Windsorized Sigma Clipping":
17021
- tile_result, tile_rej_map = windsorized_sigma_clip_weighted(
17022
- tile_stack, weights_list, lower=self.sigma_low, upper=self.sigma_high
17023
- )
17024
- elif algo == "Kappa-Sigma Clipping":
17025
- tile_result, tile_rej_map = kappa_sigma_clip_weighted(
17026
- tile_stack, weights_list, kappa=self.kappa, iterations=self.iterations
17027
- )
17028
- elif algo == "Trimmed Mean":
17029
- tile_result, tile_rej_map = trimmed_mean_weighted(
17030
- tile_stack, weights_list, trim_fraction=self.trim_fraction
17031
- )
17032
- elif algo == "Extreme Studentized Deviate (ESD)":
17033
- tile_result, tile_rej_map = esd_clip_weighted(
17034
- tile_stack, weights_list, threshold=self.esd_threshold
17035
- )
17036
- elif algo == "Biweight Estimator":
17037
- tile_result, tile_rej_map = biweight_location_weighted(
17038
- tile_stack, weights_list, tuning_constant=self.biweight_constant
17039
- )
17040
- elif algo == "Modified Z-Score Clipping":
17041
- tile_result, tile_rej_map = modified_zscore_clip_weighted(
17042
- tile_stack, weights_list, threshold=self.modz_threshold
17043
- )
17044
- elif algo == "Max Value":
17045
- tile_result, tile_rej_map = max_value_stack(
17046
- tile_stack, weights_list
17047
- )
17048
- else:
17049
- tile_result, tile_rej_map = windsorized_sigma_clip_weighted(
17050
- tile_stack, weights_list, lower=self.sigma_low, upper=self.sigma_high
17051
- )
17052
-
17053
- # Ensure tile_result has correct shape
17054
- if tile_result.ndim == 2:
17055
- tile_result = tile_result[:, :, None]
17056
- expected_shape = (tile_h, tile_w, channels)
17057
- if tile_result.shape != expected_shape:
17058
- if tile_result.shape[2] == 0:
17059
- tile_result = np.zeros(expected_shape, dtype=np.float32)
17060
- elif tile_result.shape[:2] == (tile_h, tile_w):
17061
- if tile_result.shape[2] > channels:
17062
- tile_result = tile_result[:, :, :channels]
17063
- else:
17064
- tile_result = np.repeat(tile_result, channels, axis=2)[:, :, :channels]
17065
-
17066
- # Commit tile
17067
- final_stacked[y_start:y_end, x_start:x_end, :] = tile_result
17068
-
17069
- # Collect per-tile rejection coords (unchanged logic)
17070
- if tile_rej_map.ndim == 3: # (N, tile_h, tile_w)
17071
- combined_rej = np.any(tile_rej_map, axis=0)
17072
- elif tile_rej_map.ndim == 4: # (N, tile_h, tile_w, C)
17073
- combined_rej = np.any(tile_rej_map, axis=0)
17074
- combined_rej = np.any(combined_rej, axis=-1)
17075
- else:
17076
- combined_rej = np.zeros((tile_h, tile_w), dtype=np.bool_)
17077
-
17078
- ys_tile, xs_tile = np.where(combined_rej)
17079
- for dy, dx in zip(ys_tile, xs_tile):
17080
- rejection_coords.append((x_start + dx, y_start + dy))
17081
-
17082
- # Finish/save (unchanged from your version) …
17083
- final_array = np.array(final_stacked)
17084
- del final_stacked
17085
-
17086
- final_array = self._normalize_stack_01(final_array)
17087
-
17088
- if final_array.ndim == 3 and final_array.shape[-1] == 1:
17089
- final_array = final_array[..., 0]
17090
- is_mono = (final_array.ndim == 2)
17091
-
17092
- if ref_header is None:
17093
- ref_header = fits.Header()
17094
- ref_header["IMAGETYP"] = "MASTER STACK"
17095
- ref_header["BITPIX"] = -32
17096
- ref_header["STACKED"] = (True, "Stacked using chunked approach")
17097
- ref_header["CREATOR"] = "SetiAstroSuite"
17098
- ref_header["DATE-OBS"] = datetime.utcnow().isoformat()
17099
- if is_mono:
17100
- ref_header["NAXIS"] = 2
17101
- ref_header["NAXIS1"] = final_array.shape[1]
17102
- ref_header["NAXIS2"] = final_array.shape[0]
17103
- if "NAXIS3" in ref_header: del ref_header["NAXIS3"]
17104
- else:
17105
- ref_header["NAXIS"] = 3
17106
- ref_header["NAXIS1"] = final_array.shape[1]
17107
- ref_header["NAXIS2"] = final_array.shape[0]
17108
- ref_header["NAXIS3"] = 3
17109
-
17110
- output_stem = f"MasterLight_{group_key}_{len(aligned_paths)}stacked"
17111
- output_path = self._build_out(self.stacking_directory, output_stem, "fit")
17112
-
17113
- save_image(
17114
- img_array=final_array,
17115
- filename=output_path,
17116
- original_format="fit",
17117
- bit_depth="32-bit floating point",
17118
- original_header=ref_header,
17119
- is_mono=is_mono
17120
- )
17121
-
17122
- self.update_status(self.tr(f"βœ… Group '{group_key}' stacked {len(aligned_paths)} frame(s)! Saved: {output_path}"))
17123
-
17124
- print(f"βœ… Master Light saved for group '{group_key}': {output_path}")
17125
-
17126
- # Optionally, you might want to store or log 'rejection_coords' (here appended to all_rejection_coords)
17127
- all_rejection_coords.extend(rejection_coords)
17128
-
17129
- # Clean up memmap file
17130
- try:
17131
- os.remove(memmap_path)
17132
- except OSError:
17133
- pass
17134
-
17135
- QMessageBox.information(
17136
- self,
17137
- "Stacking Complete",
17138
- f"All stacking finished successfully.\n"
17139
- f"Frames per group:\n" +
17140
- "\n".join([f"{group_key}: {len(files)} frame(s)" for group_key, files in grouped_files.items()])
17141
- )
17142
-
17143
- # Optionally, you could return the global rejection coordinate list.
17144
- return all_rejection_coords
17145
-
17146
17645
  def _start_after_align_worker(self, aligned_light_files: dict[str, list[str]]):
17147
17646
  # Snapshot UI settings
17148
17647
  if getattr(self, "_suppress_normal_integration_once", False):
@@ -17316,7 +17815,37 @@ class StackingSuiteDialog(QDialog):
17316
17815
 
17317
17816
  # Thread + worker
17318
17817
  self._mf_thread = QThread(self)
17319
- star_mask_ref = self.reference_frame if use_star_masks else None
17818
+
17819
+ def _pick_mf_ref_from_frames(frames: list[str]) -> str | None:
17820
+ """Pick a reference path for MFDeconv masks from the aligned frames list."""
17821
+ from os import path
17822
+ if not frames:
17823
+ return None
17824
+
17825
+ # Prefer the weighted-best frame if weights exist
17826
+ w = getattr(self, "frame_weights", None) or {}
17827
+ best = None
17828
+ bestw = -1.0
17829
+ for p in frames:
17830
+ pn = path.normpath(p)
17831
+ if not path.exists(pn):
17832
+ continue
17833
+ ww = float(w.get(pn, w.get(p, 0.0)) or 0.0)
17834
+ if ww > bestw:
17835
+ bestw, best = ww, pn
17836
+
17837
+ # Otherwise fall back to first existing frame
17838
+ if best:
17839
+ return best
17840
+ for p in frames:
17841
+ pn = path.normpath(p)
17842
+ if path.exists(pn):
17843
+ return pn
17844
+ return None
17845
+
17846
+ star_mask_ref = _pick_mf_ref_from_frames(frames) if use_star_masks else None
17847
+ if use_star_masks:
17848
+ self.update_status(self.tr(f"🌟 MFDeconv star-mask reference β†’ {star_mask_ref or '(none)'}"))
17320
17849
 
17321
17850
  # ── choose engine plainly (Normal / cuDNN-free / High Octane) ─────────────
17322
17851
  # Expect a setting saved by your radio buttons: "normal" | "cudnn" | "sport"
@@ -17461,6 +17990,87 @@ class StackingSuiteDialog(QDialog):
17461
17990
 
17462
17991
  self.update_status(self.tr(f"πŸ“Š Found {len(cand)} aligned/normalized frames. Measuring in parallel previews…"))
17463
17992
 
17993
+ # ─────────────────────────────────────────────────────────────────────
17994
+ # XISF safety: convert any .xisf to float32 FITS once up-front so the
17995
+ # downstream integration pipeline is guaranteed to be FITS-based.
17996
+ # ─────────────────────────────────────────────────────────────────────
17997
+ prep_dir = os.path.join(self.stacking_directory, "Prepared_Registered")
17998
+ os.makedirs(prep_dir, exist_ok=True)
17999
+
18000
+ orig2prep = {} # optional, for debugging or later mapping
18001
+
18002
+ def _prep_path_for(fp: str) -> str:
18003
+ base = os.path.basename(fp)
18004
+ stem, _ext = os.path.splitext(base)
18005
+ return os.path.normpath(os.path.join(prep_dir, stem + "_prep.fit"))
18006
+
18007
+ prepared = []
18008
+ for fp in cand:
18009
+ ext = os.path.splitext(fp)[1].lower()
18010
+ if ext != ".xisf":
18011
+ prepared.append(fp)
18012
+ continue
18013
+
18014
+ outp = _prep_path_for(fp)
18015
+
18016
+ # reuse if already created this run
18017
+ if os.path.exists(outp):
18018
+ orig2prep[os.path.normcase(os.path.normpath(fp))] = outp
18019
+ prepared.append(outp)
18020
+ continue
18021
+
18022
+ try:
18023
+ img, hdr = self._load_image_any(fp) # must support XISF
18024
+ if img is None:
18025
+ self.update_status(self.tr(f"⚠️ Could not read XISF: {fp}"))
18026
+ continue
18027
+
18028
+ img = _to_writable_f32(img)
18029
+ if img.ndim == 3 and img.shape[-1] == 1:
18030
+ img = np.squeeze(img, axis=-1)
18031
+
18032
+ # Minimal header: keep what you can if hdr is a fits.Header
18033
+ try:
18034
+ h = hdr if isinstance(hdr, fits.Header) else fits.Header()
18035
+ except Exception:
18036
+ h = fits.Header()
18037
+
18038
+ h["SAS_PREP"] = (True, "Prepared from XISF for integration")
18039
+ h["SRCFILE"] = (os.path.basename(fp), "Original source filename")
18040
+ if isinstance(img, np.ndarray) and img.ndim == 3 and img.shape[-1] == 3:
18041
+ h["DEBAYERED"] = (True, "Color frame")
18042
+ else:
18043
+ h["DEBAYERED"] = (False, "Mono frame")
18044
+
18045
+ fits.PrimaryHDU(data=img.astype(np.float32), header=h).writeto(outp, overwrite=True)
18046
+
18047
+ orig2prep[os.path.normcase(os.path.normpath(fp))] = outp
18048
+ prepared.append(outp)
18049
+
18050
+ except Exception as e:
18051
+ self.update_status(self.tr(f"⚠️ XISFβ†’FITS prepare failed for {fp}: {e}"))
18052
+
18053
+ # Swap cand to prepared paths
18054
+ cand = prepared
18055
+
18056
+ # Also update light_files to match these prepared paths so the rest of the
18057
+ # pipeline only ever sees FITS paths.
18058
+ prep_map = orig2prep
18059
+ new_light_files = {}
18060
+ for g, lst in self.light_files.items():
18061
+ out = []
18062
+ for p in lst:
18063
+ k = os.path.normcase(os.path.normpath(p))
18064
+ out.append(prep_map.get(k, p))
18065
+ new_light_files[g] = out
18066
+ self.light_files = new_light_files
18067
+
18068
+ # If reference_frame was set and is XISF, redirect it too
18069
+ if getattr(self, "reference_frame", None):
18070
+ k = os.path.normcase(os.path.normpath(self.reference_frame))
18071
+ if k in prep_map:
18072
+ self.reference_frame = prep_map[k]
18073
+
17464
18074
  # 2) Chunked preview measurement (mean + star count/ecc)
17465
18075
  self.frame_weights = {}
17466
18076
  mean_values = {}
@@ -17490,7 +18100,8 @@ class StackingSuiteDialog(QDialog):
17490
18100
  paths_ok = []
17491
18101
 
17492
18102
  def _preview_job(fp: str):
17493
- return _quick_preview_from_path(fp, target_xbin=1, target_ybin=1)
18103
+ # Use the unified reader (FITS/XISF/TIFF/etc) like registration does
18104
+ return self._quick_preview_any(fp, target_xbin=1, target_ybin=1)
17494
18105
 
17495
18106
  with ThreadPoolExecutor(max_workers=max_workers) as ex:
17496
18107
  futs = {ex.submit(_preview_job, fp): fp for fp in chunk}
@@ -18045,6 +18656,10 @@ class StackingSuiteDialog(QDialog):
18045
18656
  hdr_orig["CREATOR"] = "SetiAstroSuite"
18046
18657
  hdr_orig["DATE-OBS"] = datetime.utcnow().isoformat()
18047
18658
 
18659
+ n_frames = int(len(file_list))
18660
+ hdr_orig["NCOMBINE"] = (n_frames, "Number of frames combined")
18661
+ hdr_orig["NSTACK"] = (n_frames, "Alias of NCOMBINE (SetiAstro)")
18662
+
18048
18663
  if final_drizzle.ndim == 2:
18049
18664
  hdr_orig["NAXIS"] = 2
18050
18665
  hdr_orig["NAXIS1"] = final_drizzle.shape[1]
@@ -18074,10 +18689,12 @@ class StackingSuiteDialog(QDialog):
18074
18689
  cropped_drizzle, hdr_crop = self._apply_autocrop(
18075
18690
  final_drizzle,
18076
18691
  file_list,
18077
- hdr.copy() if hdr is not None else fits.Header(),
18692
+ hdr_orig.copy(),
18078
18693
  scale=float(scale_factor),
18079
18694
  rect_override=rect_override
18080
18695
  )
18696
+ hdr_crop["NCOMBINE"] = (n_frames, "Number of frames combined")
18697
+ hdr_crop["NSTACK"] = (n_frames, "Alias of NCOMBINE (SetiAstro)")
18081
18698
  is_mono_crop = (cropped_drizzle.ndim == 2)
18082
18699
  display_group_driz_crop = self._label_with_dims(group_key, cropped_drizzle.shape[1], cropped_drizzle.shape[0])
18083
18700
  base_crop = f"MasterLight_{display_group_driz_crop}_{len(file_list)}stacked_drizzle_autocrop"