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.
@@ -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: (3, H, W) <-- channels-first for FITS
2483
- - Optional EXTNAME=REJ_COMB: uint8 (0/1) combined rejection mask
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) # becomes (H, W)
2494
- elif data.shape[-1] in (3, 4): # RGB or RGBA
2495
- data = np.transpose(data, (2, 0, 1)) # (C, H, W)
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]) # width
2519
- H["NAXIS2"] = int(data.shape[0]) # height
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) # width
2525
- H["NAXIS2"] = int(Hh) # height
2526
- H["NAXIS3"] = int(C) # channels/planes
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). Convert types safely.
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"] = "Combined rejection mask (any algorithm / any frame)"
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
- #if rej_frac is not None:
2543
- # rej_frac_2d = np.asarray(rej_frac, dtype=np.float32)
2544
- # if rej_frac_2d.ndim != 2:
2545
- # raise ValueError(f"REJ_FRAC must be 2D, got {rej_frac_2d.shape}")
2546
- # h = fits.Header()
2547
- # h["EXTNAME"] = "REJ_FRAC"
2548
- # h["COMMENT"] = "Per-pixel fraction of frames rejected [0..1]"
2549
- # hdul.append(fits.ImageHDU(data=rej_frac_2d, header=h))
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
- sub = src.read_tile(y0, y1, x0, x1)
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, out_file):
17109
+ def save_rejection_map_sasr(self, rejection_map: dict, out_path: str):
16928
17110
  """
16929
- Writes the per-file rejection map to a custom text file.
16930
- Format:
16931
- FILE: path/to/file1
16932
- x1, y1
16933
- x2, y2
16934
-
16935
- FILE: path/to/file2
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
- with open(out_file, "w") as f:
16939
- for fpath, coords_list in rejection_map.items():
16940
- f.write(f"FILE: {fpath}\n")
16941
- for (x, y) in coords_list:
16942
- # Convert to Python int in case they're NumPy int64
16943
- f.write(f"{int(x)}, {int(y)}\n")
16944
- f.write("\n") # blank line to separate blocks
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
- Reads a .sasr text file and rebuilds the rejection map dictionary.
16949
- Returns a dict { fpath: [(x, y), (x, y), ...], ... }
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
- with open(in_file, "r") as f:
16953
- content = f.read().strip()
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
- x = int(x_str.strip())
16972
- y = int(y_str.strip())
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
- rejections[raw_path] = coords
16975
- return rejections
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
- if maps and save_layers:
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 = maps.get("any"),
17149
- rej_frac= maps.get("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
- group_integration_data[group_key] = {
17485
- "integrated_image": integrated_image,
17486
- "rejection_map": rejection_map,
17487
- "n_frames": n_frames_group,
17488
- "drizzled": True
17489
- }
17490
- else:
17491
- group_integration_data[group_key] = {
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
- self.drizzle_stack_one_group(
17553
- group_key=group_key,
17554
- file_list=file_list,
17555
- entries=entries_by_group.get(group_key, []),
17556
- transforms_dict=transforms_dict,
17557
- frame_weights=frame_weights,
17558
- scale_factor=scale_factor,
17559
- drop_shrink=drop_shrink,
17560
- rejection_map=rejections_for_group,
17561
- autocrop_enabled=autocrop_enabled,
17562
- rect_override=global_rect,
17563
- status_cb=log
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
- for i, fpath in enumerate(file_list):
17916
- ys, xs = np.where(trm[i])
17917
- if ys.size:
17918
- per_file_rejections[fpath].extend(zip(x0 + xs, y0 + ys))
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
- total_rej = sum(len(v) for v in (rejection_map or {}).values())
18854
- log(f"🔭 Drizzle stacking for group '{group_key}' with {total_rej} total rejected pixels.")
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 = np.zeros((out_h, out_w) if is_mono else (out_h, out_w, c), dtype=self._dtype())
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 = finalize_drizzle_2d if is_mono else finalize_drizzle_3d
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 (lookup by ALIGNED file) ----
18999
- if rejection_map and aligned_file in rejection_map:
19000
- coords_for_this_file = rejection_map.get(aligned_file, [])
19001
- if coords_for_this_file:
19002
- dilate_px = int(self.settings.value("stacking/reject_dilate_px", 0, type=int))
19003
- dilate_shape = (self.settings.value("stacking/reject_dilate_shape", "square", type=str) or "square").lower()
19004
-
19005
- offsets = [(0, 0)]
19006
- if dilate_px > 0:
19007
- r = dilate_px
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"] = "MASTER STACK - DRIZZLE"
19554
+ hdr_orig["IMAGETYP"] = "MASTER STACK - DRIZZLE"
19078
19555
  hdr_orig["DRIZFACTOR"] = (float(scale_factor), "Drizzle scale factor")
19079
- hdr_orig["DROPFRAC"] = (float(drop_shrink), "Drizzle drop shrink/pixfrac")
19080
- hdr_orig["CREATOR"] = "SetiAstroSuite"
19081
- hdr_orig["DATE-OBS"] = datetime.utcnow().isoformat()
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"] = (n_frames, "Alias of NCOMBINE (SetiAstro)")
19562
+ hdr_orig["NSTACK"] = (n_frames, "Alias of NCOMBINE (SetiAstro)")
19086
19563
 
19087
19564
  if final_drizzle.ndim == 2:
19088
- hdr_orig["NAXIS"] = 2
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"] = 3
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"] = (n_frames, "Alias of NCOMBINE (SetiAstro)")
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
- for i, fpath in enumerate(file_list):
19568
- ys, xs = np.where(trm[i])
19569
- if ys.size:
19570
- per_file_rejections[fpath].extend(zip(x0 + xs, y0 + ys))
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