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