setiastrosuitepro 1.7.4__py3-none-any.whl → 1.7.5.post1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- setiastro/images/clonestamp.png +0 -0
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/blemish_blaster.py +54 -14
- setiastro/saspro/blink_comparator_pro.py +146 -2
- setiastro/saspro/clone_stamp.py +753 -0
- setiastro/saspro/gui/main_window.py +22 -1
- setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +8 -13
- setiastro/saspro/resources.py +2 -0
- setiastro/saspro/stacking_suite.py +646 -164
- {setiastrosuitepro-1.7.4.dist-info → setiastrosuitepro-1.7.5.post1.dist-info}/METADATA +1 -1
- {setiastrosuitepro-1.7.4.dist-info → setiastrosuitepro-1.7.5.post1.dist-info}/RECORD +16 -14
- {setiastrosuitepro-1.7.4.dist-info → setiastrosuitepro-1.7.5.post1.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.7.4.dist-info → setiastrosuitepro-1.7.5.post1.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.7.4.dist-info → setiastrosuitepro-1.7.5.post1.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.7.4.dist-info → setiastrosuitepro-1.7.5.post1.dist-info}/licenses/license.txt +0 -0
|
@@ -2295,6 +2295,92 @@ def compute_safe_chunk(height, width, N, channels, dtype, pref_h, pref_w):
|
|
|
2295
2295
|
|
|
2296
2296
|
_DIM_RE = re.compile(r"\s*\(\d+\s*x\s*\d+\)\s*")
|
|
2297
2297
|
|
|
2298
|
+
|
|
2299
|
+
def build_rejection_layers(
|
|
2300
|
+
rejection_map: dict,
|
|
2301
|
+
ref_H: int,
|
|
2302
|
+
ref_W: int,
|
|
2303
|
+
n_frames: int,
|
|
2304
|
+
*,
|
|
2305
|
+
comb_threshold_frac: float = 0.02
|
|
2306
|
+
):
|
|
2307
|
+
"""
|
|
2308
|
+
Supports BOTH formats:
|
|
2309
|
+
|
|
2310
|
+
Old format:
|
|
2311
|
+
rejection_map[file] = [(x, y), (x, y), ...]
|
|
2312
|
+
|
|
2313
|
+
New format (tile masks):
|
|
2314
|
+
rejection_map[file] = [(x0, y0, mask_bool_thx_tw), ...]
|
|
2315
|
+
where mask_bool is (th, tw) boolean array for the tile at (x0,y0) in ref space.
|
|
2316
|
+
"""
|
|
2317
|
+
import numpy as np
|
|
2318
|
+
|
|
2319
|
+
if n_frames <= 0:
|
|
2320
|
+
raise ValueError("n_frames must be > 0")
|
|
2321
|
+
|
|
2322
|
+
rej_cnt = np.zeros((ref_H, ref_W), dtype=np.uint32)
|
|
2323
|
+
|
|
2324
|
+
for _fpath, items in (rejection_map or {}).items():
|
|
2325
|
+
if not items:
|
|
2326
|
+
continue
|
|
2327
|
+
|
|
2328
|
+
# Detect format by peeking at first item
|
|
2329
|
+
first = items[0]
|
|
2330
|
+
|
|
2331
|
+
# ---- NEW tile-mask format ----
|
|
2332
|
+
if isinstance(first, (tuple, list)) and len(first) == 3:
|
|
2333
|
+
for it in items:
|
|
2334
|
+
try:
|
|
2335
|
+
x0, y0, m = it
|
|
2336
|
+
except Exception:
|
|
2337
|
+
continue
|
|
2338
|
+
if m is None:
|
|
2339
|
+
continue
|
|
2340
|
+
|
|
2341
|
+
mm = np.asarray(m, dtype=np.bool_)
|
|
2342
|
+
if mm.ndim != 2:
|
|
2343
|
+
# if somehow stored as (th,tw,1) etc.
|
|
2344
|
+
mm = np.squeeze(mm)
|
|
2345
|
+
if mm.ndim != 2:
|
|
2346
|
+
continue
|
|
2347
|
+
|
|
2348
|
+
th, tw = mm.shape
|
|
2349
|
+
if th <= 0 or tw <= 0:
|
|
2350
|
+
continue
|
|
2351
|
+
|
|
2352
|
+
x0i = int(x0)
|
|
2353
|
+
y0i = int(y0)
|
|
2354
|
+
if x0i >= ref_W or y0i >= ref_H:
|
|
2355
|
+
continue
|
|
2356
|
+
|
|
2357
|
+
x1i = min(ref_W, x0i + tw)
|
|
2358
|
+
y1i = min(ref_H, y0i + th)
|
|
2359
|
+
if x1i <= x0i or y1i <= y0i:
|
|
2360
|
+
continue
|
|
2361
|
+
|
|
2362
|
+
# add 1 rejection count wherever mask is True
|
|
2363
|
+
sub = rej_cnt[y0i:y1i, x0i:x1i]
|
|
2364
|
+
sub += mm[: (y1i - y0i), : (x1i - x0i)].astype(np.uint32)
|
|
2365
|
+
|
|
2366
|
+
# ---- OLD coord-list format ----
|
|
2367
|
+
else:
|
|
2368
|
+
for it in items:
|
|
2369
|
+
try:
|
|
2370
|
+
x, y = it
|
|
2371
|
+
except Exception:
|
|
2372
|
+
# tolerate weird entries
|
|
2373
|
+
continue
|
|
2374
|
+
xi = int(x)
|
|
2375
|
+
yi = int(y)
|
|
2376
|
+
if 0 <= xi < ref_W and 0 <= yi < ref_H:
|
|
2377
|
+
rej_cnt[yi, xi] += 1
|
|
2378
|
+
|
|
2379
|
+
rej_frac = (rej_cnt.astype(np.float32) / float(n_frames))
|
|
2380
|
+
rej_any = (rej_frac >= float(comb_threshold_frac))
|
|
2381
|
+
|
|
2382
|
+
return rej_cnt, rej_frac, rej_any
|
|
2383
|
+
|
|
2298
2384
|
class _Responder(QObject):
|
|
2299
2385
|
finished = pyqtSignal(object) # emits the edited dict or None
|
|
2300
2386
|
|
|
@@ -2472,36 +2558,38 @@ def _save_master_with_rejection_layers(
|
|
|
2472
2558
|
hdr: "fits.Header",
|
|
2473
2559
|
out_path: str,
|
|
2474
2560
|
*,
|
|
2475
|
-
rej_any: "np.ndarray | None" = None, # 2D bool
|
|
2561
|
+
rej_any: "np.ndarray | None" = None, # 2D bool (recommended: thresholded)
|
|
2476
2562
|
rej_frac: "np.ndarray | None" = None, # 2D float32 [0..1]
|
|
2563
|
+
rej_cnt: "np.ndarray | None" = None, # 2D uint16/uint32 counts
|
|
2477
2564
|
):
|
|
2478
2565
|
"""
|
|
2479
2566
|
Writes a MEF (multi-extension FITS) file:
|
|
2480
2567
|
- Primary HDU: the master image (2D or 3D) as float32
|
|
2481
2568
|
* Mono: (H, W)
|
|
2482
|
-
* Color: (
|
|
2483
|
-
- Optional EXTNAME=REJ_COMB: uint8 (0/1)
|
|
2484
|
-
- Optional EXTNAME=REJ_FRAC: float32 fraction-of-frames rejected per pixel
|
|
2569
|
+
* Color: (C, H, W) channels-first for FITS
|
|
2570
|
+
- Optional EXTNAME=REJ_COMB: uint8 (0/1) thresholded rejection mask
|
|
2571
|
+
- Optional EXTNAME=REJ_FRAC: float32 fraction-of-frames rejected per pixel [0..1]
|
|
2572
|
+
- Optional EXTNAME=REJ_CNT : uint16/uint32 count of frames rejected per pixel
|
|
2485
2573
|
"""
|
|
2574
|
+
|
|
2575
|
+
from astropy.io import fits
|
|
2576
|
+
from datetime import datetime
|
|
2577
|
+
|
|
2486
2578
|
# --- sanitize/shape primary data ---
|
|
2487
2579
|
data = np.asarray(img_array, dtype=np.float32, order="C")
|
|
2488
2580
|
|
|
2489
2581
|
# If channels-last, move to channels-first for FITS
|
|
2490
2582
|
if data.ndim == 3:
|
|
2491
|
-
# squeeze accidental singleton channels
|
|
2492
2583
|
if data.shape[-1] == 1:
|
|
2493
|
-
data = np.squeeze(data, axis=-1)
|
|
2494
|
-
elif data.shape[-1] in (3, 4):
|
|
2495
|
-
data = np.transpose(data, (2, 0, 1))
|
|
2496
|
-
# If already (C, H, W) leave it as-is.
|
|
2584
|
+
data = np.squeeze(data, axis=-1) # (H, W)
|
|
2585
|
+
elif data.shape[-1] in (3, 4):
|
|
2586
|
+
data = np.transpose(data, (2, 0, 1)) # (C, H, W)
|
|
2497
2587
|
|
|
2498
|
-
# After squeeze/transpose, re-evaluate dims
|
|
2499
2588
|
if data.ndim not in (2, 3):
|
|
2500
2589
|
raise ValueError(f"Unsupported master image shape for FITS: {data.shape}")
|
|
2501
2590
|
|
|
2502
2591
|
# --- clone + annotate header, and align NAXIS* with 'data' ---
|
|
2503
2592
|
H = (hdr.copy() if hdr is not None else fits.Header())
|
|
2504
|
-
# purge prior NAXIS keys to avoid conflicts after transpose/squeeze
|
|
2505
2593
|
for k in ("NAXIS", "NAXIS1", "NAXIS2", "NAXIS3", "NAXIS4"):
|
|
2506
2594
|
if k in H:
|
|
2507
2595
|
del H[k]
|
|
@@ -2512,41 +2600,50 @@ def _save_master_with_rejection_layers(
|
|
|
2512
2600
|
H["CREATOR"] = "SetiAstroSuite"
|
|
2513
2601
|
H["DATE-OBS"] = datetime.utcnow().isoformat()
|
|
2514
2602
|
|
|
2515
|
-
# Fill NAXIS* to match data (optional; Astropy will infer if omitted)
|
|
2516
2603
|
if data.ndim == 2:
|
|
2517
2604
|
H["NAXIS"] = 2
|
|
2518
|
-
H["NAXIS1"] = int(data.shape[1])
|
|
2519
|
-
H["NAXIS2"] = int(data.shape[0])
|
|
2605
|
+
H["NAXIS1"] = int(data.shape[1])
|
|
2606
|
+
H["NAXIS2"] = int(data.shape[0])
|
|
2520
2607
|
else:
|
|
2521
|
-
# data.shape == (C, H, W)
|
|
2522
2608
|
C, Hh, Ww = data.shape
|
|
2523
2609
|
H["NAXIS"] = 3
|
|
2524
|
-
H["NAXIS1"] = int(Ww)
|
|
2525
|
-
H["NAXIS2"] = int(Hh)
|
|
2526
|
-
H["NAXIS3"] = int(C)
|
|
2610
|
+
H["NAXIS1"] = int(Ww)
|
|
2611
|
+
H["NAXIS2"] = int(Hh)
|
|
2612
|
+
H["NAXIS3"] = int(C)
|
|
2527
2613
|
|
|
2528
|
-
# --- build HDU list ---
|
|
2529
2614
|
prim = fits.PrimaryHDU(data=data, header=H)
|
|
2530
2615
|
hdul = [prim]
|
|
2531
2616
|
|
|
2532
|
-
# Optional layers: must be 2D (H, W)
|
|
2617
|
+
# --- Optional layers: all must be 2D (H, W) in REGISTERED space ---
|
|
2533
2618
|
if rej_any is not None:
|
|
2534
2619
|
rej_any_2d = np.asarray(rej_any, dtype=bool)
|
|
2535
2620
|
if rej_any_2d.ndim != 2:
|
|
2536
2621
|
raise ValueError(f"REJ_COMB must be 2D, got {rej_any_2d.shape}")
|
|
2537
2622
|
h = fits.Header()
|
|
2538
2623
|
h["EXTNAME"] = "REJ_COMB"
|
|
2539
|
-
h["COMMENT"] = "
|
|
2624
|
+
h["COMMENT"] = "Thresholded rejection mask (1=frequently rejected, not merely 'ever')"
|
|
2540
2625
|
hdul.append(fits.ImageHDU(data=rej_any_2d.astype(np.uint8, copy=False), header=h))
|
|
2541
2626
|
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2627
|
+
if rej_frac is not None:
|
|
2628
|
+
rej_frac_2d = np.asarray(rej_frac, dtype=np.float32)
|
|
2629
|
+
if rej_frac_2d.ndim != 2:
|
|
2630
|
+
raise ValueError(f"REJ_FRAC must be 2D, got {rej_frac_2d.shape}")
|
|
2631
|
+
h = fits.Header()
|
|
2632
|
+
h["EXTNAME"] = "REJ_FRAC"
|
|
2633
|
+
h["COMMENT"] = "Per-pixel fraction of frames rejected [0..1]"
|
|
2634
|
+
hdul.append(fits.ImageHDU(data=rej_frac_2d, header=h))
|
|
2635
|
+
|
|
2636
|
+
if rej_cnt is not None:
|
|
2637
|
+
rej_cnt_2d = np.asarray(rej_cnt)
|
|
2638
|
+
if rej_cnt_2d.ndim != 2:
|
|
2639
|
+
raise ValueError(f"REJ_CNT must be 2D, got {rej_cnt_2d.shape}")
|
|
2640
|
+
# keep integer type; clamp to uint16 if you prefer smaller files
|
|
2641
|
+
if rej_cnt_2d.dtype not in (np.uint16, np.uint32, np.int32, np.int64):
|
|
2642
|
+
rej_cnt_2d = rej_cnt_2d.astype(np.uint32, copy=False)
|
|
2643
|
+
h = fits.Header()
|
|
2644
|
+
h["EXTNAME"] = "REJ_CNT"
|
|
2645
|
+
h["COMMENT"] = "Per-pixel count of frames rejected"
|
|
2646
|
+
hdul.append(fits.ImageHDU(data=rej_cnt_2d, header=h))
|
|
2550
2647
|
|
|
2551
2648
|
fits.HDUList(hdul).writeto(out_path, overwrite=True)
|
|
2552
2649
|
|
|
@@ -3794,6 +3891,94 @@ class _MMImage:
|
|
|
3794
3891
|
return arr / 65535.0
|
|
3795
3892
|
return arr
|
|
3796
3893
|
|
|
3894
|
+
def read_tile_into(self, dst: np.ndarray, y0, y1, x0, x1, channels: int):
|
|
3895
|
+
"""
|
|
3896
|
+
Fill dst (shape (th,tw,C) float32) with the tile.
|
|
3897
|
+
No per-tile allocations.
|
|
3898
|
+
"""
|
|
3899
|
+
import numpy as np
|
|
3900
|
+
|
|
3901
|
+
th = y1 - y0
|
|
3902
|
+
tw = x1 - x0
|
|
3903
|
+
|
|
3904
|
+
# dst is (th,tw,C)
|
|
3905
|
+
if self._kind == "fits":
|
|
3906
|
+
d = self._fits_data
|
|
3907
|
+
|
|
3908
|
+
if self.ndim == 2:
|
|
3909
|
+
tile = d[y0:y1, x0:x1] # may be view (memmap) or array
|
|
3910
|
+
# scale BEFORE float cast (tile dtype still int if int on disk)
|
|
3911
|
+
if tile.dtype.kind in ("u", "i"):
|
|
3912
|
+
if self._bitpix == 8:
|
|
3913
|
+
np.multiply(tile, (1.0/255.0), out=dst[..., 0], casting="unsafe")
|
|
3914
|
+
elif self._bitpix == 16:
|
|
3915
|
+
np.multiply(tile, (1.0/65535.0), out=dst[..., 0], casting="unsafe")
|
|
3916
|
+
else:
|
|
3917
|
+
dst[..., 0] = tile
|
|
3918
|
+
else:
|
|
3919
|
+
dst[..., 0] = tile # astropy already float-scaled
|
|
3920
|
+
|
|
3921
|
+
if channels == 3:
|
|
3922
|
+
dst[..., 1] = dst[..., 0]
|
|
3923
|
+
dst[..., 2] = dst[..., 0]
|
|
3924
|
+
return
|
|
3925
|
+
|
|
3926
|
+
# ndim==3
|
|
3927
|
+
sl = [slice(None)] * 3
|
|
3928
|
+
sl[self._spat_axes[0]] = slice(y0, y1)
|
|
3929
|
+
sl[self._spat_axes[1]] = slice(x0, x1)
|
|
3930
|
+
tile = d[tuple(sl)]
|
|
3931
|
+
if (self._color_axis is not None) and (self._color_axis != 2):
|
|
3932
|
+
tile = np.moveaxis(tile, self._color_axis, -1) # now HWC-ish
|
|
3933
|
+
|
|
3934
|
+
# tile might be (th,tw,3) or (3,th,tw) in weird cases; handle quick
|
|
3935
|
+
if tile.ndim == 3 and tile.shape[-1] != 3 and tile.shape[0] == 3:
|
|
3936
|
+
tile = np.moveaxis(tile, 0, -1)
|
|
3937
|
+
|
|
3938
|
+
# scale if integer
|
|
3939
|
+
if tile.dtype.kind in ("u", "i"):
|
|
3940
|
+
div = 1.0
|
|
3941
|
+
if self._bitpix == 8:
|
|
3942
|
+
div = 255.0
|
|
3943
|
+
elif self._bitpix == 16:
|
|
3944
|
+
div = 65535.0
|
|
3945
|
+
# dst float32 = tile / div
|
|
3946
|
+
np.divide(tile, div, out=dst[..., :3], casting="unsafe")
|
|
3947
|
+
else:
|
|
3948
|
+
dst[..., :3] = tile
|
|
3949
|
+
|
|
3950
|
+
# if caller requested mono, collapse to dst[...,0]
|
|
3951
|
+
if channels == 1:
|
|
3952
|
+
# simple luma or first channel; you can choose policy
|
|
3953
|
+
dst[..., 0] = dst[..., 0] # keep R
|
|
3954
|
+
return
|
|
3955
|
+
|
|
3956
|
+
# XISF
|
|
3957
|
+
if self._xisf_memmap is not None:
|
|
3958
|
+
C = 1 if self.ndim == 2 else self.shape[2]
|
|
3959
|
+
if C == 1:
|
|
3960
|
+
tile = self._xisf_memmap[0, y0:y1, x0:x1]
|
|
3961
|
+
dst[..., 0] = tile
|
|
3962
|
+
if channels == 3:
|
|
3963
|
+
dst[..., 1] = dst[..., 0]
|
|
3964
|
+
dst[..., 2] = dst[..., 0]
|
|
3965
|
+
else:
|
|
3966
|
+
tile = np.moveaxis(self._xisf_memmap[:, y0:y1, x0:x1], 0, -1)
|
|
3967
|
+
dst[..., :3] = tile
|
|
3968
|
+
if channels == 1:
|
|
3969
|
+
dst[..., 0] = dst[..., 0]
|
|
3970
|
+
else:
|
|
3971
|
+
tile = self._xisf_arr[y0:y1, x0:x1]
|
|
3972
|
+
if tile.ndim == 2:
|
|
3973
|
+
dst[..., 0] = tile
|
|
3974
|
+
if channels == 3:
|
|
3975
|
+
dst[..., 1] = dst[..., 0]
|
|
3976
|
+
dst[..., 2] = dst[..., 0]
|
|
3977
|
+
else:
|
|
3978
|
+
dst[..., :3] = tile
|
|
3979
|
+
if channels == 1:
|
|
3980
|
+
dst[..., 0] = dst[..., 0]
|
|
3981
|
+
|
|
3797
3982
|
|
|
3798
3983
|
def read_tile(self, y0, y1, x0, x1) -> np.ndarray:
|
|
3799
3984
|
import os
|
|
@@ -11341,10 +11526,7 @@ class StackingSuiteDialog(QDialog):
|
|
|
11341
11526
|
tw = x1 - x0
|
|
11342
11527
|
ts = buf[:N, :th, :tw, :channels]
|
|
11343
11528
|
for i, src in enumerate(sources):
|
|
11344
|
-
|
|
11345
|
-
if sub.ndim == 2:
|
|
11346
|
-
sub = sub[:, :, None] if channels == 1 else sub[:, :, None].repeat(3, axis=2)
|
|
11347
|
-
ts[i, :, :, :] = sub
|
|
11529
|
+
src.read_tile_into(ts[i], y0, y1, x0, x1, channels)
|
|
11348
11530
|
return th, tw
|
|
11349
11531
|
|
|
11350
11532
|
tp = ThreadPoolExecutor(max_workers=1)
|
|
@@ -16924,33 +17106,217 @@ class StackingSuiteDialog(QDialog):
|
|
|
16924
17106
|
QApplication.processEvents()
|
|
16925
17107
|
|
|
16926
17108
|
|
|
16927
|
-
def save_rejection_map_sasr(self, rejection_map,
|
|
17109
|
+
def save_rejection_map_sasr(self, rejection_map: dict, out_path: str):
|
|
16928
17110
|
"""
|
|
16929
|
-
|
|
16930
|
-
|
|
16931
|
-
|
|
16932
|
-
|
|
16933
|
-
|
|
16934
|
-
|
|
16935
|
-
|
|
16936
|
-
|
|
17111
|
+
Save per-file rejection information to .sasr (JSON).
|
|
17112
|
+
|
|
17113
|
+
Supports BOTH formats:
|
|
17114
|
+
Old: rejection_map[file] = [(x, y), ...]
|
|
17115
|
+
New: rejection_map[file] = [(x0, y0, mask_bool_thx_tw), ...]
|
|
17116
|
+
where mask_bool is a 2D boolean ndarray for that tile in REF space.
|
|
17117
|
+
|
|
17118
|
+
For the new format we bit-pack masks to keep files small.
|
|
16937
17119
|
"""
|
|
16938
|
-
|
|
16939
|
-
|
|
16940
|
-
|
|
16941
|
-
|
|
16942
|
-
|
|
16943
|
-
|
|
16944
|
-
|
|
17120
|
+
import os, json, base64
|
|
17121
|
+
import numpy as np
|
|
17122
|
+
|
|
17123
|
+
def _is_tile_item(it) -> bool:
|
|
17124
|
+
return isinstance(it, (tuple, list)) and len(it) == 3
|
|
17125
|
+
|
|
17126
|
+
def _is_xy_item(it) -> bool:
|
|
17127
|
+
return isinstance(it, (tuple, list)) and len(it) == 2
|
|
17128
|
+
|
|
17129
|
+
payload = {
|
|
17130
|
+
"version": 2, # bump because we now support tile masks
|
|
17131
|
+
"kind": "mixed", # per-file can be xy or tiles
|
|
17132
|
+
"files": {}
|
|
17133
|
+
}
|
|
17134
|
+
|
|
17135
|
+
for fpath, items in (rejection_map or {}).items():
|
|
17136
|
+
if not items:
|
|
17137
|
+
continue
|
|
17138
|
+
|
|
17139
|
+
# detect which format this file uses
|
|
17140
|
+
first = items[0]
|
|
17141
|
+
if _is_tile_item(first):
|
|
17142
|
+
out_items = []
|
|
17143
|
+
for it in items:
|
|
17144
|
+
try:
|
|
17145
|
+
x0, y0, m = it
|
|
17146
|
+
except Exception:
|
|
17147
|
+
continue
|
|
17148
|
+
if m is None:
|
|
17149
|
+
continue
|
|
17150
|
+
|
|
17151
|
+
mm = np.asarray(m, dtype=np.bool_)
|
|
17152
|
+
if mm.ndim != 2:
|
|
17153
|
+
mm = np.squeeze(mm)
|
|
17154
|
+
if mm.ndim != 2:
|
|
17155
|
+
continue
|
|
17156
|
+
|
|
17157
|
+
th, tw = mm.shape
|
|
17158
|
+
if th <= 0 or tw <= 0:
|
|
17159
|
+
continue
|
|
17160
|
+
|
|
17161
|
+
packed = np.packbits(mm.reshape(-1).astype(np.uint8))
|
|
17162
|
+
b64 = base64.b64encode(packed.tobytes()).decode("ascii")
|
|
17163
|
+
|
|
17164
|
+
out_items.append({
|
|
17165
|
+
"x0": int(x0),
|
|
17166
|
+
"y0": int(y0),
|
|
17167
|
+
"h": int(th),
|
|
17168
|
+
"w": int(tw),
|
|
17169
|
+
"bits": b64
|
|
17170
|
+
})
|
|
17171
|
+
|
|
17172
|
+
payload["files"][os.path.normpath(fpath)] = {
|
|
17173
|
+
"format": "tilemask",
|
|
17174
|
+
"tiles": out_items
|
|
17175
|
+
}
|
|
17176
|
+
|
|
17177
|
+
elif _is_xy_item(first):
|
|
17178
|
+
# old (x,y) list
|
|
17179
|
+
xy = []
|
|
17180
|
+
for it in items:
|
|
17181
|
+
try:
|
|
17182
|
+
x, y = it
|
|
17183
|
+
except Exception:
|
|
17184
|
+
continue
|
|
17185
|
+
xy.append([int(x), int(y)])
|
|
17186
|
+
|
|
17187
|
+
payload["files"][os.path.normpath(fpath)] = {
|
|
17188
|
+
"format": "xy",
|
|
17189
|
+
"coords": xy
|
|
17190
|
+
}
|
|
17191
|
+
|
|
17192
|
+
else:
|
|
17193
|
+
# Unknown / mixed junk — try to salvage
|
|
17194
|
+
xy = []
|
|
17195
|
+
tiles = []
|
|
17196
|
+
for it in items:
|
|
17197
|
+
if _is_xy_item(it):
|
|
17198
|
+
x, y = it
|
|
17199
|
+
xy.append([int(x), int(y)])
|
|
17200
|
+
elif _is_tile_item(it):
|
|
17201
|
+
x0, y0, m = it
|
|
17202
|
+
mm = np.asarray(m, dtype=np.bool_)
|
|
17203
|
+
if mm.ndim != 2:
|
|
17204
|
+
mm = np.squeeze(mm)
|
|
17205
|
+
if mm.ndim != 2:
|
|
17206
|
+
continue
|
|
17207
|
+
th, tw = mm.shape
|
|
17208
|
+
packed = np.packbits(mm.reshape(-1).astype(np.uint8))
|
|
17209
|
+
b64 = base64.b64encode(packed.tobytes()).decode("ascii")
|
|
17210
|
+
tiles.append({"x0": int(x0), "y0": int(y0), "h": int(th), "w": int(tw), "bits": b64})
|
|
17211
|
+
|
|
17212
|
+
if tiles:
|
|
17213
|
+
payload["files"][os.path.normpath(fpath)] = {"format": "tilemask", "tiles": tiles}
|
|
17214
|
+
elif xy:
|
|
17215
|
+
payload["files"][os.path.normpath(fpath)] = {"format": "xy", "coords": xy}
|
|
17216
|
+
|
|
17217
|
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
|
17218
|
+
with open(out_path, "w", encoding="utf-8") as f:
|
|
17219
|
+
json.dump(payload, f, indent=2)
|
|
17220
|
+
|
|
16945
17221
|
|
|
16946
17222
|
def load_rejection_map_sasr(self, in_file):
|
|
16947
17223
|
"""
|
|
16948
|
-
|
|
16949
|
-
|
|
17224
|
+
Load a .sasr rejection map.
|
|
17225
|
+
|
|
17226
|
+
Supports:
|
|
17227
|
+
- V1 legacy text format:
|
|
17228
|
+
FILE: <path>
|
|
17229
|
+
x, y
|
|
17230
|
+
x, y
|
|
17231
|
+
|
|
17232
|
+
Returns: { fpath: [(x, y), ...] }
|
|
17233
|
+
|
|
17234
|
+
- V2 JSON format (tile masks, bit-packed):
|
|
17235
|
+
Returns: { fpath: [(x0, y0, mask_bool_2d), ...] }
|
|
17236
|
+
|
|
17237
|
+
Notes:
|
|
17238
|
+
- This function returns whatever the file contains (xy or tilemask).
|
|
17239
|
+
- Your drizzle path expects tilemask format keyed by *aligned path*.
|
|
16950
17240
|
"""
|
|
17241
|
+
import os, re, json, base64
|
|
17242
|
+
import numpy as np
|
|
17243
|
+
|
|
17244
|
+
# Read file
|
|
17245
|
+
with open(in_file, "r", encoding="utf-8") as f:
|
|
17246
|
+
content = f.read()
|
|
17247
|
+
|
|
17248
|
+
s = content.lstrip()
|
|
17249
|
+
|
|
17250
|
+
# -----------------------
|
|
17251
|
+
# V2: JSON tilemask format
|
|
17252
|
+
# -----------------------
|
|
17253
|
+
if s.startswith("{") or s.startswith("["):
|
|
17254
|
+
try:
|
|
17255
|
+
obj = json.loads(content)
|
|
17256
|
+
except Exception:
|
|
17257
|
+
obj = None
|
|
17258
|
+
|
|
17259
|
+
if isinstance(obj, dict) and "files" in obj:
|
|
17260
|
+
files = obj.get("files") or {}
|
|
17261
|
+
rejections = {}
|
|
17262
|
+
|
|
17263
|
+
for raw_path, entry in files.items():
|
|
17264
|
+
if not isinstance(entry, dict):
|
|
17265
|
+
continue
|
|
17266
|
+
fmt = (entry.get("format") or "").lower()
|
|
17267
|
+
|
|
17268
|
+
if fmt == "xy":
|
|
17269
|
+
coords = []
|
|
17270
|
+
for xy in entry.get("coords") or []:
|
|
17271
|
+
try:
|
|
17272
|
+
x, y = xy
|
|
17273
|
+
coords.append((int(x), int(y)))
|
|
17274
|
+
except Exception:
|
|
17275
|
+
continue
|
|
17276
|
+
if coords:
|
|
17277
|
+
rejections[os.path.normpath(raw_path)] = coords
|
|
17278
|
+
|
|
17279
|
+
elif fmt == "tilemask":
|
|
17280
|
+
tiles_out = []
|
|
17281
|
+
for t in entry.get("tiles") or []:
|
|
17282
|
+
try:
|
|
17283
|
+
x0 = int(t["x0"])
|
|
17284
|
+
y0 = int(t["y0"])
|
|
17285
|
+
h = int(t["h"])
|
|
17286
|
+
w = int(t["w"])
|
|
17287
|
+
b64 = t["bits"]
|
|
17288
|
+
except Exception:
|
|
17289
|
+
continue
|
|
17290
|
+
|
|
17291
|
+
try:
|
|
17292
|
+
packed = np.frombuffer(base64.b64decode(b64.encode("ascii")), dtype=np.uint8)
|
|
17293
|
+
bits = np.unpackbits(packed) # 1D, length >= h*w
|
|
17294
|
+
bits = bits[: (h * w)]
|
|
17295
|
+
mask = (bits.reshape(h, w).astype(bool))
|
|
17296
|
+
except Exception:
|
|
17297
|
+
continue
|
|
17298
|
+
|
|
17299
|
+
tiles_out.append((x0, y0, mask))
|
|
17300
|
+
|
|
17301
|
+
if tiles_out:
|
|
17302
|
+
rejections[os.path.normpath(raw_path)] = tiles_out
|
|
17303
|
+
|
|
17304
|
+
else:
|
|
17305
|
+
# Unknown entry type; ignore
|
|
17306
|
+
continue
|
|
17307
|
+
|
|
17308
|
+
return rejections
|
|
17309
|
+
|
|
17310
|
+
# If JSON but not our format, fall through to legacy parse attempt
|
|
17311
|
+
# (some people may have old text that starts with '{' accidentally)
|
|
17312
|
+
|
|
17313
|
+
# -----------------------
|
|
17314
|
+
# V1: legacy text format
|
|
17315
|
+
# -----------------------
|
|
16951
17316
|
rejections = {}
|
|
16952
|
-
|
|
16953
|
-
|
|
17317
|
+
content = content.strip()
|
|
17318
|
+
if not content:
|
|
17319
|
+
return rejections
|
|
16954
17320
|
|
|
16955
17321
|
# Split on blank lines
|
|
16956
17322
|
blocks = re.split(r"\n\s*\n", content)
|
|
@@ -16959,21 +17325,23 @@ class StackingSuiteDialog(QDialog):
|
|
|
16959
17325
|
if not lines:
|
|
16960
17326
|
continue
|
|
16961
17327
|
|
|
16962
|
-
# First line should be 'FILE: <path>'
|
|
16963
17328
|
if lines[0].startswith("FILE:"):
|
|
16964
17329
|
raw_path = lines[0].replace("FILE:", "").strip()
|
|
16965
17330
|
coords = []
|
|
16966
17331
|
for line in lines[1:]:
|
|
16967
|
-
# Each subsequent line is "x, y"
|
|
16968
17332
|
parts = line.split(",")
|
|
16969
17333
|
if len(parts) == 2:
|
|
16970
17334
|
x_str, y_str = parts
|
|
16971
|
-
|
|
16972
|
-
|
|
17335
|
+
try:
|
|
17336
|
+
x = int(x_str.strip())
|
|
17337
|
+
y = int(y_str.strip())
|
|
17338
|
+
except Exception:
|
|
17339
|
+
continue
|
|
16973
17340
|
coords.append((x, y))
|
|
16974
|
-
|
|
16975
|
-
|
|
17341
|
+
if coords:
|
|
17342
|
+
rejections[os.path.normpath(raw_path)] = coords
|
|
16976
17343
|
|
|
17344
|
+
return rejections
|
|
16977
17345
|
|
|
16978
17346
|
@pyqtSlot(list, dict, result=object) # (files: list[str], initial_xy: dict[str, (x,y)]) -> dict|None
|
|
16979
17347
|
def show_comet_preview(self, files, initial_xy):
|
|
@@ -17139,16 +17507,29 @@ class StackingSuiteDialog(QDialog):
|
|
|
17139
17507
|
maps = getattr(self, "_rej_maps", {}).get(group_key)
|
|
17140
17508
|
save_layers = self.settings.value("stacking/save_rejection_layers", True, type=bool)
|
|
17141
17509
|
|
|
17142
|
-
|
|
17510
|
+
save_layers = self.settings.value("stacking/save_rejection_layers", True, type=bool)
|
|
17511
|
+
|
|
17512
|
+
if rejection_map and save_layers:
|
|
17143
17513
|
try:
|
|
17514
|
+
# integrated_image is already normalized above; rejection coords are assumed in this (H,W) space
|
|
17515
|
+
rej_cnt, rej_frac, rej_any = build_rejection_layers(
|
|
17516
|
+
rejection_map=rejection_map,
|
|
17517
|
+
ref_H=H,
|
|
17518
|
+
ref_W=W,
|
|
17519
|
+
n_frames=n_frames_group,
|
|
17520
|
+
comb_threshold_frac=0.02, # tweakable
|
|
17521
|
+
)
|
|
17522
|
+
|
|
17144
17523
|
_save_master_with_rejection_layers(
|
|
17145
|
-
integrated_image,
|
|
17146
|
-
hdr_orig,
|
|
17147
|
-
out_path_orig,
|
|
17148
|
-
rej_any
|
|
17149
|
-
rej_frac=
|
|
17524
|
+
img_array=integrated_image,
|
|
17525
|
+
hdr=hdr_orig,
|
|
17526
|
+
out_path=out_path_orig,
|
|
17527
|
+
rej_any=rej_any,
|
|
17528
|
+
rej_frac=rej_frac,
|
|
17529
|
+
rej_cnt=rej_cnt,
|
|
17150
17530
|
)
|
|
17151
17531
|
log(f"✅ Saved integrated image (with rejection layers) for '{group_key}': {out_path_orig}")
|
|
17532
|
+
|
|
17152
17533
|
except Exception as e:
|
|
17153
17534
|
log(f"⚠️ MEF save failed ({e}); falling back to single-HDU save.")
|
|
17154
17535
|
save_image(
|
|
@@ -17160,8 +17541,8 @@ class StackingSuiteDialog(QDialog):
|
|
|
17160
17541
|
is_mono=is_mono_orig
|
|
17161
17542
|
)
|
|
17162
17543
|
log(f"✅ Saved integrated image (single-HDU) for '{group_key}': {out_path_orig}")
|
|
17544
|
+
|
|
17163
17545
|
else:
|
|
17164
|
-
# No maps available or feature disabled → single-HDU save
|
|
17165
17546
|
save_image(
|
|
17166
17547
|
img_array=integrated_image,
|
|
17167
17548
|
filename=out_path_orig,
|
|
@@ -17478,22 +17859,25 @@ class StackingSuiteDialog(QDialog):
|
|
|
17478
17859
|
|
|
17479
17860
|
# ---- Drizzle bookkeeping for this group ----
|
|
17480
17861
|
if drizzle_enabled_global:
|
|
17862
|
+
|
|
17863
|
+
try:
|
|
17864
|
+
any_key = next(iter(rejection_map))
|
|
17865
|
+
first_item = rejection_map[any_key][0] if rejection_map[any_key] else None
|
|
17866
|
+
log(f"🔎 rej_map sample: key={os.path.basename(any_key)} item_type={type(first_item)} len={len(first_item) if hasattr(first_item,'__len__') else 'NA'}")
|
|
17867
|
+
except Exception:
|
|
17868
|
+
pass
|
|
17869
|
+
|
|
17481
17870
|
sasr_path = os.path.join(self.stacking_directory, f"{group_key}_rejections.sasr")
|
|
17482
17871
|
self.save_rejection_map_sasr(rejection_map, sasr_path)
|
|
17483
17872
|
log(f"✅ Saved rejection map to {sasr_path}")
|
|
17484
|
-
|
|
17485
|
-
|
|
17486
|
-
|
|
17487
|
-
|
|
17488
|
-
|
|
17489
|
-
|
|
17490
|
-
|
|
17491
|
-
|
|
17492
|
-
"integrated_image": integrated_image,
|
|
17493
|
-
"rejection_map": None,
|
|
17494
|
-
"n_frames": n_frames_group,
|
|
17495
|
-
"drizzled": False
|
|
17496
|
-
}
|
|
17873
|
+
|
|
17874
|
+
group_integration_data[group_key] = {
|
|
17875
|
+
"integrated_image": integrated_image,
|
|
17876
|
+
"rejection_map": rejection_map if drizzle_enabled_global else None,
|
|
17877
|
+
"n_frames": n_frames_group,
|
|
17878
|
+
"drizzled": False, # <-- ALWAYS false here
|
|
17879
|
+
}
|
|
17880
|
+
if not drizzle_enabled_global:
|
|
17497
17881
|
log(f"ℹ️ Skipping rejection map save for '{group_key}' (drizzle disabled).")
|
|
17498
17882
|
|
|
17499
17883
|
QApplication.processEvents()
|
|
@@ -17549,19 +17933,27 @@ class StackingSuiteDialog(QDialog):
|
|
|
17549
17933
|
|
|
17550
17934
|
log(f"📐 Drizzle for '{group_key}' at {scale_factor}× (drop={drop_shrink}) using {n_frames_group} frame(s).")
|
|
17551
17935
|
|
|
17552
|
-
|
|
17553
|
-
|
|
17554
|
-
|
|
17555
|
-
|
|
17556
|
-
|
|
17557
|
-
|
|
17558
|
-
|
|
17559
|
-
|
|
17560
|
-
|
|
17561
|
-
|
|
17562
|
-
|
|
17563
|
-
|
|
17564
|
-
|
|
17936
|
+
ok = False
|
|
17937
|
+
try:
|
|
17938
|
+
ok = bool(self.drizzle_stack_one_group(
|
|
17939
|
+
group_key=group_key,
|
|
17940
|
+
file_list=file_list,
|
|
17941
|
+
entries=entries_by_group.get(group_key, []),
|
|
17942
|
+
transforms_dict=transforms_dict,
|
|
17943
|
+
frame_weights=frame_weights,
|
|
17944
|
+
scale_factor=scale_factor,
|
|
17945
|
+
drop_shrink=drop_shrink,
|
|
17946
|
+
rejection_map=rejections_for_group,
|
|
17947
|
+
autocrop_enabled=autocrop_enabled,
|
|
17948
|
+
rect_override=global_rect,
|
|
17949
|
+
status_cb=log
|
|
17950
|
+
))
|
|
17951
|
+
except Exception as e:
|
|
17952
|
+
log(f"⚠️ Drizzle failed for '{group_key}': {e!r}")
|
|
17953
|
+
ok = False
|
|
17954
|
+
|
|
17955
|
+
if ok:
|
|
17956
|
+
group_integration_data[group_key]["drizzled"] = True
|
|
17565
17957
|
|
|
17566
17958
|
|
|
17567
17959
|
# Build summary lines
|
|
@@ -17593,7 +17985,8 @@ class StackingSuiteDialog(QDialog):
|
|
|
17593
17985
|
*,
|
|
17594
17986
|
algo_override: str | None = None
|
|
17595
17987
|
):
|
|
17596
|
-
|
|
17988
|
+
collect_per_file = bool(self._get_drizzle_enabled())
|
|
17989
|
+
per_file_rejections = {f: [] for f in file_list} if collect_per_file else None
|
|
17597
17990
|
debug_starrem = bool(self.settings.value("stacking/comet_starrem/debug_dump", False, type=bool))
|
|
17598
17991
|
debug_dir = os.path.join(self.stacking_directory, "debug_comet_starrem")
|
|
17599
17992
|
os.makedirs(debug_dir, exist_ok=True)
|
|
@@ -17912,10 +18305,13 @@ class StackingSuiteDialog(QDialog):
|
|
|
17912
18305
|
rej_any[y0:y1, x0:x1] |= np.any(trm, axis=0)
|
|
17913
18306
|
rej_count[y0:y1, x0:x1] += trm.sum(axis=0).astype(np.uint16)
|
|
17914
18307
|
|
|
17915
|
-
|
|
17916
|
-
|
|
17917
|
-
|
|
17918
|
-
|
|
18308
|
+
if collect_per_file:
|
|
18309
|
+
for i, fpath in enumerate(file_list):
|
|
18310
|
+
m = trm[i]
|
|
18311
|
+
if np.any(m):
|
|
18312
|
+
# store as a tile mask, not coord list
|
|
18313
|
+
per_file_rejections[fpath].append((x0, y0, m.copy()))
|
|
18314
|
+
|
|
17919
18315
|
|
|
17920
18316
|
# Close readers and clean temp
|
|
17921
18317
|
for s in sources:
|
|
@@ -18792,13 +19188,13 @@ class StackingSuiteDialog(QDialog):
|
|
|
18792
19188
|
frame_weights,
|
|
18793
19189
|
scale_factor,
|
|
18794
19190
|
drop_shrink,
|
|
18795
|
-
rejection_map,
|
|
19191
|
+
rejection_map, # NEW FORMAT: { aligned_path: [(x0,y0,mask_bool), ...] }
|
|
18796
19192
|
autocrop_enabled,
|
|
18797
19193
|
rect_override,
|
|
18798
19194
|
status_cb
|
|
18799
19195
|
):
|
|
18800
19196
|
import os
|
|
18801
|
-
|
|
19197
|
+
import numpy as np
|
|
18802
19198
|
import cv2
|
|
18803
19199
|
from datetime import datetime
|
|
18804
19200
|
from astropy.io import fits
|
|
@@ -18816,14 +19212,14 @@ class StackingSuiteDialog(QDialog):
|
|
|
18816
19212
|
log(f"ℹ️ Using in-memory REF_SHAPE fallback: {ref_H}×{ref_W}")
|
|
18817
19213
|
else:
|
|
18818
19214
|
log("⚠️ Missing REF_SHAPE in SASD; cannot drizzle.")
|
|
18819
|
-
return
|
|
19215
|
+
return False
|
|
18820
19216
|
|
|
18821
19217
|
log(f"✅ SASD v2: loaded {len(xforms)} transform(s).")
|
|
18822
19218
|
|
|
18823
19219
|
# ---- sanity: entries must exist ----
|
|
18824
19220
|
if not entries:
|
|
18825
19221
|
log(f"⚠️ Group '{group_key}' has no drizzle entries – skipping.")
|
|
18826
|
-
return
|
|
19222
|
+
return False
|
|
18827
19223
|
|
|
18828
19224
|
# Debug (first few)
|
|
18829
19225
|
try:
|
|
@@ -18850,20 +19246,26 @@ class StackingSuiteDialog(QDialog):
|
|
|
18850
19246
|
else:
|
|
18851
19247
|
_kcode = 0 # square
|
|
18852
19248
|
|
|
18853
|
-
|
|
18854
|
-
|
|
19249
|
+
# Rejection logging: now "tiles", not "pixels"
|
|
19250
|
+
total_tiles = 0
|
|
19251
|
+
if rejection_map:
|
|
19252
|
+
try:
|
|
19253
|
+
total_tiles = sum(len(v) for v in rejection_map.values())
|
|
19254
|
+
except Exception:
|
|
19255
|
+
total_tiles = 0
|
|
19256
|
+
log(f"🔭 Drizzle stacking for group '{group_key}' with {total_tiles} rejection tiles.")
|
|
18855
19257
|
|
|
18856
19258
|
# Need at least 2 frames to be worth it
|
|
18857
19259
|
if len(file_list) < 2:
|
|
18858
19260
|
log(f"⚠️ Group '{group_key}' does not have enough frames to drizzle.")
|
|
18859
|
-
return
|
|
19261
|
+
return False
|
|
18860
19262
|
|
|
18861
19263
|
# ---- establish header + default dims from first aligned file (metadata only) ----
|
|
18862
19264
|
first_file = file_list[0]
|
|
18863
19265
|
first_img, hdr, _, _ = load_image(first_file)
|
|
18864
19266
|
if first_img is None:
|
|
18865
19267
|
log(f"⚠️ Could not load {first_file} to determine drizzle shape!")
|
|
18866
|
-
return
|
|
19268
|
+
return False
|
|
18867
19269
|
|
|
18868
19270
|
# Force mono if any entry is channel-split (dual-band drizzle output should be mono)
|
|
18869
19271
|
force_mono = any((e.get("chan") in ("R", "G", "B")) for e in entries)
|
|
@@ -18896,9 +19298,118 @@ class StackingSuiteDialog(QDialog):
|
|
|
18896
19298
|
# ---- allocate buffers ----
|
|
18897
19299
|
out_h = int(canvas_H)
|
|
18898
19300
|
out_w = int(canvas_W)
|
|
18899
|
-
drizzle_buffer
|
|
19301
|
+
drizzle_buffer = np.zeros((out_h, out_w) if is_mono else (out_h, out_w, c), dtype=self._dtype())
|
|
18900
19302
|
coverage_buffer = np.zeros_like(drizzle_buffer, dtype=self._dtype())
|
|
18901
|
-
finalize_func
|
|
19303
|
+
finalize_func = finalize_drizzle_2d if is_mono else finalize_drizzle_3d
|
|
19304
|
+
|
|
19305
|
+
# ---- helpers for rejection masks ----
|
|
19306
|
+
dilate_px = int(self.settings.value("stacking/reject_dilate_px", 0, type=int))
|
|
19307
|
+
dilate_shape = (self.settings.value("stacking/reject_dilate_shape", "square", type=str) or "square").lower()
|
|
19308
|
+
|
|
19309
|
+
def _make_dilate_kernel(r: int, shape_name: str):
|
|
19310
|
+
if r <= 0:
|
|
19311
|
+
return None
|
|
19312
|
+
ksz = 2 * r + 1
|
|
19313
|
+
if shape_name.startswith("dia"):
|
|
19314
|
+
k = np.zeros((ksz, ksz), np.uint8)
|
|
19315
|
+
for yy in range(-r, r + 1):
|
|
19316
|
+
for xx in range(-r, r + 1):
|
|
19317
|
+
if (abs(xx) + abs(yy)) <= r:
|
|
19318
|
+
k[yy + r, xx + r] = 1
|
|
19319
|
+
return k
|
|
19320
|
+
# square default
|
|
19321
|
+
return np.ones((ksz, ksz), np.uint8)
|
|
19322
|
+
|
|
19323
|
+
_dilate_kernel = _make_dilate_kernel(dilate_px, dilate_shape)
|
|
19324
|
+
|
|
19325
|
+
def _build_rej_mask_ref(aligned_key: str):
|
|
19326
|
+
"""
|
|
19327
|
+
Build a full-frame ref-space rejection mask (ref_H, ref_W) from tile masks:
|
|
19328
|
+
rejection_map[aligned_key] = [(x0,y0,mask_bool_thx_tw), ...]
|
|
19329
|
+
Returns None if no tiles.
|
|
19330
|
+
"""
|
|
19331
|
+
if not rejection_map:
|
|
19332
|
+
return None
|
|
19333
|
+
tiles = rejection_map.get(aligned_key, None)
|
|
19334
|
+
if not tiles:
|
|
19335
|
+
return None
|
|
19336
|
+
|
|
19337
|
+
mref = np.zeros((ref_H, ref_W), dtype=np.bool_)
|
|
19338
|
+
for item in tiles:
|
|
19339
|
+
try:
|
|
19340
|
+
x0, y0, m = item
|
|
19341
|
+
except Exception:
|
|
19342
|
+
continue
|
|
19343
|
+
if m is None:
|
|
19344
|
+
continue
|
|
19345
|
+
# ensure bool
|
|
19346
|
+
mm = np.asarray(m, dtype=np.bool_)
|
|
19347
|
+
th, tw = mm.shape[:2]
|
|
19348
|
+
if th <= 0 or tw <= 0:
|
|
19349
|
+
continue
|
|
19350
|
+
x0i = int(x0); y0i = int(y0)
|
|
19351
|
+
if x0i >= ref_W or y0i >= ref_H:
|
|
19352
|
+
continue
|
|
19353
|
+
x1i = min(ref_W, x0i + tw)
|
|
19354
|
+
y1i = min(ref_H, y0i + th)
|
|
19355
|
+
if x1i <= x0i or y1i <= y0i:
|
|
19356
|
+
continue
|
|
19357
|
+
mref[y0i:y1i, x0i:x1i] |= mm[:(y1i - y0i), :(x1i - x0i)]
|
|
19358
|
+
|
|
19359
|
+
if _dilate_kernel is not None:
|
|
19360
|
+
mref = cv2.dilate(mref.astype(np.uint8), _dilate_kernel, iterations=1).astype(bool)
|
|
19361
|
+
return mref
|
|
19362
|
+
|
|
19363
|
+
def _apply_rejections_to_img(img_data: np.ndarray, rej_mask_ref: np.ndarray, pixels_are_registered: bool, A23: np.ndarray):
|
|
19364
|
+
"""
|
|
19365
|
+
Apply ref-space rejection mask to img_data:
|
|
19366
|
+
- if pixels_are_registered: img_data is already in ref space -> direct apply
|
|
19367
|
+
- else: img_data is raw/original and A23 maps raw->ref -> warp mask ref->raw once, then apply
|
|
19368
|
+
"""
|
|
19369
|
+
if rej_mask_ref is None:
|
|
19370
|
+
return img_data
|
|
19371
|
+
|
|
19372
|
+
# after channel extraction, img_data may be 2D
|
|
19373
|
+
if img_data.ndim == 2:
|
|
19374
|
+
Hraw, Wraw = img_data.shape
|
|
19375
|
+
else:
|
|
19376
|
+
Hraw, Wraw = img_data.shape[0], img_data.shape[1]
|
|
19377
|
+
|
|
19378
|
+
if pixels_are_registered:
|
|
19379
|
+
# Clip mask to actual image dims (paranoia)
|
|
19380
|
+
hh = min(rej_mask_ref.shape[0], Hraw)
|
|
19381
|
+
ww = min(rej_mask_ref.shape[1], Wraw)
|
|
19382
|
+
m = rej_mask_ref[:hh, :ww]
|
|
19383
|
+
if img_data.ndim == 2:
|
|
19384
|
+
img_data[:hh, :ww][m] = 0.0
|
|
19385
|
+
else:
|
|
19386
|
+
img_data[:hh, :ww, :][m, :] = 0.0
|
|
19387
|
+
return img_data
|
|
19388
|
+
|
|
19389
|
+
# affine case: A23 maps raw->ref. Need ref-mask in raw space -> warp with inverse affine.
|
|
19390
|
+
try:
|
|
19391
|
+
invA23 = cv2.invertAffineTransform(A23.astype(np.float32))
|
|
19392
|
+
except Exception:
|
|
19393
|
+
# fallback to numpy invert of 3x3, then slice
|
|
19394
|
+
Hc = np.eye(3, dtype=np.float32)
|
|
19395
|
+
Hc[:2, :] = A23.astype(np.float32)
|
|
19396
|
+
Hinv = np.linalg.inv(Hc)
|
|
19397
|
+
invA23 = Hinv[:2, :].astype(np.float32)
|
|
19398
|
+
|
|
19399
|
+
raw_mask = cv2.warpAffine(
|
|
19400
|
+
rej_mask_ref.astype(np.uint8),
|
|
19401
|
+
invA23,
|
|
19402
|
+
(Wraw, Hraw),
|
|
19403
|
+
flags=cv2.INTER_NEAREST,
|
|
19404
|
+
borderMode=cv2.BORDER_CONSTANT,
|
|
19405
|
+
borderValue=0
|
|
19406
|
+
).astype(bool)
|
|
19407
|
+
|
|
19408
|
+
if img_data.ndim == 2:
|
|
19409
|
+
img_data[raw_mask] = 0.0
|
|
19410
|
+
else:
|
|
19411
|
+
img_data[raw_mask, :] = 0.0
|
|
19412
|
+
return img_data
|
|
18902
19413
|
|
|
18903
19414
|
# ---- main loop over ENTRIES ----
|
|
18904
19415
|
# each entry: {"orig": <original path>, "aligned": <aligned path>, "chan": "R"/"G"/None}
|
|
@@ -18995,50 +19506,16 @@ class StackingSuiteDialog(QDialog):
|
|
|
18995
19506
|
f"canvas {int(ref_W * scale_factor)}×{int(ref_H * scale_factor)} @ {scale_factor}×"
|
|
18996
19507
|
)
|
|
18997
19508
|
|
|
18998
|
-
# ---- apply per-file rejections (
|
|
18999
|
-
|
|
19000
|
-
|
|
19001
|
-
|
|
19002
|
-
|
|
19003
|
-
|
|
19004
|
-
|
|
19005
|
-
|
|
19006
|
-
|
|
19007
|
-
|
|
19008
|
-
offsets = [(dx, dy) for dx in range(-r, r + 1) for dy in range(-r, r + 1)]
|
|
19009
|
-
if dilate_shape.startswith("dia"):
|
|
19010
|
-
offsets = [(dx, dy) for (dx, dy) in offsets if (abs(dx) + abs(dy) <= r)]
|
|
19011
|
-
|
|
19012
|
-
# after optional channel extraction, img_data may be 2D
|
|
19013
|
-
if img_data.ndim == 2:
|
|
19014
|
-
Hraw, Wraw = img_data.shape
|
|
19015
|
-
else:
|
|
19016
|
-
Hraw, Wraw = img_data.shape[0], img_data.shape[1]
|
|
19017
|
-
|
|
19018
|
-
if pixels_are_registered:
|
|
19019
|
-
# Directly zero registered pixels
|
|
19020
|
-
for (x_r, y_r) in coords_for_this_file:
|
|
19021
|
-
for (ox, oy) in offsets:
|
|
19022
|
-
xr, yr = x_r + ox, y_r + oy
|
|
19023
|
-
if 0 <= xr < Wraw and 0 <= yr < Hraw:
|
|
19024
|
-
if img_data.ndim == 2:
|
|
19025
|
-
img_data[yr, xr] = 0.0
|
|
19026
|
-
else:
|
|
19027
|
-
img_data[yr, xr, :] = 0.0
|
|
19028
|
-
else:
|
|
19029
|
-
# Back-project via inverse affine (H_canvas)
|
|
19030
|
-
Hinv = np.linalg.inv(H_canvas)
|
|
19031
|
-
for (x_r, y_r) in coords_for_this_file:
|
|
19032
|
-
for (ox, oy) in offsets:
|
|
19033
|
-
xr, yr = x_r + ox, y_r + oy
|
|
19034
|
-
v = Hinv @ np.array([xr, yr, 1.0], np.float32)
|
|
19035
|
-
x_raw = int(round(v[0] / max(v[2], 1e-8)))
|
|
19036
|
-
y_raw = int(round(v[1] / max(v[2], 1e-8)))
|
|
19037
|
-
if 0 <= x_raw < Wraw and 0 <= y_raw < Hraw:
|
|
19038
|
-
if img_data.ndim == 2:
|
|
19039
|
-
img_data[y_raw, x_raw] = 0.0
|
|
19040
|
-
else:
|
|
19041
|
-
img_data[y_raw, x_raw, :] = 0.0
|
|
19509
|
+
# ---- apply per-file rejections (FAST: tile masks -> full mask -> optional warp) ----
|
|
19510
|
+
rej_mask_ref = _build_rej_mask_ref(aligned_file)
|
|
19511
|
+
if rej_mask_ref is not None:
|
|
19512
|
+
A23_for_mask = H_canvas[:2, :] # raw->ref affine (or identity)
|
|
19513
|
+
img_data = _apply_rejections_to_img(
|
|
19514
|
+
img_data=img_data,
|
|
19515
|
+
rej_mask_ref=rej_mask_ref,
|
|
19516
|
+
pixels_are_registered=pixels_are_registered,
|
|
19517
|
+
A23=A23_for_mask
|
|
19518
|
+
)
|
|
19042
19519
|
|
|
19043
19520
|
# ---- deposit ----
|
|
19044
19521
|
A23 = H_canvas[:2, :]
|
|
@@ -19074,24 +19551,24 @@ class StackingSuiteDialog(QDialog):
|
|
|
19074
19551
|
out_path_orig = self._build_out(self._master_light_dir(), base_stem, "fit")
|
|
19075
19552
|
|
|
19076
19553
|
hdr_orig = hdr.copy() if hdr is not None else fits.Header()
|
|
19077
|
-
hdr_orig["IMAGETYP"]
|
|
19554
|
+
hdr_orig["IMAGETYP"] = "MASTER STACK - DRIZZLE"
|
|
19078
19555
|
hdr_orig["DRIZFACTOR"] = (float(scale_factor), "Drizzle scale factor")
|
|
19079
|
-
hdr_orig["DROPFRAC"]
|
|
19080
|
-
hdr_orig["CREATOR"]
|
|
19081
|
-
hdr_orig["DATE-OBS"]
|
|
19556
|
+
hdr_orig["DROPFRAC"] = (float(drop_shrink), "Drizzle drop shrink/pixfrac")
|
|
19557
|
+
hdr_orig["CREATOR"] = "SetiAstroSuite"
|
|
19558
|
+
hdr_orig["DATE-OBS"] = datetime.utcnow().isoformat()
|
|
19082
19559
|
|
|
19083
19560
|
n_frames = int(len(file_list))
|
|
19084
19561
|
hdr_orig["NCOMBINE"] = (n_frames, "Number of frames combined")
|
|
19085
|
-
hdr_orig["NSTACK"]
|
|
19562
|
+
hdr_orig["NSTACK"] = (n_frames, "Alias of NCOMBINE (SetiAstro)")
|
|
19086
19563
|
|
|
19087
19564
|
if final_drizzle.ndim == 2:
|
|
19088
|
-
hdr_orig["NAXIS"]
|
|
19565
|
+
hdr_orig["NAXIS"] = 2
|
|
19089
19566
|
hdr_orig["NAXIS1"] = final_drizzle.shape[1]
|
|
19090
19567
|
hdr_orig["NAXIS2"] = final_drizzle.shape[0]
|
|
19091
19568
|
if "NAXIS3" in hdr_orig:
|
|
19092
19569
|
del hdr_orig["NAXIS3"]
|
|
19093
19570
|
else:
|
|
19094
|
-
hdr_orig["NAXIS"]
|
|
19571
|
+
hdr_orig["NAXIS"] = 3
|
|
19095
19572
|
hdr_orig["NAXIS1"] = final_drizzle.shape[1]
|
|
19096
19573
|
hdr_orig["NAXIS2"] = final_drizzle.shape[0]
|
|
19097
19574
|
hdr_orig["NAXIS3"] = final_drizzle.shape[2]
|
|
@@ -19118,7 +19595,7 @@ class StackingSuiteDialog(QDialog):
|
|
|
19118
19595
|
rect_override=rect_override
|
|
19119
19596
|
)
|
|
19120
19597
|
hdr_crop["NCOMBINE"] = (n_frames, "Number of frames combined")
|
|
19121
|
-
hdr_crop["NSTACK"]
|
|
19598
|
+
hdr_crop["NSTACK"] = (n_frames, "Alias of NCOMBINE (SetiAstro)")
|
|
19122
19599
|
|
|
19123
19600
|
is_mono_crop = (cropped_drizzle.ndim == 2)
|
|
19124
19601
|
display_group_driz_crop = self._label_with_dims(group_key, cropped_drizzle.shape[1], cropped_drizzle.shape[0])
|
|
@@ -19140,6 +19617,8 @@ class StackingSuiteDialog(QDialog):
|
|
|
19140
19617
|
self._autocrop_outputs.append((group_key, out_path_crop))
|
|
19141
19618
|
log(f"✂️ Drizzle (auto-cropped) saved: {out_path_crop}")
|
|
19142
19619
|
|
|
19620
|
+
return True
|
|
19621
|
+
|
|
19143
19622
|
def _load_sasd_v2(self, path: str):
|
|
19144
19623
|
"""
|
|
19145
19624
|
Returns (ref_H, ref_W, xforms) where xforms maps
|
|
@@ -19252,7 +19731,8 @@ class StackingSuiteDialog(QDialog):
|
|
|
19252
19731
|
algo_override: str | None = None
|
|
19253
19732
|
):
|
|
19254
19733
|
import errno
|
|
19255
|
-
|
|
19734
|
+
collect_per_file = bool(self._get_drizzle_enabled())
|
|
19735
|
+
per_file_rejections = {f: [] for f in file_list} if collect_per_file else None
|
|
19256
19736
|
log = status_cb or (lambda *_: None)
|
|
19257
19737
|
log(f"Starting integration for group '{group_key}' with {len(file_list)} files.")
|
|
19258
19738
|
if not file_list:
|
|
@@ -19564,10 +20044,12 @@ class StackingSuiteDialog(QDialog):
|
|
|
19564
20044
|
rej_count[y0:y1, x0:x1] += trm.sum(axis=0).astype(np.uint16)
|
|
19565
20045
|
|
|
19566
20046
|
# per-file coords (existing behavior)
|
|
19567
|
-
|
|
19568
|
-
|
|
19569
|
-
|
|
19570
|
-
|
|
20047
|
+
if collect_per_file:
|
|
20048
|
+
for i, fpath in enumerate(file_list):
|
|
20049
|
+
m = trm[i]
|
|
20050
|
+
if np.any(m):
|
|
20051
|
+
# store as a tile mask, not coord list
|
|
20052
|
+
per_file_rejections[fpath].append((x0, y0, m.copy()))
|
|
19571
20053
|
|
|
19572
20054
|
# perf log
|
|
19573
20055
|
dt = time.perf_counter() - t0
|