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.
- setiastro/images/abeicon.svg +16 -0
- setiastro/images/acv_icon.png +0 -0
- setiastro/images/cosmic.svg +40 -0
- setiastro/images/cosmicsat.svg +24 -0
- setiastro/images/first_quarter.png +0 -0
- setiastro/images/full_moon.png +0 -0
- setiastro/images/graxpert.svg +19 -0
- setiastro/images/last_quarter.png +0 -0
- setiastro/images/linearfit.svg +32 -0
- setiastro/images/new_moon.png +0 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/images/waning_crescent_1.png +0 -0
- setiastro/images/waning_crescent_2.png +0 -0
- setiastro/images/waning_crescent_3.png +0 -0
- setiastro/images/waning_crescent_4.png +0 -0
- setiastro/images/waning_crescent_5.png +0 -0
- setiastro/images/waning_gibbous_1.png +0 -0
- setiastro/images/waning_gibbous_2.png +0 -0
- setiastro/images/waning_gibbous_3.png +0 -0
- setiastro/images/waning_gibbous_4.png +0 -0
- setiastro/images/waning_gibbous_5.png +0 -0
- setiastro/images/waxing_crescent_1.png +0 -0
- setiastro/images/waxing_crescent_2.png +0 -0
- setiastro/images/waxing_crescent_3.png +0 -0
- setiastro/images/waxing_crescent_4.png +0 -0
- setiastro/images/waxing_crescent_5.png +0 -0
- setiastro/images/waxing_gibbous_1.png +0 -0
- setiastro/images/waxing_gibbous_2.png +0 -0
- setiastro/images/waxing_gibbous_3.png +0 -0
- setiastro/images/waxing_gibbous_4.png +0 -0
- setiastro/images/waxing_gibbous_5.png +0 -0
- setiastro/qml/ResourceMonitor.qml +84 -82
- setiastro/saspro/__main__.py +19 -0
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/abe.py +37 -4
- setiastro/saspro/aberration_ai.py +237 -21
- setiastro/saspro/acv_exporter.py +379 -0
- setiastro/saspro/add_stars.py +33 -6
- setiastro/saspro/backgroundneutral.py +35 -7
- setiastro/saspro/blemish_blaster.py +4 -1
- setiastro/saspro/blink_comparator_pro.py +74 -24
- setiastro/saspro/clahe.py +4 -1
- setiastro/saspro/continuum_subtract.py +4 -1
- setiastro/saspro/convo.py +4 -1
- setiastro/saspro/cosmicclarity.py +129 -18
- setiastro/saspro/crop_dialog_pro.py +123 -7
- setiastro/saspro/curve_editor_pro.py +109 -42
- setiastro/saspro/doc_manager.py +67 -4
- setiastro/saspro/exoplanet_detector.py +120 -28
- setiastro/saspro/frequency_separation.py +1158 -204
- setiastro/saspro/ghs_dialog_pro.py +81 -16
- setiastro/saspro/graxpert.py +1 -0
- setiastro/saspro/gui/main_window.py +393 -204
- setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +356 -12
- setiastro/saspro/gui/mixins/update_mixin.py +138 -36
- setiastro/saspro/gui/mixins/view_mixin.py +42 -0
- setiastro/saspro/halobgon.py +4 -0
- setiastro/saspro/histogram.py +5 -1
- setiastro/saspro/image_combine.py +4 -0
- setiastro/saspro/image_peeker_pro.py +4 -0
- setiastro/saspro/imageops/stretch.py +531 -62
- setiastro/saspro/isophote.py +4 -0
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/image_manager.py +154 -20
- setiastro/saspro/legacy/numba_utils.py +43 -0
- setiastro/saspro/legacy/xisf.py +240 -98
- setiastro/saspro/live_stacking.py +180 -79
- setiastro/saspro/luminancerecombine.py +228 -27
- setiastro/saspro/mask_creation.py +174 -15
- setiastro/saspro/mfdeconv.py +113 -35
- setiastro/saspro/mfdeconvcudnn.py +119 -70
- setiastro/saspro/mfdeconvsport.py +112 -35
- setiastro/saspro/morphology.py +4 -0
- setiastro/saspro/multiscale_decomp.py +51 -12
- setiastro/saspro/numba_utils.py +72 -2
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/ops/script_editor.py +5 -2
- setiastro/saspro/ops/scripts.py +3 -0
- setiastro/saspro/perfect_palette_picker.py +37 -3
- setiastro/saspro/plate_solver.py +84 -49
- setiastro/saspro/psf_viewer.py +119 -37
- setiastro/saspro/resources.py +67 -0
- setiastro/saspro/rgbalign.py +4 -0
- setiastro/saspro/selective_color.py +4 -1
- setiastro/saspro/sfcc.py +60 -2
- setiastro/saspro/shortcuts.py +142 -23
- setiastro/saspro/signature_insert.py +692 -33
- setiastro/saspro/stacking_suite.py +1017 -400
- setiastro/saspro/star_alignment.py +4 -1
- setiastro/saspro/star_spikes.py +4 -0
- setiastro/saspro/star_stretch.py +38 -3
- setiastro/saspro/stat_stretch.py +702 -128
- setiastro/saspro/subwindow.py +786 -360
- setiastro/saspro/supernovaasteroidhunter.py +1 -1
- setiastro/saspro/wavescale_hdr.py +4 -1
- setiastro/saspro/wavescalede.py +4 -1
- setiastro/saspro/whitebalance.py +60 -12
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/resource_monitor.py +109 -59
- setiastro/saspro/widgets/spinboxes.py +10 -13
- setiastro/saspro/wimi.py +27 -656
- setiastro/saspro/wims.py +13 -3
- setiastro/saspro/xisf.py +101 -11
- {setiastrosuitepro-1.6.4.dist-info β setiastrosuitepro-1.6.10.dist-info}/METADATA +2 -1
- {setiastrosuitepro-1.6.4.dist-info β setiastrosuitepro-1.6.10.dist-info}/RECORD +112 -80
- {setiastrosuitepro-1.6.4.dist-info β setiastrosuitepro-1.6.10.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.4.dist-info β setiastrosuitepro-1.6.10.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.4.dist-info β setiastrosuitepro-1.6.10.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
267
|
+
Fast header-only peek with targeted fallback.
|
|
158
268
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
8168
|
-
|
|
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(
|
|
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(
|
|
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
|
-
"
|
|
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
|
-
|
|
10213
|
-
header
|
|
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(
|
|
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
|
-
# ---
|
|
10320
|
-
|
|
10321
|
-
|
|
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
|
-
|
|
10329
|
-
|
|
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
|
-
|
|
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([
|
|
10626
|
+
exposure_item = QTreeWidgetItem([base_key, ""])
|
|
10334
10627
|
tree.addTopLevelItem(exposure_item)
|
|
10335
|
-
self._dark_group_item[
|
|
10336
|
-
|
|
10337
|
-
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
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
|
-
|
|
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
|
-
#
|
|
10493
|
-
# self.dark_files can be either:
|
|
10494
|
-
# legacy: exposure_key -> [paths]
|
|
10495
|
-
# session: (exposure_key, session) -> [paths]
|
|
10813
|
+
# Temp helpers
|
|
10496
10814
|
# -------------------------------------------------------------------------
|
|
10497
|
-
|
|
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
|
-
|
|
10523
|
-
|
|
10524
|
-
|
|
10525
|
-
|
|
10526
|
-
|
|
10527
|
-
|
|
10528
|
-
|
|
10529
|
-
|
|
10530
|
-
|
|
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
|
-
|
|
10533
|
-
|
|
10534
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"({
|
|
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]] = {}
|
|
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
|
-
|
|
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
|
|
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})
|
|
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
|
-
|
|
10658
|
-
|
|
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
|
-
#
|
|
10699
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
10825
|
-
|
|
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"]
|
|
10831
|
-
master_header["SESSION"]
|
|
10832
|
-
master_header["
|
|
10833
|
-
master_header["
|
|
10834
|
-
|
|
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(
|
|
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(
|
|
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)
|
|
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 = {
|
|
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
|
|
11303
|
-
|
|
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
|
-
|
|
11307
|
-
|
|
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
|
|
11331
|
-
|
|
11332
|
-
|
|
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 = {
|
|
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
|
|
11364
|
-
|
|
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
|
-
|
|
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
|
|
11827
|
+
meds[i] = float(np.median(sub))
|
|
11373
11828
|
|
|
11374
|
-
gmed = np.median(meds) if np.all(np.isfinite(meds)) else 1.0
|
|
11375
|
-
|
|
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
|
-
|
|
11378
|
-
|
|
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
|
-
|
|
11953
|
-
|
|
11954
|
-
|
|
11955
|
-
|
|
11956
|
-
|
|
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
|
-
|
|
11961
|
-
|
|
11962
|
-
if not
|
|
11963
|
-
|
|
11964
|
-
|
|
11965
|
-
|
|
11966
|
-
|
|
11967
|
-
|
|
11968
|
-
|
|
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
|
-
|
|
11971
|
-
|
|
11972
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
12687
|
-
|
|
12688
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|