setiastrosuitepro 1.7.3__py3-none-any.whl → 1.7.5__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.

Files changed (52) hide show
  1. setiastro/images/clonestamp.png +0 -0
  2. setiastro/saspro/__init__.py +15 -4
  3. setiastro/saspro/__main__.py +23 -5
  4. setiastro/saspro/_generated/build_info.py +2 -2
  5. setiastro/saspro/abe.py +4 -4
  6. setiastro/saspro/autostretch.py +29 -18
  7. setiastro/saspro/blemish_blaster.py +54 -14
  8. setiastro/saspro/clone_stamp.py +753 -0
  9. setiastro/saspro/gui/main_window.py +27 -6
  10. setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
  11. setiastro/saspro/gui/mixins/toolbar_mixin.py +10 -15
  12. setiastro/saspro/legacy/numba_utils.py +301 -119
  13. setiastro/saspro/numba_utils.py +998 -270
  14. setiastro/saspro/ops/settings.py +6 -6
  15. setiastro/saspro/pixelmath.py +1 -1
  16. setiastro/saspro/planetprojection.py +310 -105
  17. setiastro/saspro/resources.py +2 -0
  18. setiastro/saspro/sfcc.py +14 -8
  19. setiastro/saspro/stacking_suite.py +413 -174
  20. setiastro/saspro/subwindow.py +28 -35
  21. setiastro/saspro/translations/all_source_strings.json +2 -2
  22. setiastro/saspro/translations/ar_translations.py +3 -3
  23. setiastro/saspro/translations/de_translations.py +2 -2
  24. setiastro/saspro/translations/es_translations.py +2 -2
  25. setiastro/saspro/translations/fr_translations.py +2 -2
  26. setiastro/saspro/translations/hi_translations.py +2 -2
  27. setiastro/saspro/translations/it_translations.py +2 -2
  28. setiastro/saspro/translations/ja_translations.py +2 -2
  29. setiastro/saspro/translations/pt_translations.py +2 -2
  30. setiastro/saspro/translations/ru_translations.py +2 -2
  31. setiastro/saspro/translations/saspro_ar.ts +2 -2
  32. setiastro/saspro/translations/saspro_de.ts +4 -4
  33. setiastro/saspro/translations/saspro_es.ts +2 -2
  34. setiastro/saspro/translations/saspro_fr.ts +2 -2
  35. setiastro/saspro/translations/saspro_hi.ts +2 -2
  36. setiastro/saspro/translations/saspro_it.ts +4 -4
  37. setiastro/saspro/translations/saspro_ja.ts +2 -2
  38. setiastro/saspro/translations/saspro_pt.ts +2 -2
  39. setiastro/saspro/translations/saspro_ru.ts +2 -2
  40. setiastro/saspro/translations/saspro_sw.ts +2 -2
  41. setiastro/saspro/translations/saspro_uk.ts +2 -2
  42. setiastro/saspro/translations/saspro_zh.ts +2 -2
  43. setiastro/saspro/translations/sw_translations.py +2 -2
  44. setiastro/saspro/translations/uk_translations.py +2 -2
  45. setiastro/saspro/translations/zh_translations.py +2 -2
  46. setiastro/saspro/window_shelf.py +62 -1
  47. {setiastrosuitepro-1.7.3.dist-info → setiastrosuitepro-1.7.5.dist-info}/METADATA +1 -1
  48. {setiastrosuitepro-1.7.3.dist-info → setiastrosuitepro-1.7.5.dist-info}/RECORD +52 -50
  49. {setiastrosuitepro-1.7.3.dist-info → setiastrosuitepro-1.7.5.dist-info}/entry_points.txt +1 -1
  50. {setiastrosuitepro-1.7.3.dist-info → setiastrosuitepro-1.7.5.dist-info}/WHEEL +0 -0
  51. {setiastrosuitepro-1.7.3.dist-info → setiastrosuitepro-1.7.5.dist-info}/licenses/LICENSE +0 -0
  52. {setiastrosuitepro-1.7.3.dist-info → setiastrosuitepro-1.7.5.dist-info}/licenses/license.txt +0 -0
@@ -84,7 +84,7 @@ from setiastro.saspro.legacy.numba_utils import (
84
84
  finalize_drizzle_2d,
85
85
  finalize_drizzle_3d,
86
86
  )
87
- from setiastro.saspro.numba_utils import (
87
+ from setiastro.saspro.legacy.numba_utils import (
88
88
  bulk_cosmetic_correction_numba,
89
89
  drizzle_deposit_numba_naive,
90
90
  drizzle_deposit_color_naive,
@@ -2295,6 +2295,36 @@ 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(rejection_map: dict, ref_H: int, ref_W: int, n_frames: int,
2300
+ *, comb_threshold_frac: float = 0.02):
2301
+ """
2302
+ Returns:
2303
+ rej_cnt : (H,W) uint16 (or uint32 if needed)
2304
+ rej_frac : (H,W) float32 in [0..1]
2305
+ rej_any : (H,W) bool (thresholded, not "ever rejected")
2306
+ """
2307
+ if n_frames <= 0:
2308
+ raise ValueError("n_frames must be > 0")
2309
+
2310
+ # Use uint32 if you might exceed 65535 rejections (rare)
2311
+ rej_cnt = np.zeros((ref_H, ref_W), dtype=np.uint32)
2312
+
2313
+ # rejection_map keys are per-file, values are coords in REGISTERED space
2314
+ for _fpath, coords in (rejection_map or {}).items():
2315
+ for (x, y) in coords:
2316
+ xi = int(x); yi = int(y)
2317
+ if 0 <= xi < ref_W and 0 <= yi < ref_H:
2318
+ rej_cnt[yi, xi] += 1
2319
+
2320
+ rej_frac = (rej_cnt.astype(np.float32) / float(n_frames))
2321
+
2322
+ # “combined mask” becomes “rejected in >= threshold% of frames”
2323
+ rej_any = (rej_frac >= float(comb_threshold_frac))
2324
+
2325
+ return rej_cnt, rej_frac, rej_any
2326
+
2327
+
2298
2328
  class _Responder(QObject):
2299
2329
  finished = pyqtSignal(object) # emits the edited dict or None
2300
2330
 
@@ -2472,36 +2502,38 @@ def _save_master_with_rejection_layers(
2472
2502
  hdr: "fits.Header",
2473
2503
  out_path: str,
2474
2504
  *,
2475
- rej_any: "np.ndarray | None" = None, # 2D bool
2505
+ rej_any: "np.ndarray | None" = None, # 2D bool (recommended: thresholded)
2476
2506
  rej_frac: "np.ndarray | None" = None, # 2D float32 [0..1]
2507
+ rej_cnt: "np.ndarray | None" = None, # 2D uint16/uint32 counts
2477
2508
  ):
2478
2509
  """
2479
2510
  Writes a MEF (multi-extension FITS) file:
2480
2511
  - Primary HDU: the master image (2D or 3D) as float32
2481
2512
  * 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
2513
+ * Color: (C, H, W) channels-first for FITS
2514
+ - Optional EXTNAME=REJ_COMB: uint8 (0/1) thresholded rejection mask
2515
+ - Optional EXTNAME=REJ_FRAC: float32 fraction-of-frames rejected per pixel [0..1]
2516
+ - Optional EXTNAME=REJ_CNT : uint16/uint32 count of frames rejected per pixel
2485
2517
  """
2518
+
2519
+ from astropy.io import fits
2520
+ from datetime import datetime
2521
+
2486
2522
  # --- sanitize/shape primary data ---
2487
2523
  data = np.asarray(img_array, dtype=np.float32, order="C")
2488
2524
 
2489
2525
  # If channels-last, move to channels-first for FITS
2490
2526
  if data.ndim == 3:
2491
- # squeeze accidental singleton channels
2492
2527
  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.
2528
+ data = np.squeeze(data, axis=-1) # (H, W)
2529
+ elif data.shape[-1] in (3, 4):
2530
+ data = np.transpose(data, (2, 0, 1)) # (C, H, W)
2497
2531
 
2498
- # After squeeze/transpose, re-evaluate dims
2499
2532
  if data.ndim not in (2, 3):
2500
2533
  raise ValueError(f"Unsupported master image shape for FITS: {data.shape}")
2501
2534
 
2502
2535
  # --- clone + annotate header, and align NAXIS* with 'data' ---
2503
2536
  H = (hdr.copy() if hdr is not None else fits.Header())
2504
- # purge prior NAXIS keys to avoid conflicts after transpose/squeeze
2505
2537
  for k in ("NAXIS", "NAXIS1", "NAXIS2", "NAXIS3", "NAXIS4"):
2506
2538
  if k in H:
2507
2539
  del H[k]
@@ -2512,41 +2544,50 @@ def _save_master_with_rejection_layers(
2512
2544
  H["CREATOR"] = "SetiAstroSuite"
2513
2545
  H["DATE-OBS"] = datetime.utcnow().isoformat()
2514
2546
 
2515
- # Fill NAXIS* to match data (optional; Astropy will infer if omitted)
2516
2547
  if data.ndim == 2:
2517
2548
  H["NAXIS"] = 2
2518
- H["NAXIS1"] = int(data.shape[1]) # width
2519
- H["NAXIS2"] = int(data.shape[0]) # height
2549
+ H["NAXIS1"] = int(data.shape[1])
2550
+ H["NAXIS2"] = int(data.shape[0])
2520
2551
  else:
2521
- # data.shape == (C, H, W)
2522
2552
  C, Hh, Ww = data.shape
2523
2553
  H["NAXIS"] = 3
2524
- H["NAXIS1"] = int(Ww) # width
2525
- H["NAXIS2"] = int(Hh) # height
2526
- H["NAXIS3"] = int(C) # channels/planes
2554
+ H["NAXIS1"] = int(Ww)
2555
+ H["NAXIS2"] = int(Hh)
2556
+ H["NAXIS3"] = int(C)
2527
2557
 
2528
- # --- build HDU list ---
2529
2558
  prim = fits.PrimaryHDU(data=data, header=H)
2530
2559
  hdul = [prim]
2531
2560
 
2532
- # Optional layers: must be 2D (H, W). Convert types safely.
2561
+ # --- Optional layers: all must be 2D (H, W) in REGISTERED space ---
2533
2562
  if rej_any is not None:
2534
2563
  rej_any_2d = np.asarray(rej_any, dtype=bool)
2535
2564
  if rej_any_2d.ndim != 2:
2536
2565
  raise ValueError(f"REJ_COMB must be 2D, got {rej_any_2d.shape}")
2537
2566
  h = fits.Header()
2538
2567
  h["EXTNAME"] = "REJ_COMB"
2539
- h["COMMENT"] = "Combined rejection mask (any algorithm / any frame)"
2568
+ h["COMMENT"] = "Thresholded rejection mask (1=frequently rejected, not merely 'ever')"
2540
2569
  hdul.append(fits.ImageHDU(data=rej_any_2d.astype(np.uint8, copy=False), header=h))
2541
2570
 
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))
2571
+ if rej_frac is not None:
2572
+ rej_frac_2d = np.asarray(rej_frac, dtype=np.float32)
2573
+ if rej_frac_2d.ndim != 2:
2574
+ raise ValueError(f"REJ_FRAC must be 2D, got {rej_frac_2d.shape}")
2575
+ h = fits.Header()
2576
+ h["EXTNAME"] = "REJ_FRAC"
2577
+ h["COMMENT"] = "Per-pixel fraction of frames rejected [0..1]"
2578
+ hdul.append(fits.ImageHDU(data=rej_frac_2d, header=h))
2579
+
2580
+ if rej_cnt is not None:
2581
+ rej_cnt_2d = np.asarray(rej_cnt)
2582
+ if rej_cnt_2d.ndim != 2:
2583
+ raise ValueError(f"REJ_CNT must be 2D, got {rej_cnt_2d.shape}")
2584
+ # keep integer type; clamp to uint16 if you prefer smaller files
2585
+ if rej_cnt_2d.dtype not in (np.uint16, np.uint32, np.int32, np.int64):
2586
+ rej_cnt_2d = rej_cnt_2d.astype(np.uint32, copy=False)
2587
+ h = fits.Header()
2588
+ h["EXTNAME"] = "REJ_CNT"
2589
+ h["COMMENT"] = "Per-pixel count of frames rejected"
2590
+ hdul.append(fits.ImageHDU(data=rej_cnt_2d, header=h))
2550
2591
 
2551
2592
  fits.HDUList(hdul).writeto(out_path, overwrite=True)
2552
2593
 
@@ -6522,6 +6563,18 @@ class StackingSuiteDialog(QDialog):
6522
6563
  w.blockSignals(True); w.setValue(v); w.blockSignals(False)
6523
6564
 
6524
6565
  def _get_drizzle_scale(self) -> float:
6566
+ # UI combo wins if it exists
6567
+ combo = getattr(self, "drizzle_scale_combo", None)
6568
+ if combo is not None:
6569
+ try:
6570
+ txt = str(combo.currentText()).strip().lower()
6571
+ if txt.endswith("x"):
6572
+ txt = txt[:-1]
6573
+ return float(txt)
6574
+ except Exception:
6575
+ pass
6576
+
6577
+ # fallback to settings
6525
6578
  val = self.settings.value("stacking/drizzle_scale", "2x")
6526
6579
  if isinstance(val, (int, float)):
6527
6580
  return float(val)
@@ -6536,6 +6589,7 @@ class StackingSuiteDialog(QDialog):
6536
6589
  return 2.0
6537
6590
 
6538
6591
 
6592
+
6539
6593
  def _set_drizzle_scale(self, r: float | str) -> None:
6540
6594
  if isinstance(r, str):
6541
6595
  try: r = float(r.rstrip("xX"))
@@ -7202,6 +7256,8 @@ class StackingSuiteDialog(QDialog):
7202
7256
  )
7203
7257
  main_layout.addWidget(self.clear_master_flat_selection_btn)
7204
7258
  self.override_dark_combo.currentIndexChanged[int].connect(self.override_selected_master_dark_for_flats)
7259
+ # Connect selection change to update combobox
7260
+ self.flat_tree.itemSelectionChanged.connect(self._on_flat_tree_selection_changed)
7205
7261
  self.update_override_dark_combo()
7206
7262
  self.rebuild_flat_tree()
7207
7263
 
@@ -8846,8 +8902,8 @@ class StackingSuiteDialog(QDialog):
8846
8902
  tab.setLayout(layout)
8847
8903
 
8848
8904
  self.drizzle_checkbox.setChecked(self.settings.value("stacking/drizzle_enabled", False, type=bool))
8849
- self.drizzle_scale_combo.setCurrentText(self.settings.value("stacking/drizzle_scale", "2x", type=str))
8850
- self.drizzle_drop_shrink_spin.setValue(self.settings.value("stacking/drizzle_drop", 0.65, type=float))
8905
+ self.drizzle_scale_combo.setCurrentText(f"{int(self._get_drizzle_scale())}x")
8906
+ self.drizzle_drop_shrink_spin.setValue(self._get_drizzle_pixfrac())
8851
8907
 
8852
8908
  drizzle_on = self.settings.value("stacking/drizzle_enabled", False, type=bool)
8853
8909
  self.cfa_drizzle_cb.setEnabled(drizzle_on)
@@ -8901,6 +8957,7 @@ class StackingSuiteDialog(QDialog):
8901
8957
  # wire it
8902
8958
  self.comet_cb.toggled.connect(lambda _v: self._refresh_comet_starless_enable())
8903
8959
  self.comet_save_starless_cb.toggled.connect(lambda _v: self._refresh_comet_starless_enable()) # optional belt/suspenders
8960
+ self.drizzle_drop_shrink_spin.valueChanged.connect(self._on_drizzle_param_changed)
8904
8961
 
8905
8962
  # run once AFTER you restore all initial states from settings
8906
8963
  self._refresh_comet_starless_enable()
@@ -8963,11 +9020,16 @@ class StackingSuiteDialog(QDialog):
8963
9020
  self._update_drizzle_summary_columns()
8964
9021
 
8965
9022
  def _on_drizzle_param_changed(self, *_):
8966
- # Persist drizzle params whenever changed
8967
- self.settings.setValue("stacking/drizzle_scale", self.drizzle_scale_combo.currentText())
8968
- self.settings.setValue("stacking/drizzle_drop", float(self.drizzle_drop_shrink_spin.value()))
8969
- # If you reflect params to tree rows, update here:
8970
- # self._refresh_reg_tree_drizzle_column()
9023
+ # persist scale from UI
9024
+ if hasattr(self, "drizzle_scale_combo"):
9025
+ self._set_drizzle_scale(self.drizzle_scale_combo.currentText())
9026
+
9027
+ # (optional) persist pixfrac too if you want “global controls are canonical”
9028
+ if hasattr(self, "drizzle_drop_shrink_spin"):
9029
+ self._set_drizzle_pixfrac(float(self.drizzle_drop_shrink_spin.value()))
9030
+
9031
+ self.settings.sync()
9032
+ self._update_drizzle_summary_columns() # if you show “1x / 0.65” in the tree
8971
9033
 
8972
9034
  def _on_star_trail_toggled(self, enabled: bool):
8973
9035
  """
@@ -11703,6 +11765,51 @@ class StackingSuiteDialog(QDialog):
11703
11765
 
11704
11766
 
11705
11767
 
11768
+ def _on_flat_tree_selection_changed(self):
11769
+ """Update the override_dark_combo to reflect the value stored in the selected row."""
11770
+ items = self.flat_tree.selectedItems()
11771
+ if not items:
11772
+ return
11773
+
11774
+ # Get the first top-level item (group row, not a leaf)
11775
+ selected_item = None
11776
+ for it in items:
11777
+ if not it.parent(): # Top-level item (group row)
11778
+ selected_item = it
11779
+ break
11780
+
11781
+ if not selected_item:
11782
+ return
11783
+
11784
+ # Read the stored value from column 2
11785
+ ud = selected_item.data(2, Qt.ItemDataRole.UserRole)
11786
+
11787
+ # Block signals to avoid triggering override_selected_master_dark_for_flats
11788
+ self.override_dark_combo.blockSignals(True)
11789
+ try:
11790
+ # Find the matching index in the combobox
11791
+ if ud is None:
11792
+ # "None (Use Auto-Select)" should be at index 0
11793
+ self.override_dark_combo.setCurrentIndex(0)
11794
+ elif ud == "__NO_DARK__":
11795
+ # "None (Use no Dark to Calibrate)" should be at index 1
11796
+ self.override_dark_combo.setCurrentIndex(1)
11797
+ else:
11798
+ # Find the item with matching userData (path)
11799
+ found_idx = -1
11800
+ for i in range(self.override_dark_combo.count()):
11801
+ item_ud = self.override_dark_combo.itemData(i)
11802
+ if item_ud == ud:
11803
+ found_idx = i
11804
+ break
11805
+ if found_idx >= 0:
11806
+ self.override_dark_combo.setCurrentIndex(found_idx)
11807
+ else:
11808
+ # If not found, default to index 0
11809
+ self.override_dark_combo.setCurrentIndex(0)
11810
+ finally:
11811
+ self.override_dark_combo.blockSignals(False)
11812
+
11706
11813
  def override_selected_master_dark_for_flats(self, idx: int):
11707
11814
  """Apply combo choice to selected flat groups; stores path/token in row + dict."""
11708
11815
  items = self.flat_tree.selectedItems()
@@ -15752,7 +15859,7 @@ class StackingSuiteDialog(QDialog):
15752
15859
  filters = "TIFF (*.tif);;PNG (*.png);;JPEG (*.jpg *.jpeg);;FITS (*.fits);;XISF (*.xisf)"
15753
15860
  path, chosen_filter = QFileDialog.getSaveFileName(
15754
15861
  self, "Save Star-Trail Image",
15755
- os.path.join(self.stacking_directory, default_name),
15862
+ os.path.join(self._master_light_dir(), default_name),
15756
15863
  "TIFF (*.tif);;PNG (*.png);;JPEG (*.jpg *.jpeg);;FITS (*.fits);;XISF (*.xisf)"
15757
15864
  )
15758
15865
  if not path:
@@ -15879,8 +15986,11 @@ class StackingSuiteDialog(QDialog):
15879
15986
  """
15880
15987
  Take aligned OSC frames and produce per-band aligned mono FITS.
15881
15988
 
15882
- Uses the same classifier and grouping behavior as _split_dual_band_osc,
15883
- but operates on the *aligned* frames.
15989
+ IMPORTANT (for drizzle correctness):
15990
+ - We also populate self._split_drizzle_info so drizzle can deposit from the
15991
+ *parent original* pixels while using the same transforms solved from originals.
15992
+ Mapping:
15993
+ split_aligned_path -> {"orig": <parent original>, "chan": "R"|"G"|None}
15884
15994
 
15885
15995
  Returns a new light_files-style dict:
15886
15996
  { "Ha - ...": [aligned_Ha_*.fit...],
@@ -15895,18 +16005,35 @@ class StackingSuiteDialog(QDialog):
15895
16005
  out_dir = os.path.join(self.stacking_directory, "DualBand_Split")
15896
16006
  os.makedirs(out_dir, exist_ok=True)
15897
16007
 
16008
+ # split_aligned_path -> {"orig": <parent original>, "chan": "R"|"G"|None}
16009
+ if not hasattr(self, "_split_drizzle_info") or self._split_drizzle_info is None:
16010
+ self._split_drizzle_info = {}
16011
+ else:
16012
+ # Clear any previous run’s mapping to avoid mixing sessions
16013
+ self._split_drizzle_info.clear()
16014
+
16015
+ split_info = self._split_drizzle_info
16016
+
15898
16017
  # Collect per-band file lists (some may already be mono NB)
15899
16018
  ha_files, sii_files, oiii_files, hb_files = [], [], [], []
15900
- inherit_map: dict[str, set[str]] = {} # new_group_key -> set(parent_group)
15901
16019
  parent_of: dict[str, str] = {} # new_path_or_existing -> parent_group
15902
16020
 
15903
16021
  # Snapshot old drizzle configs so we can inherit to the new NB groups
15904
16022
  old_drizzle = dict(getattr(self, "per_group_drizzle", {}))
15905
16023
  selected_groups = set(getattr(self, "_reg_selected_groups", set()))
15906
16024
 
16025
+ # Normalized aligned->original map (should exist from your registration pipeline)
16026
+ oba = getattr(self, "orig_by_aligned", {}) or {}
16027
+
15907
16028
  # --- Pass 1: classify + split each aligned frame ---
15908
16029
  for group, files in aligned_light_files.items():
15909
16030
  for fp in files:
16031
+ fpn = os.path.normpath(fp)
16032
+
16033
+ # Find parent original for this aligned frame (for drizzle)
16034
+ parent_orig = oba.get(fpn) or oba.get(os.path.normpath(fp))
16035
+ parent_orig = os.path.normpath(parent_orig) if parent_orig else None
16036
+
15910
16037
  try:
15911
16038
  with fits.open(fp, memmap=False) as hdul:
15912
16039
  data = hdul[0].data
@@ -15927,16 +16054,12 @@ class StackingSuiteDialog(QDialog):
15927
16054
  # ----------------------------------------
15928
16055
  # Detect layout: mono vs RGB, CHW vs HWC
15929
16056
  # ----------------------------------------
15930
- layout = None
15931
-
15932
16057
  if arr.ndim == 2:
15933
16058
  layout = "MONO"
15934
16059
  elif arr.ndim == 3:
15935
16060
  c0, c1, c2 = arr.shape
15936
- # channels-first (C, H, W)
15937
16061
  if c0 in (1, 3) and c1 > 8 and c2 > 8:
15938
16062
  layout = "CHW"
15939
- # channels-last (H, W, C)
15940
16063
  elif c2 in (1, 3) and c0 > 8 and c1 > 8:
15941
16064
  layout = "HWC"
15942
16065
  else:
@@ -15945,7 +16068,11 @@ class StackingSuiteDialog(QDialog):
15945
16068
  layout = "UNKNOWN"
15946
16069
 
15947
16070
  # ---- MONO narrowband frames (Ha/SII/OIII/Hb) ----
15948
- if layout == "MONO" or (layout == "CHW" and arr.shape[0] == 1) or (layout == "HWC" and arr.shape[-1] == 1):
16071
+ if (
16072
+ layout == "MONO"
16073
+ or (layout == "CHW" and arr.shape[0] == 1)
16074
+ or (layout == "HWC" and arr.shape[-1] == 1)
16075
+ ):
15949
16076
  # Treat as mono NB based on classifier
15950
16077
  if cls == "MONO_HA":
15951
16078
  ha_files.append(fp); parent_of[fp] = group
@@ -15955,7 +16082,14 @@ class StackingSuiteDialog(QDialog):
15955
16082
  oiii_files.append(fp); parent_of[fp] = group
15956
16083
  elif cls == "MONO_HB":
15957
16084
  hb_files.append(fp); parent_of[fp] = group
15958
- # UNKNOWN mono: ignore for NB split
16085
+ else:
16086
+ # Unknown mono: ignore for NB split
16087
+ pass
16088
+
16089
+ # For drizzle mapping: if this is a split pipeline, we still may want
16090
+ # aligned mono to drizzle from parent original (chan None means “use mono as-is”)
16091
+ if parent_orig:
16092
+ split_info[fpn] = {"orig": parent_orig, "chan": None}
15959
16093
  continue
15960
16094
 
15961
16095
  # ---- RGB / OSC case (what we expect for dual-band) ----
@@ -15972,39 +16106,55 @@ class StackingSuiteDialog(QDialog):
15972
16106
  R = arr[..., 0]
15973
16107
  G = arr[..., 1]
15974
16108
  else:
15975
- # layout == "CHW" → (C, H, W)
15976
- # C should be 3
16109
+ # (C, H, W)
15977
16110
  R = arr[0, ...]
15978
16111
  G = arr[1, ...]
15979
16112
 
15980
16113
  base = os.path.splitext(os.path.basename(fp))[0]
15981
16114
 
16115
+ def _map_split(path_written: str, chan: str):
16116
+ """Record drizzle info for this newly created split aligned file."""
16117
+ if parent_orig:
16118
+ split_info[os.path.normpath(path_written)] = {"orig": parent_orig, "chan": chan}
16119
+
15982
16120
  if cls == "DUAL_HA_OIII":
15983
16121
  ha_path = os.path.join(out_dir, f"{base}_Ha.fit")
15984
16122
  oiii_path = os.path.join(out_dir, f"{base}_OIII.fit")
15985
- self._write_band_fit(ha_path, R, hdr, "Ha", src_filter=filt)
16123
+ self._write_band_fit(ha_path, R, hdr, "Ha", src_filter=filt)
15986
16124
  self._write_band_fit(oiii_path, G, hdr, "OIII", src_filter=filt)
16125
+
15987
16126
  ha_files.append(ha_path); parent_of[ha_path] = group
15988
16127
  oiii_files.append(oiii_path); parent_of[oiii_path] = group
15989
16128
 
16129
+ _map_split(ha_path, "R")
16130
+ _map_split(oiii_path, "G")
16131
+
15990
16132
  elif cls == "DUAL_SII_OIII":
15991
16133
  sii_path = os.path.join(out_dir, f"{base}_SII.fit")
15992
16134
  oiii_path = os.path.join(out_dir, f"{base}_OIII.fit")
15993
- self._write_band_fit(sii_path, R, hdr, "SII", src_filter=filt)
16135
+ self._write_band_fit(sii_path, R, hdr, "SII", src_filter=filt)
15994
16136
  self._write_band_fit(oiii_path, G, hdr, "OIII", src_filter=filt)
15995
- sii_files.append(sii_path); parent_of[sii_path] = group
15996
- oiii_files.append(oiii_path); parent_of[oiii_path] = group
16137
+
16138
+ sii_files.append(sii_path); parent_of[sii_path] = group
16139
+ oiii_files.append(oiii_path); parent_of[oiii_path] = group
16140
+
16141
+ _map_split(sii_path, "R")
16142
+ _map_split(oiii_path, "G")
15997
16143
 
15998
16144
  elif cls == "DUAL_SII_HB":
15999
16145
  sii_path = os.path.join(out_dir, f"{base}_SII.fit")
16000
16146
  hb_path = os.path.join(out_dir, f"{base}_Hb.fit")
16001
16147
  self._write_band_fit(sii_path, R, hdr, "SII", src_filter=filt)
16002
16148
  self._write_band_fit(hb_path, G, hdr, "Hb", src_filter=filt)
16149
+
16003
16150
  sii_files.append(sii_path); parent_of[sii_path] = group
16004
16151
  hb_files.append(hb_path); parent_of[hb_path] = group
16005
16152
 
16153
+ _map_split(sii_path, "R")
16154
+ _map_split(hb_path, "G")
16155
+
16006
16156
  else:
16007
- # UNKNOWN dual → ignore, they won't show up in NB groups
16157
+ # UNKNOWN dual → ignore
16008
16158
  continue
16009
16159
 
16010
16160
  # --- Pass 2: group the new files (band + exposure + size), same as before ---
@@ -16020,7 +16170,7 @@ class StackingSuiteDialog(QDialog):
16020
16170
  return f"{band} - ? - ?x?"
16021
16171
 
16022
16172
  new_groups: dict[str, list[str]] = {}
16023
- inherit_map = {}
16173
+ inherit_map: dict[str, set[str]] = {}
16024
16174
 
16025
16175
  for band, flist in (
16026
16176
  ("Ha", ha_files),
@@ -16043,7 +16193,6 @@ class StackingSuiteDialog(QDialog):
16043
16193
  return aligned_light_files
16044
16194
 
16045
16195
  # --- Replace light_files with NB groups & seed drizzle configs ---
16046
-
16047
16196
  self.light_files = new_groups
16048
16197
 
16049
16198
  self.per_group_drizzle = {}
@@ -16087,6 +16236,11 @@ class StackingSuiteDialog(QDialog):
16087
16236
  return new_groups
16088
16237
 
16089
16238
 
16239
+ def _master_light_dir(self) -> str:
16240
+ out_dir = os.path.join(self.stacking_directory, "Master_Light")
16241
+ os.makedirs(out_dir, exist_ok=True)
16242
+ return out_dir
16243
+
16090
16244
  def on_registration_complete(self, success, msg):
16091
16245
 
16092
16246
  self.update_status(self.tr(msg))
@@ -16504,7 +16658,7 @@ class StackingSuiteDialog(QDialog):
16504
16658
 
16505
16659
  group_key, frames = self._mf_queue.pop(0)
16506
16660
 
16507
- out_dir = os.path.join(self.stacking_directory, "Masters")
16661
+ out_dir = self._master_light_dir()
16508
16662
  os.makedirs(out_dir, exist_ok=True)
16509
16663
  out_path = os.path.join(out_dir, f"MasterLight_{group_key}_MFDeconv.fit")
16510
16664
 
@@ -17020,22 +17174,35 @@ class StackingSuiteDialog(QDialog):
17020
17174
  display_group = self._label_with_dims(group_key, W, H)
17021
17175
  base = f"MasterLight_{display_group}_{n_frames_group}stacked"
17022
17176
  base = self._normalize_master_stem(base)
17023
- out_path_orig = self._build_out(self.stacking_directory, base, "fit")
17177
+ out_path_orig = self._build_out(self._master_light_dir(), base, "fit")
17024
17178
 
17025
17179
  # Try to attach rejection maps that were accumulated during integration
17026
17180
  maps = getattr(self, "_rej_maps", {}).get(group_key)
17027
17181
  save_layers = self.settings.value("stacking/save_rejection_layers", True, type=bool)
17028
17182
 
17029
- if maps and save_layers:
17183
+ save_layers = self.settings.value("stacking/save_rejection_layers", True, type=bool)
17184
+
17185
+ if rejection_map and save_layers:
17030
17186
  try:
17187
+ # integrated_image is already normalized above; rejection coords are assumed in this (H,W) space
17188
+ rej_cnt, rej_frac, rej_any = build_rejection_layers(
17189
+ rejection_map=rejection_map,
17190
+ ref_H=H,
17191
+ ref_W=W,
17192
+ n_frames=n_frames_group,
17193
+ comb_threshold_frac=0.02, # tweakable
17194
+ )
17195
+
17031
17196
  _save_master_with_rejection_layers(
17032
- integrated_image,
17033
- hdr_orig,
17034
- out_path_orig,
17035
- rej_any = maps.get("any"),
17036
- rej_frac= maps.get("frac"),
17197
+ img_array=integrated_image,
17198
+ hdr=hdr_orig,
17199
+ out_path=out_path_orig,
17200
+ rej_any=rej_any,
17201
+ rej_frac=rej_frac,
17202
+ rej_cnt=rej_cnt,
17037
17203
  )
17038
17204
  log(f"✅ Saved integrated image (with rejection layers) for '{group_key}': {out_path_orig}")
17205
+
17039
17206
  except Exception as e:
17040
17207
  log(f"⚠️ MEF save failed ({e}); falling back to single-HDU save.")
17041
17208
  save_image(
@@ -17047,8 +17214,8 @@ class StackingSuiteDialog(QDialog):
17047
17214
  is_mono=is_mono_orig
17048
17215
  )
17049
17216
  log(f"✅ Saved integrated image (single-HDU) for '{group_key}': {out_path_orig}")
17217
+
17050
17218
  else:
17051
- # No maps available or feature disabled → single-HDU save
17052
17219
  save_image(
17053
17220
  img_array=integrated_image,
17054
17221
  filename=out_path_orig,
@@ -17128,7 +17295,7 @@ class StackingSuiteDialog(QDialog):
17128
17295
  display_group_crop = self._label_with_dims(group_key, Wc, Hc)
17129
17296
  base_crop = f"MasterLight_{display_group_crop}_{n_frames_group}stacked_autocrop"
17130
17297
  base_crop = self._normalize_master_stem(base_crop)
17131
- out_path_crop = self._build_out(self.stacking_directory, base_crop, "fit")
17298
+ out_path_crop = self._build_out(self._master_light_dir(), base_crop, "fit")
17132
17299
 
17133
17300
  save_image(
17134
17301
  img_array=cropped_img,
@@ -17276,7 +17443,7 @@ class StackingSuiteDialog(QDialog):
17276
17443
  Hc, Wc = comet_only.shape[:2]
17277
17444
  display_group_c = self._label_with_dims(group_key, Wc, Hc)
17278
17445
  comet_path = self._build_out(
17279
- self.stacking_directory,
17446
+ self._master_light_dir(),
17280
17447
  f"MasterCometOnly_{display_group_c}_{len(usable)}stacked",
17281
17448
  "fit"
17282
17449
  )
@@ -17303,7 +17470,7 @@ class StackingSuiteDialog(QDialog):
17303
17470
  Hcc, Wcc = comet_only_crop.shape[:2]
17304
17471
  display_group_cc = self._label_with_dims(group_key, Wcc, Hcc)
17305
17472
  comet_path_crop = self._build_out(
17306
- self.stacking_directory,
17473
+ self._master_light_dir(),
17307
17474
  f"MasterCometOnly_{display_group_cc}_{len(usable)}stacked_autocrop",
17308
17475
  "fit"
17309
17476
  )
@@ -17332,7 +17499,7 @@ class StackingSuiteDialog(QDialog):
17332
17499
 
17333
17500
  is_mono_blend = (blend.ndim == 2) or (blend.ndim == 3 and blend.shape[2] == 1)
17334
17501
  blend_path = self._build_out(
17335
- self.stacking_directory,
17502
+ self._master_light_dir(),
17336
17503
  f"MasterCometBlend_{display_group_c}_{len(usable)}stacked",
17337
17504
  "fit"
17338
17505
  )
@@ -17352,7 +17519,7 @@ class StackingSuiteDialog(QDialog):
17352
17519
  Hb, Wb = blend_crop.shape[:2]
17353
17520
  display_group_bc = self._label_with_dims(group_key, Wb, Hb)
17354
17521
  blend_path_crop = self._build_out(
17355
- self.stacking_directory,
17522
+ self._master_light_dir(),
17356
17523
  f"MasterCometBlend_{display_group_bc}_{len(usable)}stacked_autocrop",
17357
17524
  "fit"
17358
17525
  )
@@ -17368,19 +17535,14 @@ class StackingSuiteDialog(QDialog):
17368
17535
  sasr_path = os.path.join(self.stacking_directory, f"{group_key}_rejections.sasr")
17369
17536
  self.save_rejection_map_sasr(rejection_map, sasr_path)
17370
17537
  log(f"✅ Saved rejection map to {sasr_path}")
17371
- group_integration_data[group_key] = {
17372
- "integrated_image": integrated_image,
17373
- "rejection_map": rejection_map,
17374
- "n_frames": n_frames_group,
17375
- "drizzled": True
17376
- }
17377
- else:
17378
- group_integration_data[group_key] = {
17379
- "integrated_image": integrated_image,
17380
- "rejection_map": None,
17381
- "n_frames": n_frames_group,
17382
- "drizzled": False
17383
- }
17538
+
17539
+ group_integration_data[group_key] = {
17540
+ "integrated_image": integrated_image,
17541
+ "rejection_map": rejection_map if drizzle_enabled_global else None,
17542
+ "n_frames": n_frames_group,
17543
+ "drizzled": False, # <-- ALWAYS false here
17544
+ }
17545
+ if not drizzle_enabled_global:
17384
17546
  log(f"ℹ️ Skipping rejection map save for '{group_key}' (drizzle disabled).")
17385
17547
 
17386
17548
  QApplication.processEvents()
@@ -17409,22 +17571,55 @@ class StackingSuiteDialog(QDialog):
17409
17571
 
17410
17572
  rejections_for_group = group_integration_data[group_key]["rejection_map"]
17411
17573
  n_frames_group = group_integration_data[group_key]["n_frames"]
17574
+ # Build drizzle entries for each group
17575
+ split_info = getattr(self, "_split_drizzle_info", {}) or {}
17576
+ oba = getattr(self, "orig_by_aligned", {}) or {}
17577
+
17578
+ entries_by_group = {}
17579
+ for gk, aligned_list in grouped_files.items():
17580
+ entries = []
17581
+ for ap in aligned_list:
17582
+ apn = os.path.normpath(ap)
17583
+ info = split_info.get(apn)
17584
+
17585
+ if info and info.get("orig"):
17586
+ entries.append({
17587
+ "orig": os.path.normpath(info["orig"]), # parent original pixels
17588
+ "aligned": apn, # split aligned (rej/weights)
17589
+ "chan": info.get("chan", None), # 'R'/'G'/None
17590
+ })
17591
+ else:
17592
+ # normal (non-split) case
17593
+ op = oba.get(apn)
17594
+ if op:
17595
+ entries.append({"orig": os.path.normpath(op), "aligned": apn, "chan": None})
17596
+
17597
+ entries_by_group[gk] = entries
17412
17598
 
17413
17599
  log(f"📐 Drizzle for '{group_key}' at {scale_factor}× (drop={drop_shrink}) using {n_frames_group} frame(s).")
17414
17600
 
17415
- self.drizzle_stack_one_group(
17416
- group_key=group_key,
17417
- file_list=file_list,
17418
- original_list=originals_by_group.get(group_key, []),
17419
- transforms_dict=transforms_dict,
17420
- frame_weights=frame_weights,
17421
- scale_factor=scale_factor,
17422
- drop_shrink=drop_shrink,
17423
- rejection_map=rejections_for_group,
17424
- autocrop_enabled=autocrop_enabled,
17425
- rect_override=global_rect,
17426
- status_cb=log
17427
- )
17601
+ ok = False
17602
+ try:
17603
+ ok = bool(self.drizzle_stack_one_group(
17604
+ group_key=group_key,
17605
+ file_list=file_list,
17606
+ entries=entries_by_group.get(group_key, []),
17607
+ transforms_dict=transforms_dict,
17608
+ frame_weights=frame_weights,
17609
+ scale_factor=scale_factor,
17610
+ drop_shrink=drop_shrink,
17611
+ rejection_map=rejections_for_group,
17612
+ autocrop_enabled=autocrop_enabled,
17613
+ rect_override=global_rect,
17614
+ status_cb=log
17615
+ ))
17616
+ except Exception as e:
17617
+ log(f"⚠️ Drizzle failed for '{group_key}': {e!r}")
17618
+ ok = False
17619
+
17620
+ if ok:
17621
+ group_integration_data[group_key]["drizzled"] = True
17622
+
17428
17623
 
17429
17624
  # Build summary lines
17430
17625
  for group_key, info in group_integration_data.items():
@@ -18019,7 +18214,7 @@ class StackingSuiteDialog(QDialog):
18019
18214
  return
18020
18215
 
18021
18216
  group_key, frames = self._mf_queue.pop(0)
18022
- out_dir = os.path.join(self.stacking_directory, "Masters")
18217
+ out_dir = self._master_light_dir()
18023
18218
  os.makedirs(out_dir, exist_ok=True)
18024
18219
 
18025
18220
  # Settings snapshot
@@ -18648,8 +18843,8 @@ class StackingSuiteDialog(QDialog):
18648
18843
  self,
18649
18844
  *,
18650
18845
  group_key,
18651
- file_list, # registered _n_r.fit (keep for headers/metadata)
18652
- original_list, # NEW: originals (normalized), used as pixel sources
18846
+ file_list, # still the aligned split files (for headers/metadata)
18847
+ entries, # NEW: list of {orig, aligned, chan}
18653
18848
  transforms_dict,
18654
18849
  frame_weights,
18655
18850
  scale_factor,
@@ -18659,8 +18854,15 @@ class StackingSuiteDialog(QDialog):
18659
18854
  rect_override,
18660
18855
  status_cb
18661
18856
  ):
18662
- # Load per-frame transforms (SASD v2)
18663
- log = status_cb or (lambda *_: None)
18857
+ import os
18858
+
18859
+ import cv2
18860
+ from datetime import datetime
18861
+ from astropy.io import fits
18862
+
18863
+ log = status_cb or (lambda *_: None)
18864
+
18865
+ # ---- load SASD v2 transforms (keyed by ORIGINAL path) ----
18664
18866
  sasd_path = os.path.join(self.stacking_directory, "alignment_transforms.sasd")
18665
18867
  ref_H, ref_W, xforms = self._load_sasd_v2(sasd_path)
18666
18868
  if not (ref_H and ref_W):
@@ -18668,26 +18870,33 @@ class StackingSuiteDialog(QDialog):
18668
18870
  rs = getattr(self, "ref_shape_for_drizzle", None)
18669
18871
  if isinstance(rs, tuple) and len(rs) == 2 and all(int(v) > 0 for v in rs):
18670
18872
  ref_H, ref_W = int(rs[0]), int(rs[1])
18671
- status_cb(f"ℹ️ Using in-memory REF_SHAPE fallback: {ref_H}×{ref_W}")
18873
+ log(f"ℹ️ Using in-memory REF_SHAPE fallback: {ref_H}×{ref_W}")
18672
18874
  else:
18673
- status_cb("⚠️ Missing REF_SHAPE in SASD; cannot drizzle.")
18875
+ log("⚠️ Missing REF_SHAPE in SASD; cannot drizzle.")
18674
18876
  return
18675
18877
 
18676
18878
  log(f"✅ SASD v2: loaded {len(xforms)} transform(s).")
18677
- # Debug (first few):
18879
+
18880
+ # ---- sanity: entries must exist ----
18881
+ if not entries:
18882
+ log(f"⚠️ Group '{group_key}' has no drizzle entries – skipping.")
18883
+ return False
18884
+
18885
+ # Debug (first few)
18678
18886
  try:
18679
- sample_need = [os.path.basename(p) for p in original_list[:5]]
18887
+ sample_need = [os.path.basename(os.path.normpath(e.get("orig", ""))) for e in entries[:5]]
18680
18888
  sample_have = [os.path.basename(p) for p in list(xforms.keys())[:5]]
18681
18889
  log(f" originals needed (sample): {sample_need}")
18682
18890
  log(f" sasd FILEs (sample): {sample_have}")
18683
18891
  except Exception:
18684
18892
  pass
18685
18893
 
18686
- canvas_H, canvas_W = int(ref_H * scale_factor), int(ref_W * scale_factor)
18894
+ # ---- output canvas ----
18895
+ canvas_H = int(ref_H * scale_factor)
18896
+ canvas_W = int(ref_W * scale_factor)
18687
18897
 
18688
-
18689
- # --- kernel config from settings ---
18690
- kernel_name = self.settings.value("stacking/drizzle_kernel", "square", type=str).lower()
18898
+ # ---- kernel config from settings ----
18899
+ kernel_name = (self.settings.value("stacking/drizzle_kernel", "square", type=str) or "square").lower()
18691
18900
  gauss_sigma = self.settings.value(
18692
18901
  "stacking/drizzle_gauss_sigma", float(drop_shrink) * 0.5, type=float
18693
18902
  )
@@ -18701,80 +18910,78 @@ class StackingSuiteDialog(QDialog):
18701
18910
  total_rej = sum(len(v) for v in (rejection_map or {}).values())
18702
18911
  log(f"🔭 Drizzle stacking for group '{group_key}' with {total_rej} total rejected pixels.")
18703
18912
 
18913
+ # Need at least 2 frames to be worth it
18704
18914
  if len(file_list) < 2:
18705
18915
  log(f"⚠️ Group '{group_key}' does not have enough frames to drizzle.")
18706
- return
18916
+ return False
18707
18917
 
18708
- # --- establish geometry + is_mono before choosing depositor ---
18918
+ # ---- establish header + default dims from first aligned file (metadata only) ----
18709
18919
  first_file = file_list[0]
18710
18920
  first_img, hdr, _, _ = load_image(first_file)
18711
18921
  if first_img is None:
18712
18922
  log(f"⚠️ Could not load {first_file} to determine drizzle shape!")
18713
- return
18923
+ return False
18924
+
18925
+ # Force mono if any entry is channel-split (dual-band drizzle output should be mono)
18926
+ force_mono = any((e.get("chan") in ("R", "G", "B")) for e in entries)
18714
18927
 
18715
- if first_img.ndim == 2:
18928
+ if force_mono:
18716
18929
  is_mono = True
18717
- h, w = first_img.shape
18930
+ h, w = first_img.shape[:2]
18718
18931
  c = 1
18719
18932
  else:
18720
- is_mono = False
18721
- h, w, c = first_img.shape
18933
+ if first_img.ndim == 2:
18934
+ is_mono = True
18935
+ h, w = first_img.shape
18936
+ c = 1
18937
+ else:
18938
+ is_mono = False
18939
+ h, w, c = first_img.shape
18722
18940
 
18723
18941
  # --- choose depositor ONCE (and log it) ---
18724
- if _kcode == 0 and drop_shrink >= 0.99:
18725
- # square + pixfrac≈1 → naive “one-to-one” deposit
18942
+ use_kernelized = not (_kcode == 0 and drop_shrink >= 0.99)
18943
+
18944
+ if not use_kernelized:
18726
18945
  deposit_func = drizzle_deposit_numba_naive if is_mono else drizzle_deposit_color_naive
18727
18946
  kinf = "naive (square, pixfrac≈1)"
18728
18947
  else:
18729
- # Any other case → kernelized path (square/circular/gaussian)
18730
18948
  deposit_func = drizzle_deposit_numba_kernel_mono if is_mono else drizzle_deposit_color_kernel
18731
18949
  kinf = ["square", "circular", "gaussian"][_kcode]
18732
- log(f"Using {kinf} kernel drizzle ({'mono' if is_mono else 'color'}).")
18733
18950
 
18734
- # --- allocate buffers ---
18951
+ log(f"Using {kinf} drizzle ({'mono' if is_mono else 'color'}).")
18952
+
18953
+ # ---- allocate buffers ----
18735
18954
  out_h = int(canvas_H)
18736
18955
  out_w = int(canvas_W)
18737
18956
  drizzle_buffer = np.zeros((out_h, out_w) if is_mono else (out_h, out_w, c), dtype=self._dtype())
18738
18957
  coverage_buffer = np.zeros_like(drizzle_buffer, dtype=self._dtype())
18739
18958
  finalize_func = finalize_drizzle_2d if is_mono else finalize_drizzle_3d
18740
18959
 
18741
- def _invert_2x3(A23: np.ndarray) -> np.ndarray:
18742
- A23 = np.asarray(A23, np.float32).reshape(2, 3)
18743
- A33 = np.eye(3, dtype=np.float32); A33[:2] = A23
18744
- return np.linalg.inv(A33) # 3x3
18960
+ # ---- main loop over ENTRIES ----
18961
+ # each entry: {"orig": <original path>, "aligned": <aligned path>, "chan": "R"/"G"/None}
18962
+ for i, ent in enumerate(entries):
18963
+ orig_file = os.path.normpath(ent.get("orig", ""))
18964
+ aligned_file = os.path.normpath(ent.get("aligned", "")) # this is what rejections/weights reference
18965
+ chan = ent.get("chan", None)
18745
18966
 
18746
- def _apply_H_point(H: np.ndarray, x: float, y: float) -> tuple[int, int]:
18747
- v = H @ np.array([x, y, 1.0], dtype=np.float32)
18748
- if abs(v[2]) < 1e-8:
18749
- return (int(round(v[0])), int(round(v[1])))
18750
- return (int(round(v[0] / v[2])), int(round(v[1] / v[2])))
18751
-
18752
-
18753
- # --- main loop ---
18754
- # Map original (normalized) → aligned path (for weights & rejection map lookups)
18755
- orig_to_aligned = {}
18756
- for op in original_list:
18757
- ap = transforms_dict.get(os.path.normpath(op))
18758
- if ap:
18759
- orig_to_aligned[os.path.normpath(op)] = os.path.normpath(ap)
18760
-
18761
- for orig_file in original_list:
18762
- orig_key = os.path.normpath(orig_file)
18763
- aligned_file = orig_to_aligned.get(orig_key)
18967
+ if not orig_file or not aligned_file:
18968
+ log(f"⚠️ Bad drizzle entry (missing orig/aligned) skipping: {ent}")
18969
+ continue
18764
18970
 
18765
- weight = frame_weights.get(aligned_file, frame_weights.get(orig_key, 1.0))
18971
+ # Weight: prefer aligned key (that matches your rejection/weights mapping)
18972
+ weight = frame_weights.get(aligned_file, frame_weights.get(orig_file, 1.0))
18766
18973
 
18767
- kind, X = xforms.get(orig_key, (None, None))
18768
- log(f"🧭 Drizzle uses {kind or '-'} for {os.path.basename(orig_key)}")
18974
+ kind, X = xforms.get(orig_file, (None, None))
18975
+ log(f"🧭 Drizzle uses {kind or '-'} for {os.path.basename(orig_file)} (chan={chan or 'all'})")
18769
18976
  if kind is None:
18770
18977
  log(f"⚠️ No usable transform for {os.path.basename(orig_file)} – skipping")
18771
18978
  continue
18772
18979
 
18773
- # --- choose pixel source + mapping ---
18980
+ # choose pixel source + mapping
18774
18981
  pixels_are_registered = False
18775
18982
  img_data = None
18776
18983
 
18777
- if isinstance(kind, str) and (kind.startswith("poly") or kind in ("tps","thin_plate_spline")):
18984
+ if isinstance(kind, str) and (kind.startswith("poly") or kind in ("tps", "thin_plate_spline")):
18778
18985
  # Already warped to reference during registration
18779
18986
  pixel_path = aligned_file
18780
18987
  if not pixel_path:
@@ -18784,6 +18991,7 @@ class StackingSuiteDialog(QDialog):
18784
18991
  pixels_are_registered = True
18785
18992
 
18786
18993
  elif kind == "affine" and X is not None:
18994
+ # Use ORIGINAL pixels + affine subpixel mapping
18787
18995
  pixel_path = orig_file
18788
18996
  H_canvas = np.eye(3, dtype=np.float32)
18789
18997
  H_canvas[:2] = np.asarray(X, np.float32).reshape(2, 3)
@@ -18796,6 +19004,7 @@ class StackingSuiteDialog(QDialog):
18796
19004
  log(f"⚠️ Failed to read {os.path.basename(pixel_path)} – skipping")
18797
19005
  continue
18798
19006
  H = np.asarray(X, np.float32).reshape(3, 3)
19007
+
18799
19008
  if raw_img.ndim == 2:
18800
19009
  img_data = cv2.warpPerspective(
18801
19010
  raw_img, H, (ref_W, ref_H),
@@ -18810,6 +19019,7 @@ class StackingSuiteDialog(QDialog):
18810
19019
  borderMode=cv2.BORDER_CONSTANT, borderValue=0
18811
19020
  ) for ch in range(raw_img.shape[2])
18812
19021
  ], axis=2)
19022
+
18813
19023
  H_canvas = np.eye(3, dtype=np.float32)
18814
19024
  pixels_are_registered = True
18815
19025
 
@@ -18824,28 +19034,43 @@ class StackingSuiteDialog(QDialog):
18824
19034
  log(f"⚠️ Failed to read {os.path.basename(pixel_path)} – skipping")
18825
19035
  continue
18826
19036
 
18827
- # --- debug bbox once ---
18828
- if orig_file is original_list[0]:
18829
- x0, y0 = 0, 0; x1, y1 = ref_W-1, ref_H-1
19037
+ # If this is a split dual-band entry, extract channel from the pixel source
19038
+ if chan in ("R", "G", "B"):
19039
+ if img_data.ndim == 3 and img_data.shape[2] >= 3:
19040
+ ch_idx = {"R": 0, "G": 1, "B": 2}[chan]
19041
+ img_data = img_data[..., ch_idx] # mono plane
19042
+ # if already mono, leave it alone
19043
+
19044
+ # Debug bbox once
19045
+ if i == 0:
19046
+ x0, y0 = 0, 0
19047
+ x1, y1 = ref_W - 1, ref_H - 1
18830
19048
  p0 = H_canvas @ np.array([x0, y0, 1], np.float32); p0 /= max(p0[2], 1e-8)
18831
19049
  p1 = H_canvas @ np.array([x1, y1, 1], np.float32); p1 /= max(p1[2], 1e-8)
18832
- log(f" bbox(ref)→reg: ({p0[0]:.1f},{p0[1]:.1f}) to ({p1[0]:.1f},{p1[1]:.1f}); "
18833
- f"canvas {int(ref_W*scale_factor)}×{int(ref_H*scale_factor)} @ {scale_factor}×")
19050
+ log(
19051
+ f" bbox(ref)→reg: ({p0[0]:.1f},{p0[1]:.1f}) to ({p1[0]:.1f},{p1[1]:.1f}); "
19052
+ f"canvas {int(ref_W * scale_factor)}×{int(ref_H * scale_factor)} @ {scale_factor}×"
19053
+ )
18834
19054
 
18835
- # --- apply per-file rejections ---
19055
+ # ---- apply per-file rejections (lookup by ALIGNED file) ----
18836
19056
  if rejection_map and aligned_file in rejection_map:
18837
19057
  coords_for_this_file = rejection_map.get(aligned_file, [])
18838
19058
  if coords_for_this_file:
18839
19059
  dilate_px = int(self.settings.value("stacking/reject_dilate_px", 0, type=int))
18840
- dilate_shape = self.settings.value("stacking/reject_dilate_shape", "square", type=str).lower()
19060
+ dilate_shape = (self.settings.value("stacking/reject_dilate_shape", "square", type=str) or "square").lower()
19061
+
18841
19062
  offsets = [(0, 0)]
18842
19063
  if dilate_px > 0:
18843
19064
  r = dilate_px
18844
- offsets = [(dx, dy) for dx in range(-r, r+1) for dy in range(-r, r+1)]
19065
+ offsets = [(dx, dy) for dx in range(-r, r + 1) for dy in range(-r, r + 1)]
18845
19066
  if dilate_shape.startswith("dia"):
18846
- offsets = [(dx, dy) for (dx,dy) in offsets if (abs(dx)+abs(dy) <= r)]
19067
+ offsets = [(dx, dy) for (dx, dy) in offsets if (abs(dx) + abs(dy) <= r)]
18847
19068
 
18848
- Hraw, Wraw = img_data.shape[0], img_data.shape[1]
19069
+ # after optional channel extraction, img_data may be 2D
19070
+ if img_data.ndim == 2:
19071
+ Hraw, Wraw = img_data.shape
19072
+ else:
19073
+ Hraw, Wraw = img_data.shape[0], img_data.shape[1]
18849
19074
 
18850
19075
  if pixels_are_registered:
18851
19076
  # Directly zero registered pixels
@@ -18853,9 +19078,12 @@ class StackingSuiteDialog(QDialog):
18853
19078
  for (ox, oy) in offsets:
18854
19079
  xr, yr = x_r + ox, y_r + oy
18855
19080
  if 0 <= xr < Wraw and 0 <= yr < Hraw:
18856
- img_data[yr, xr] = 0.0
19081
+ if img_data.ndim == 2:
19082
+ img_data[yr, xr] = 0.0
19083
+ else:
19084
+ img_data[yr, xr, :] = 0.0
18857
19085
  else:
18858
- # Back-project via inverse affine
19086
+ # Back-project via inverse affine (H_canvas)
18859
19087
  Hinv = np.linalg.inv(H_canvas)
18860
19088
  for (x_r, y_r) in coords_for_this_file:
18861
19089
  for (ox, oy) in offsets:
@@ -18864,35 +19092,43 @@ class StackingSuiteDialog(QDialog):
18864
19092
  x_raw = int(round(v[0] / max(v[2], 1e-8)))
18865
19093
  y_raw = int(round(v[1] / max(v[2], 1e-8)))
18866
19094
  if 0 <= x_raw < Wraw and 0 <= y_raw < Hraw:
18867
- img_data[y_raw, x_raw] = 0.0
19095
+ if img_data.ndim == 2:
19096
+ img_data[y_raw, x_raw] = 0.0
19097
+ else:
19098
+ img_data[y_raw, x_raw, :] = 0.0
19099
+
19100
+ # ---- deposit ----
19101
+ A23 = H_canvas[:2, :]
18868
19102
 
18869
- # --- deposit (identity for registered pixels) ---
18870
19103
  if deposit_func is drizzle_deposit_numba_naive:
18871
19104
  drizzle_buffer, coverage_buffer = deposit_func(
18872
- img_data, H_canvas[:2], drizzle_buffer, coverage_buffer, scale_factor, weight
19105
+ img_data, A23, drizzle_buffer, coverage_buffer,
19106
+ scale_factor, weight
18873
19107
  )
19108
+
18874
19109
  elif deposit_func is drizzle_deposit_color_naive:
18875
19110
  drizzle_buffer, coverage_buffer = deposit_func(
18876
- img_data, H_canvas[:2], drizzle_buffer, coverage_buffer, scale_factor, drop_shrink, weight
19111
+ img_data, A23, drizzle_buffer, coverage_buffer,
19112
+ scale_factor, drop_shrink, weight
18877
19113
  )
19114
+
18878
19115
  else:
18879
- A23 = H_canvas[:2, :]
19116
+ # kernel mono OR kernel color
18880
19117
  drizzle_buffer, coverage_buffer = deposit_func(
18881
19118
  img_data, A23, drizzle_buffer, coverage_buffer,
18882
19119
  scale_factor, drop_shrink, weight, _kcode, float(gauss_sigma)
18883
19120
  )
18884
19121
 
18885
-
18886
- # --- finalize, save, optional autocrop ---
19122
+ # ---- finalize ----
18887
19123
  final_drizzle = np.zeros_like(drizzle_buffer, dtype=np.float32)
18888
19124
  final_drizzle = finalize_func(drizzle_buffer, coverage_buffer, final_drizzle)
18889
19125
 
18890
- # Save original drizzle (single-HDU; no rejection layers here)
19126
+ # ---- save (single-HDU; no rejection layers here) ----
18891
19127
  Hd, Wd = final_drizzle.shape[:2] if final_drizzle.ndim >= 2 else (0, 0)
18892
19128
  display_group_driz = self._label_with_dims(group_key, Wd, Hd)
18893
19129
  base_stem = f"MasterLight_{display_group_driz}_{len(file_list)}stacked_drizzle"
18894
- base_stem = self._normalize_master_stem(base_stem)
18895
- out_path_orig = self._build_out(self.stacking_directory, base_stem, "fit")
19130
+ base_stem = self._normalize_master_stem(base_stem)
19131
+ out_path_orig = self._build_out(self._master_light_dir(), base_stem, "fit")
18896
19132
 
18897
19133
  hdr_orig = hdr.copy() if hdr is not None else fits.Header()
18898
19134
  hdr_orig["IMAGETYP"] = "MASTER STACK - DRIZZLE"
@@ -18929,7 +19165,7 @@ class StackingSuiteDialog(QDialog):
18929
19165
  )
18930
19166
  log(f"✅ Drizzle (original) saved: {out_path_orig}")
18931
19167
 
18932
- # Optional auto-crop (respects global rect if provided)
19168
+ # ---- optional auto-crop ----
18933
19169
  if autocrop_enabled:
18934
19170
  cropped_drizzle, hdr_crop = self._apply_autocrop(
18935
19171
  final_drizzle,
@@ -18939,12 +19175,14 @@ class StackingSuiteDialog(QDialog):
18939
19175
  rect_override=rect_override
18940
19176
  )
18941
19177
  hdr_crop["NCOMBINE"] = (n_frames, "Number of frames combined")
18942
- hdr_crop["NSTACK"] = (n_frames, "Alias of NCOMBINE (SetiAstro)")
19178
+ hdr_crop["NSTACK"] = (n_frames, "Alias of NCOMBINE (SetiAstro)")
19179
+
18943
19180
  is_mono_crop = (cropped_drizzle.ndim == 2)
18944
19181
  display_group_driz_crop = self._label_with_dims(group_key, cropped_drizzle.shape[1], cropped_drizzle.shape[0])
18945
19182
  base_crop = f"MasterLight_{display_group_driz_crop}_{len(file_list)}stacked_drizzle_autocrop"
18946
- base_crop = self._normalize_master_stem(base_crop)
18947
- out_path_crop = self._build_out(self.stacking_directory, base_crop, "fit")
19183
+ base_crop = self._normalize_master_stem(base_crop)
19184
+ out_path_crop = self._build_out(self._master_light_dir(), base_crop, "fit")
19185
+
18948
19186
  cropped_drizzle = self._normalize_stack_01(cropped_drizzle)
18949
19187
  save_image(
18950
19188
  img_array=cropped_drizzle,
@@ -18958,6 +19196,7 @@ class StackingSuiteDialog(QDialog):
18958
19196
  self._autocrop_outputs = []
18959
19197
  self._autocrop_outputs.append((group_key, out_path_crop))
18960
19198
  log(f"✂️ Drizzle (auto-cropped) saved: {out_path_crop}")
19199
+ return True
18961
19200
 
18962
19201
  def _load_sasd_v2(self, path: str):
18963
19202
  """