setiastrosuitepro 1.6.1.post1__py3-none-any.whl → 1.6.4__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.
Files changed (139) hide show
  1. setiastro/images/Background_startup.jpg +0 -0
  2. setiastro/images/rotatearbitrary.png +0 -0
  3. setiastro/qml/ResourceMonitor.qml +126 -0
  4. setiastro/saspro/__main__.py +162 -25
  5. setiastro/saspro/_generated/build_info.py +2 -1
  6. setiastro/saspro/abe.py +62 -11
  7. setiastro/saspro/aberration_ai.py +3 -3
  8. setiastro/saspro/add_stars.py +5 -2
  9. setiastro/saspro/astrobin_exporter.py +3 -0
  10. setiastro/saspro/astrospike_python.py +3 -1
  11. setiastro/saspro/autostretch.py +4 -2
  12. setiastro/saspro/backgroundneutral.py +60 -9
  13. setiastro/saspro/batch_convert.py +3 -0
  14. setiastro/saspro/batch_renamer.py +3 -0
  15. setiastro/saspro/blemish_blaster.py +3 -0
  16. setiastro/saspro/blink_comparator_pro.py +474 -251
  17. setiastro/saspro/cheat_sheet.py +50 -15
  18. setiastro/saspro/clahe.py +27 -1
  19. setiastro/saspro/comet_stacking.py +103 -38
  20. setiastro/saspro/convo.py +3 -0
  21. setiastro/saspro/copyastro.py +3 -0
  22. setiastro/saspro/cosmicclarity.py +70 -45
  23. setiastro/saspro/crop_dialog_pro.py +28 -1
  24. setiastro/saspro/curve_editor_pro.py +18 -0
  25. setiastro/saspro/debayer.py +3 -0
  26. setiastro/saspro/doc_manager.py +40 -17
  27. setiastro/saspro/fitsmodifier.py +3 -0
  28. setiastro/saspro/frequency_separation.py +8 -2
  29. setiastro/saspro/function_bundle.py +18 -16
  30. setiastro/saspro/generate_translations.py +715 -1
  31. setiastro/saspro/ghs_dialog_pro.py +3 -0
  32. setiastro/saspro/graxpert.py +3 -0
  33. setiastro/saspro/gui/main_window.py +364 -92
  34. setiastro/saspro/gui/mixins/dock_mixin.py +119 -7
  35. setiastro/saspro/gui/mixins/file_mixin.py +7 -0
  36. setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
  37. setiastro/saspro/gui/mixins/menu_mixin.py +29 -0
  38. setiastro/saspro/gui/mixins/toolbar_mixin.py +33 -10
  39. setiastro/saspro/gui/statistics_dialog.py +47 -0
  40. setiastro/saspro/halobgon.py +29 -3
  41. setiastro/saspro/histogram.py +3 -0
  42. setiastro/saspro/history_explorer.py +2 -0
  43. setiastro/saspro/i18n.py +22 -10
  44. setiastro/saspro/image_combine.py +3 -0
  45. setiastro/saspro/image_peeker_pro.py +3 -0
  46. setiastro/saspro/imageops/stretch.py +5 -13
  47. setiastro/saspro/isophote.py +3 -0
  48. setiastro/saspro/legacy/numba_utils.py +64 -47
  49. setiastro/saspro/linear_fit.py +3 -0
  50. setiastro/saspro/live_stacking.py +13 -2
  51. setiastro/saspro/mask_creation.py +3 -0
  52. setiastro/saspro/mfdeconv.py +5 -0
  53. setiastro/saspro/morphology.py +30 -5
  54. setiastro/saspro/multiscale_decomp.py +713 -256
  55. setiastro/saspro/nbtorgb_stars.py +12 -2
  56. setiastro/saspro/numba_utils.py +148 -47
  57. setiastro/saspro/ops/scripts.py +77 -17
  58. setiastro/saspro/ops/settings.py +1 -43
  59. setiastro/saspro/perfect_palette_picker.py +1 -0
  60. setiastro/saspro/pixelmath.py +6 -2
  61. setiastro/saspro/plate_solver.py +1 -0
  62. setiastro/saspro/remove_green.py +18 -1
  63. setiastro/saspro/remove_stars.py +136 -162
  64. setiastro/saspro/remove_stars_preset.py +55 -13
  65. setiastro/saspro/resources.py +36 -10
  66. setiastro/saspro/rgb_combination.py +1 -0
  67. setiastro/saspro/rgbalign.py +4 -4
  68. setiastro/saspro/save_options.py +1 -0
  69. setiastro/saspro/selective_color.py +79 -20
  70. setiastro/saspro/sfcc.py +50 -8
  71. setiastro/saspro/shortcuts.py +94 -21
  72. setiastro/saspro/signature_insert.py +3 -0
  73. setiastro/saspro/stacking_suite.py +924 -446
  74. setiastro/saspro/star_alignment.py +291 -331
  75. setiastro/saspro/star_spikes.py +116 -32
  76. setiastro/saspro/star_stretch.py +38 -1
  77. setiastro/saspro/stat_stretch.py +35 -3
  78. setiastro/saspro/status_log_dock.py +1 -1
  79. setiastro/saspro/subwindow.py +63 -2
  80. setiastro/saspro/supernovaasteroidhunter.py +3 -0
  81. setiastro/saspro/swap_manager.py +77 -42
  82. setiastro/saspro/translations/all_source_strings.json +4726 -0
  83. setiastro/saspro/translations/ar_translations.py +4096 -0
  84. setiastro/saspro/translations/de_translations.py +441 -446
  85. setiastro/saspro/translations/es_translations.py +278 -32
  86. setiastro/saspro/translations/fr_translations.py +280 -32
  87. setiastro/saspro/translations/hi_translations.py +3803 -0
  88. setiastro/saspro/translations/integrate_translations.py +38 -1
  89. setiastro/saspro/translations/it_translations.py +1211 -145
  90. setiastro/saspro/translations/ja_translations.py +556 -307
  91. setiastro/saspro/translations/pt_translations.py +3316 -3322
  92. setiastro/saspro/translations/ru_translations.py +3082 -0
  93. setiastro/saspro/translations/saspro_ar.qm +0 -0
  94. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  95. setiastro/saspro/translations/saspro_de.qm +0 -0
  96. setiastro/saspro/translations/saspro_de.ts +14428 -133
  97. setiastro/saspro/translations/saspro_es.qm +0 -0
  98. setiastro/saspro/translations/saspro_es.ts +11503 -7821
  99. setiastro/saspro/translations/saspro_fr.qm +0 -0
  100. setiastro/saspro/translations/saspro_fr.ts +11168 -7812
  101. setiastro/saspro/translations/saspro_hi.qm +0 -0
  102. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  103. setiastro/saspro/translations/saspro_it.qm +0 -0
  104. setiastro/saspro/translations/saspro_it.ts +14347 -7821
  105. setiastro/saspro/translations/saspro_ja.qm +0 -0
  106. setiastro/saspro/translations/saspro_ja.ts +14860 -137
  107. setiastro/saspro/translations/saspro_pt.qm +0 -0
  108. setiastro/saspro/translations/saspro_pt.ts +14904 -137
  109. setiastro/saspro/translations/saspro_ru.qm +0 -0
  110. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  111. setiastro/saspro/translations/saspro_sw.qm +0 -0
  112. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  113. setiastro/saspro/translations/saspro_uk.qm +0 -0
  114. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  115. setiastro/saspro/translations/saspro_zh.qm +0 -0
  116. setiastro/saspro/translations/saspro_zh.ts +10581 -7812
  117. setiastro/saspro/translations/sw_translations.py +3897 -0
  118. setiastro/saspro/translations/uk_translations.py +3929 -0
  119. setiastro/saspro/translations/zh_translations.py +283 -32
  120. setiastro/saspro/versioning.py +36 -5
  121. setiastro/saspro/view_bundle.py +20 -17
  122. setiastro/saspro/wavescale_hdr.py +22 -1
  123. setiastro/saspro/wavescalede.py +23 -1
  124. setiastro/saspro/whitebalance.py +39 -3
  125. setiastro/saspro/widgets/minigame/game.js +991 -0
  126. setiastro/saspro/widgets/minigame/index.html +53 -0
  127. setiastro/saspro/widgets/minigame/style.css +241 -0
  128. setiastro/saspro/widgets/resource_monitor.py +263 -0
  129. setiastro/saspro/widgets/spinboxes.py +18 -0
  130. setiastro/saspro/widgets/wavelet_utils.py +52 -20
  131. setiastro/saspro/wimi.py +100 -80
  132. setiastro/saspro/wims.py +33 -33
  133. setiastro/saspro/window_shelf.py +2 -2
  134. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/METADATA +15 -4
  135. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/RECORD +139 -115
  136. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/WHEEL +0 -0
  137. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/entry_points.txt +0 -0
  138. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/LICENSE +0 -0
  139. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/license.txt +0 -0
@@ -83,11 +83,12 @@ from setiastro.saspro.legacy.numba_utils import (
83
83
  finalize_drizzle_2d,
84
84
  finalize_drizzle_3d,
85
85
  )
86
- from setiastro.saspro.legacy.numba_utils import (
86
+ from setiastro.saspro.numba_utils import (
87
87
  bulk_cosmetic_correction_numba,
88
88
  drizzle_deposit_numba_naive,
89
89
  drizzle_deposit_color_naive,
90
- bulk_cosmetic_correction_bayer
90
+ bulk_cosmetic_correction_bayer,
91
+ gradient_descent_to_dim_spot_numba
91
92
  )
92
93
  from setiastro.saspro.legacy.image_manager import load_image, save_image, get_valid_header
93
94
  from setiastro.saspro.star_alignment import StarRegistrationWorker, StarRegistrationThread, IDENTITY_2x3
@@ -153,64 +154,107 @@ _FITS_EXTS = ('.fits', '.fit', '.fts', '.fits.gz', '.fit.gz', '.fts.gz', '.fz')
153
154
 
154
155
  def get_valid_header(path: str):
155
156
  """
156
- Return a robust FITS header for both normal and compressed FITS.
157
+ Fast header-only FITS peek with a targeted fallback:
158
+
159
+ 1) Header-only scan (lazy_load_hdus=True, never touches .data)
160
+ 2) If NAXIS1/2 still missing/invalid, fallback to reading ONE image HDU's data
161
+ to get shape, then patch NAXIS/NAXIS1/NAXIS2.
157
162
 
158
- - Opens the HDU list and picks the first image-like HDU (ndim >= 2).
159
- - Forces NAXIS, NAXIS1, NAXIS2 from the actual data.shape if possible.
160
- - Falls back to ZNAXIS1/2 for tile-compressed images.
163
+ Returns: (hdr, ok_bool)
161
164
  """
162
165
  try:
163
166
  from astropy.io import fits
164
167
 
165
- with fits.open(path, memmap=False) as hdul:
168
+ def _is_good_dim(v):
169
+ try:
170
+ return int(v) > 0
171
+ except Exception:
172
+ return False
173
+
174
+ # ---------------------------
175
+ # Pass 1: header-only
176
+ # ---------------------------
177
+ with fits.open(path, mode="readonly", memmap=True, lazy_load_hdus=True) as hdul:
166
178
  science_hdu = None
167
179
 
168
- # Prefer the first HDU that actually has 2D+ image data
169
180
  for hdu in hdul:
170
- data = getattr(hdu, "data", None)
171
- if data is None:
172
- continue
173
- if getattr(data, "ndim", 0) >= 2:
181
+ hdr = hdu.header
182
+
183
+ # Prefer HDUs that *declare* 2D+ via header
184
+ naxis = hdr.get("NAXIS", None)
185
+ znaxis = hdr.get("ZNAXIS", None)
186
+
187
+ looks_2d = False
188
+ try:
189
+ if naxis is not None and int(naxis) >= 2:
190
+ looks_2d = True
191
+ except Exception:
192
+ pass
193
+ try:
194
+ if znaxis is not None and int(znaxis) >= 2:
195
+ looks_2d = True
196
+ except Exception:
197
+ pass
198
+
199
+ if looks_2d:
174
200
  science_hdu = hdu
175
201
  break
176
202
 
177
203
  if science_hdu is None:
178
- # Fallback: just use primary
179
204
  science_hdu = hdul[0]
180
205
 
181
206
  hdr = science_hdu.header.copy()
182
- data = science_hdu.data
183
207
 
184
- # --- Ensure NAXIS / NAXIS1 / NAXIS2 are real numbers ---
185
- try:
186
- if data is not None and getattr(data, "ndim", 0) >= 2:
187
- shape = data.shape
188
- # FITS: final axes are X, Y
189
- ny, nx = shape[-2], shape[-1]
190
- hdr["NAXIS"] = int(data.ndim)
191
- hdr["NAXIS1"] = int(nx)
192
- hdr["NAXIS2"] = int(ny)
193
- except Exception:
194
- pass
208
+ # Prefer normal NAXISn; fallback to ZNAXISn for tile-compressed
209
+ if not _is_good_dim(hdr.get("NAXIS1")) and _is_good_dim(hdr.get("ZNAXIS1")):
210
+ hdr["NAXIS1"] = int(hdr["ZNAXIS1"])
211
+ if not _is_good_dim(hdr.get("NAXIS2")) and _is_good_dim(hdr.get("ZNAXIS2")):
212
+ hdr["NAXIS2"] = int(hdr["ZNAXIS2"])
195
213
 
196
- # --- Extra fallback from ZNAXISn (tile-compressed FITS) ---
197
- for ax in (1, 2):
198
- key = f"NAXIS{ax}"
199
- zkey = f"ZNAXIS{ax}"
200
- val = hdr.get(key, None)
201
- if (val is None or (isinstance(val, str) and not val.strip())) and zkey in hdr:
202
- try:
203
- hdr[key] = int(hdr[zkey])
204
- except Exception:
205
- pass
214
+ # If we already have good dims, we are done (FAST PATH)
215
+ if _is_good_dim(hdr.get("NAXIS1")) and _is_good_dim(hdr.get("NAXIS2")):
216
+ return hdr, True
217
+
218
+ # ---------------------------
219
+ # Pass 2: slow fallback (ONLY if needed)
220
+ # ---------------------------
221
+ # Re-open without lazy semantics and read ONE image-like HDU's data to infer shape.
222
+ with fits.open(path, mode="readonly", memmap=False) as hdul:
223
+ target_hdu = None
224
+ for hdu in hdul:
225
+ # data access is expensive; try to choose wisely by header first
226
+ naxis = hdu.header.get("NAXIS", 0)
227
+ znaxis = hdu.header.get("ZNAXIS", 0)
206
228
 
207
- return hdr, True
229
+ try:
230
+ if int(naxis) >= 2 or int(znaxis) >= 2:
231
+ target_hdu = hdu
232
+ break
233
+ except Exception:
234
+ continue
208
235
 
209
- except Exception:
210
- return None, False
236
+ if target_hdu is None:
237
+ target_hdu = hdul[0]
238
+
239
+ # Now (and only now) touch data
240
+ data = getattr(target_hdu, "data", None)
211
241
 
242
+ hdr2 = target_hdu.header.copy()
243
+ if data is not None and getattr(data, "ndim", 0) >= 2:
244
+ try:
245
+ ny, nx = data.shape[-2], data.shape[-1]
246
+ hdr2["NAXIS"] = int(getattr(data, "ndim", hdr2.get("NAXIS", 2)))
247
+ hdr2["NAXIS1"] = int(nx)
248
+ hdr2["NAXIS2"] = int(ny)
249
+ return hdr2, True
250
+ except Exception:
251
+ pass
212
252
 
253
+ # If still unknown, return header anyway (caller can show "Unknown")
254
+ return hdr2, True
213
255
 
256
+ except Exception:
257
+ return None, False
214
258
 
215
259
  def _read_tile_stack(file_list, y0, y1, x0, x1, channels, out_buf):
216
260
  """
@@ -704,12 +748,33 @@ def normalize_images(stack: np.ndarray,
704
748
  print(f"Normalizing {i}")
705
749
  f = stack[i].astype(np.float32, copy=False)
706
750
  L = _L(f)
751
+
752
+ # Optimization: Don't allocate f0 and L0. Use math properties.
753
+ # fmin = min(L)
754
+ # f0 = f - fmin
755
+ # L0 = L(f0) = L(f - fmin) = L(f) - fmin (since L is linear sum of channels)
756
+ # median(L0) = median(L - fmin) = median(L) - fmin
757
+
758
+ # Calculate stats on original L
759
+ # Note: nanmin/nanmedian are used to be safe against bad pixels
707
760
  fmin = float(np.nanmin(L))
708
- f0 = f - fmin
709
- L0 = _L(f0)
710
- fmed = float(np.nanmedian(L0))
761
+ lmed_original = float(np.nanmedian(L))
762
+
763
+ # The median of the zero-shifted image
764
+ fmed = lmed_original - fmin
765
+
711
766
  gain = (target_median / max(fmed, eps)) if target_median > 0 else 1.0
712
- out[i] = f0 * gain
767
+
768
+ # Combine subtraction and multiplication into one operation for 'out'
769
+ # out = (f - fmin) * gain
770
+ # This avoids creating the large temporary array 'f0'
771
+
772
+ # We can implement this as: out[i] = f * gain - (fmin * gain)
773
+ # But we must be careful with precision. Typically fine.
774
+ # Or just: np.subtract(f, fmin, out=out[i]); np.multiply(out[i], gain, out=out[i])
775
+
776
+ # Using direct assignment is cleaner and numpy optimizes it well enough
777
+ out[i] = (f - fmin) * gain
713
778
 
714
779
  return np.ascontiguousarray(out, dtype=np.float32)
715
780
 
@@ -864,6 +929,11 @@ def _to_Luma(img: np.ndarray) -> np.ndarray:
864
929
  if img.ndim == 2:
865
930
  return img.astype(np.float32, copy=False)
866
931
  # HWC RGB
932
+ if img.shape[2] == 3:
933
+ try:
934
+ return cv2.cvtColor(img, cv2.COLOR_RGB2GRAY).astype(np.float32, copy=False)
935
+ except Exception:
936
+ pass # fallback
867
937
  r, g, b = img[..., 0].astype(np.float32), img[..., 1].astype(np.float32), img[..., 2].astype(np.float32)
868
938
  return 0.2989 * r + 0.5870 * g + 0.1140 * b
869
939
 
@@ -898,27 +968,8 @@ def _exclude_bright_regions(gray_small: np.ndarray, exclusion_fraction: float =
898
968
 
899
969
 
900
970
  def _gradient_descent_to_dim_spot(gray_small: np.ndarray, x: int, y: int, patch: int) -> tuple[int, int]:
901
- H, W = gray_small.shape[:2]
902
- half = patch // 2
903
- def patch_median(px, py):
904
- x0, x1 = max(0, px - half), min(W, px + half + 1)
905
- y0, y1 = max(0, py - half), min(H, py + half + 1)
906
- return float(np.median(gray_small[y0:y1, x0:x1]))
907
- cx, cy = int(np.clip(x, 0, W-1)), int(np.clip(y, 0, H-1))
908
- for _ in range(60):
909
- cur = patch_median(cx, cy)
910
- best = (cx, cy); best_val = cur
911
- for nx in (cx-1, cx, cx+1):
912
- for ny in (cy-1, cy, cy+1):
913
- if nx == cx and ny == cy: continue
914
- if nx < 0 or ny < 0 or nx >= W or ny >= H: continue
915
- val = patch_median(nx, ny)
916
- if val < best_val:
917
- best_val = val; best = (nx, ny)
918
- if best == (cx, cy):
919
- break
920
- cx, cy = best
921
- return cx, cy
971
+ # Delegate to Numba optimized version
972
+ return gradient_descent_to_dim_spot_numba(gray_small, int(x), int(y), int(patch))
922
973
 
923
974
  def _generate_sample_points_small(
924
975
  img_small: np.ndarray,
@@ -3908,7 +3959,11 @@ class StackingSuiteDialog(QDialog):
3908
3959
  self._wrench_path = wrench_path
3909
3960
  self._spinner_path = spinner_path
3910
3961
  self._post_progress_label = None
3911
-
3962
+ self._dark_group_item = {} # key -> QTreeWidgetItem
3963
+ self._flat_filter_item = {} # filter_name -> QTreeWidgetItem
3964
+ self._flat_exp_item = {} # (filter_name, want_label) -> QTreeWidgetItem
3965
+ self._light_filter_item = {} # filter_name -> QTreeWidgetItem
3966
+ self._light_exp_item = {} # (filter_name, want_label) -> QTreeWidgetItem
3912
3967
 
3913
3968
  self.setWindowTitle(self.tr("Stacking Suite"))
3914
3969
  self.setGeometry(300, 200, 800, 600)
@@ -5115,8 +5170,9 @@ class StackingSuiteDialog(QDialog):
5115
5170
  disto_form.addRow(self.tr("Max control points:"), self.align_max_cp)
5116
5171
 
5117
5172
  self.align_downsample = QSpinBox()
5118
- self.align_downsample.setRange(1, 8)
5119
- self.align_downsample.setValue(self.settings.value("stacking/align/downsample", 2, type=int))
5173
+ self.align_downsample.setRange(1, 64) # or 1..32; 64 if you want “any integer”
5174
+ self.align_downsample.setValue(self.settings.value("stacking/align/downsample", 3, type=int))
5175
+ self.align_downsample.setToolTip(self.tr("Alignment solve downsample. 1 = full res; higher = faster but less accurate."))
5120
5176
  disto_form.addRow(self.tr("Solve downsample:"), self.align_downsample)
5121
5177
 
5122
5178
  # Homography / Similarity-specific RANSAC reprojection threshold
@@ -6004,12 +6060,19 @@ class StackingSuiteDialog(QDialog):
6004
6060
  w.blockSignals(True); w.setValue(v); w.blockSignals(False)
6005
6061
 
6006
6062
  def _get_drizzle_scale(self) -> float:
6007
- # Accepts "1x/2x/3x" or numeric
6008
- val = self.settings.value("stacking/drizzle_scale", "2x", type=str)
6009
- if isinstance(val, str) and val.endswith("x"):
6010
- try: return float(val[:-1])
6011
- except: return 2.0
6012
- return float(val)
6063
+ val = self.settings.value("stacking/drizzle_scale", "2x")
6064
+ if isinstance(val, (int, float)):
6065
+ return float(val)
6066
+ if isinstance(val, str):
6067
+ s = val.strip().lower()
6068
+ if s.endswith("x"):
6069
+ s = s[:-1]
6070
+ try:
6071
+ return float(s)
6072
+ except Exception:
6073
+ return 2.0
6074
+ return 2.0
6075
+
6013
6076
 
6014
6077
  def _set_drizzle_scale(self, r: float | str) -> None:
6015
6078
  if isinstance(r, str):
@@ -6025,6 +6088,25 @@ class StackingSuiteDialog(QDialog):
6025
6088
  self.drizzle_scale_combo.setCurrentText(txt)
6026
6089
  self.drizzle_scale_combo.blockSignals(False)
6027
6090
 
6091
+ def _get_drizzle_enabled(self) -> bool:
6092
+ # UI checkbox wins if it exists (most “live” truth)
6093
+ cb = getattr(self, "drizzle_checkbox", None)
6094
+ if cb is not None:
6095
+ try:
6096
+ return bool(cb.isChecked())
6097
+ except Exception:
6098
+ pass
6099
+ # fallback to settings (headless / older flows)
6100
+ return bool(self.settings.value("stacking/drizzle_enabled", False, type=bool))
6101
+
6102
+ def _set_drizzle_enabled(self, on: bool) -> None:
6103
+ on = bool(on)
6104
+ self.settings.setValue("stacking/drizzle_enabled", on)
6105
+ cb = getattr(self, "drizzle_checkbox", None)
6106
+ if cb is not None and cb.isChecked() != on:
6107
+ cb.blockSignals(True)
6108
+ cb.setChecked(on)
6109
+ cb.blockSignals(False)
6028
6110
 
6029
6111
  def closeEvent(self, e):
6030
6112
  # Graceful shutdown for any running workers
@@ -6340,7 +6422,7 @@ class StackingSuiteDialog(QDialog):
6340
6422
  dark_frames_layout.addLayout(btn_layout)
6341
6423
 
6342
6424
  self.clear_dark_selection_btn = QPushButton(self.tr("Clear Selection"))
6343
- self.clear_dark_selection_btn.clicked.connect(lambda: self.clear_tree_selection(self.dark_tree, self.dark_files))
6425
+ self.clear_dark_selection_btn.clicked.connect(lambda: self.clear_tree_selection_dark(self.dark_tree, self.dark_files))
6344
6426
  dark_frames_layout.addWidget(self.clear_dark_selection_btn)
6345
6427
 
6346
6428
  darks_layout.addLayout(dark_frames_layout, 2) # Dark Frames Tree takes more space
@@ -6413,6 +6495,9 @@ class StackingSuiteDialog(QDialog):
6413
6495
  )
6414
6496
  main_layout.addWidget(self.clear_master_dark_selection_btn)
6415
6497
 
6498
+ self.dark_tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
6499
+ self.dark_tree.customContextMenuRequested.connect(self.dark_tree_context_menu)
6500
+
6416
6501
  return tab
6417
6502
 
6418
6503
  def _tree_for_type(self, t: str):
@@ -6644,6 +6729,23 @@ class StackingSuiteDialog(QDialog):
6644
6729
 
6645
6730
  return tab
6646
6731
 
6732
+ def dark_tree_context_menu(self, pos):
6733
+ item = self.dark_tree.itemAt(pos)
6734
+ if not item:
6735
+ return
6736
+
6737
+ # ✅ same selection behavior
6738
+ if not item.isSelected():
6739
+ if not (QApplication.keyboardModifiers() & (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier)):
6740
+ self.dark_tree.clearSelection()
6741
+ item.setSelected(True)
6742
+
6743
+ menu = QMenu(self.dark_tree)
6744
+ set_session_action = menu.addAction(self.tr("Set Session Tag..."))
6745
+
6746
+ action = menu.exec(self.dark_tree.viewport().mapToGlobal(pos))
6747
+ if action == set_session_action:
6748
+ self.prompt_set_session(item, "DARK")
6647
6749
 
6648
6750
 
6649
6751
  def flat_tree_context_menu(self, pos):
@@ -6938,27 +7040,27 @@ class StackingSuiteDialog(QDialog):
6938
7040
  name = (self.settings.value("stacking/session_keyword", "Default", type=str) or "").strip()
6939
7041
  return name or "Default"
6940
7042
 
6941
- def _is_leaf_item(self, it: QTreeWidgetItem) -> bool:
6942
- return bool(it) and it.childCount() == 0 and it.parent() and it.parent().parent()
7043
+ def _is_leaf(it):
7044
+ # leaf == no children AND looks like a file row (has a filename)
7045
+ if not it or it.childCount() != 0:
7046
+ return False
7047
+ name = (it.text(0) or "").strip()
7048
+ # file rows in your UI are actual filenames
7049
+ return bool(name) and "." in name
7050
+
6943
7051
 
6944
7052
  def _iter_leaf_descendants(self, it: QTreeWidgetItem):
6945
- """Yield all leaf grandchildren under a filter row or exposure row."""
6946
7053
  if not it:
6947
7054
  return
6948
- # filter row (top-level): children are exposure rows
6949
- if it.parent() is None:
6950
- for j in range(it.childCount()):
6951
- exp = it.child(j)
6952
- for k in range(exp.childCount()):
6953
- leaf = exp.child(k)
6954
- if self._is_leaf_item(leaf):
6955
- yield leaf
6956
- # exposure row: children are leaves
6957
- elif it.parent() and it.parent().parent() is None and it.childCount() > 0:
6958
- for k in range(it.childCount()):
6959
- leaf = it.child(k)
6960
- if self._is_leaf_item(leaf):
6961
- yield leaf
7055
+ stack = [it]
7056
+ while stack:
7057
+ cur = stack.pop()
7058
+ if self._is_leaf_item(cur):
7059
+ yield cur
7060
+ continue
7061
+ for i in range(cur.childCount()):
7062
+ stack.append(cur.child(i))
7063
+
6962
7064
 
6963
7065
  def _collect_target_leaves(self, tree: QTreeWidget, clicked_item: QTreeWidgetItem | None = None) -> list[QTreeWidgetItem]:
6964
7066
  """
@@ -7074,43 +7176,59 @@ class StackingSuiteDialog(QDialog):
7074
7176
  return kw
7075
7177
 
7076
7178
  def prompt_set_session(self, item, frame_type):
7077
- text, ok = QInputDialog.getText(self, self.tr("Set Session Tag"), self.tr("Enter session name:"))
7179
+ text, ok = QInputDialog.getText(
7180
+ self,
7181
+ self.tr("Set Session Tag"),
7182
+ self.tr("Enter session name:")
7183
+ )
7078
7184
  if not (ok and (text or "").strip()):
7079
7185
  return
7080
7186
  session_name = text.strip()
7081
7187
 
7082
- is_flat = (frame_type or "").upper() == "FLAT"
7083
- tree = self.flat_tree if is_flat else self.light_tree
7084
- target_dict = self.flat_files if is_flat else self.light_files
7188
+ ft = (frame_type or "").upper()
7189
+ is_flat = (ft == "FLAT")
7190
+ is_light = (ft == "LIGHT")
7191
+ is_dark = (ft == "DARK")
7192
+
7193
+ if is_flat:
7194
+ tree = self.flat_tree
7195
+ target_dict = self.flat_files
7196
+ elif is_light:
7197
+ tree = self.light_tree
7198
+ target_dict = self.light_files
7199
+ elif is_dark:
7200
+ tree = self.dark_tree
7201
+ target_dict = self.dark_files
7202
+ else:
7203
+ return
7085
7204
 
7086
7205
  if not hasattr(self, "session_tags") or self.session_tags is None:
7087
7206
  self.session_tags = {}
7088
7207
 
7089
7208
  # --- helper: identify a "leaf" row in your tree (file row) ---
7090
7209
  def _is_leaf(it):
7091
- return bool(it) and it.childCount() == 0 and it.parent() and it.parent().parent()
7210
+ # leaf == no children AND looks like a file row (has a filename)
7211
+ if not it or it.childCount() != 0:
7212
+ return False
7213
+ name = (it.text(0) or "").strip()
7214
+ # file rows in your UI are actual filenames
7215
+ return bool(name) and "." in name
7216
+
7092
7217
 
7093
7218
  def _iter_leaf_descendants(parent_item):
7094
- """Yield all leaf file rows under a filter row or exposure row."""
7219
+ """Yield all leaf file rows under any parent row (any depth)."""
7095
7220
  if not parent_item:
7096
7221
  return
7097
- # top-level filter row
7098
- if parent_item.parent() is None:
7099
- for j in range(parent_item.childCount()):
7100
- exp = parent_item.child(j)
7101
- for k in range(exp.childCount()):
7102
- leaf = exp.child(k)
7103
- if _is_leaf(leaf):
7104
- yield leaf
7105
- # exposure row
7106
- elif parent_item.parent() and parent_item.parent().parent() is None and parent_item.childCount() > 0:
7107
- for k in range(parent_item.childCount()):
7108
- leaf = parent_item.child(k)
7109
- if _is_leaf(leaf):
7110
- yield leaf
7222
+ stack = [parent_item]
7223
+ while stack:
7224
+ cur = stack.pop()
7225
+ if _is_leaf(cur):
7226
+ yield cur
7227
+ continue
7228
+ for j in range(cur.childCount()):
7229
+ stack.append(cur.child(j))
7111
7230
 
7112
7231
  def _session_from_leaf(leaf):
7113
- # Prefer cached value (we’ll set it during ingest/retag)
7114
7232
  try:
7115
7233
  s = leaf.data(0, Qt.ItemDataRole.UserRole + 1)
7116
7234
  if isinstance(s, str) and s.strip():
@@ -7134,42 +7252,65 @@ class StackingSuiteDialog(QDialog):
7134
7252
  except Exception:
7135
7253
  pass
7136
7254
 
7137
- def _rekey_session_for_path(group_key, fpath, old_session, new_session):
7255
+ def _rekey_session_for_path(target_dict: dict, fpath: str, new_session: str, *, group_key_hint: str | None = None):
7138
7256
  """
7139
- Move fpath from (group_key, old_session) to (group_key, new_session).
7140
- Includes a fallback search if old_session is stale.
7257
+ Move fpath from whatever (group_key, old_session) bucket(s) it's currently in
7258
+ to (same_group_key, new_session).
7259
+
7260
+ This is robust even if the tree-derived group_key string doesn't exactly match
7261
+ the dict key[0] that was used when the file was added.
7141
7262
  """
7142
- old_ck = (group_key, old_session)
7143
- new_ck = (group_key, new_session)
7144
-
7145
- removed = False
7146
- if old_ck in target_dict and fpath in target_dict[old_ck]:
7147
- target_dict[old_ck] = [p for p in target_dict[old_ck] if p != fpath]
7148
- removed = True
7149
- if not target_dict[old_ck]:
7150
- del target_dict[old_ck]
7151
-
7152
- # fallback: find actual composite key containing this path for this group_key
7153
- if not removed:
7154
- found = None
7155
- for (gk, sess), lst in list(target_dict.items()):
7156
- if gk == group_key and fpath in lst:
7157
- found = (gk, sess)
7158
- break
7159
- if found:
7160
- target_dict[found] = [p for p in target_dict[found] if p != fpath]
7161
- if not target_dict[found]:
7162
- del target_dict[found]
7263
+ new_session = (new_session or "Default").strip() or "Default"
7264
+ f_norm = os.path.normcase(os.path.abspath(fpath))
7265
+
7266
+ # 1) Find all tuple-keys containing this file, regardless of session
7267
+ found_group_keys: list[str] = []
7268
+ keys_to_delete = []
7269
+
7270
+ for key, lst in list(target_dict.items()):
7271
+ if not (isinstance(key, tuple) and len(key) >= 2):
7272
+ continue
7273
+
7274
+ # check if file exists in this bucket
7275
+ keep = []
7276
+ removed_here = False
7277
+ for p in (lst or []):
7278
+ if os.path.normcase(os.path.abspath(p)) == f_norm:
7279
+ removed_here = True
7280
+ else:
7281
+ keep.append(p)
7282
+
7283
+ if removed_here:
7284
+ gk = str(key[0])
7285
+ if gk not in found_group_keys:
7286
+ found_group_keys.append(gk)
7287
+
7288
+ # write back / delete empty
7289
+ if removed_here:
7290
+ if keep:
7291
+ target_dict[key] = keep
7292
+ else:
7293
+ keys_to_delete.append(key)
7294
+
7295
+ for k in keys_to_delete:
7296
+ target_dict.pop(k, None)
7297
+
7298
+ # If not found anywhere (rare), fall back to hint so at least it gets added
7299
+ if not found_group_keys and group_key_hint:
7300
+ found_group_keys = [group_key_hint]
7301
+
7302
+ # 2) Add to new-session bucket(s)
7303
+ for gk in found_group_keys:
7304
+ new_key = (gk, new_session)
7305
+ cur = list(target_dict.get(new_key, []) or [])
7306
+ cur_norms = {os.path.normcase(os.path.abspath(p)) for p in cur}
7307
+ if f_norm not in cur_norms:
7308
+ cur.append(fpath)
7309
+ target_dict[new_key] = cur
7163
7310
 
7164
- # add to new key (avoid dupes)
7165
- target_dict.setdefault(new_ck, [])
7166
- if fpath not in target_dict[new_ck]:
7167
- target_dict[new_ck].append(fpath)
7168
7311
 
7169
7312
  # --- Build the set of leaf rows to retag ---
7170
7313
  selected = list(tree.selectedItems() or [])
7171
-
7172
- # Include the right-clicked item even if it wasn’t selected
7173
7314
  if item and item not in selected:
7174
7315
  selected.append(item)
7175
7316
 
@@ -7203,35 +7344,64 @@ class StackingSuiteDialog(QDialog):
7203
7344
  # fallback once for legacy rows missing UserRole
7204
7345
  if not fpath:
7205
7346
  filename = leaf.text(0).lstrip("⚠️ ").strip()
7206
- fpath = next(
7207
- (p for (gk, sess), lst in target_dict.items() for p in lst
7208
- if os.path.basename(p) == filename),
7209
- None
7210
- )
7347
+ # NOTE: this only works for tuple-keyed dicts; that's fine for flats/lights
7348
+ try:
7349
+ fpath = next(
7350
+ (p for (gk, sess), lst in target_dict.items() for p in (lst or [])
7351
+ if os.path.basename(p) == filename),
7352
+ None
7353
+ )
7354
+ except Exception:
7355
+ fpath = None
7211
7356
  if fpath:
7212
7357
  leaf.setData(0, Qt.ItemDataRole.UserRole, fpath)
7213
7358
 
7214
7359
  if not fpath:
7215
7360
  continue
7216
7361
 
7217
- # group_key MUST match your ingest keys: f"{filter} - {exposure_label}"
7218
- exposure_item = leaf.parent()
7219
- filter_item = exposure_item.parent() if exposure_item else None
7220
- if not (exposure_item and filter_item):
7362
+ parent = leaf.parent()
7363
+ grand = parent.parent() if parent else None
7364
+
7365
+ if parent is None:
7366
+ continue
7367
+
7368
+ if is_dark:
7369
+ # DARK tree is 2-level: group -> file
7370
+ group_key = parent.text(0)
7371
+
7372
+ elif is_flat:
7373
+ # FLAT tree is (typically) 3-level: filter -> group -> file
7374
+ # Your create_master_flat groups by EXACT string: "{filter} - {group}"
7375
+ # where group is like "Unknown (4096x4096)" (what the middle node shows).
7376
+ if grand is None:
7377
+ # If your flat tree is actually 2-level in some configs, fall back safely
7378
+ group_key = parent.text(0)
7379
+ else:
7380
+ group_key = f"{grand.text(0)} - {parent.text(0)}"
7381
+
7382
+ elif is_light:
7383
+ # LIGHT is 3-level: filter -> exposure -> file
7384
+ if grand is None:
7385
+ continue
7386
+ group_key = f"{grand.text(0)} - {parent.text(0)}"
7387
+ else:
7221
7388
  continue
7222
7389
 
7223
- group_key = f"{filter_item.text(0)} - {exposure_item.text(0)}"
7224
- old_session = _session_from_leaf(leaf)
7225
7390
 
7226
- if old_session != session_name:
7227
- _rekey_session_for_path(group_key, fpath, old_session, session_name)
7391
+ # We still compute group_key for a fallback hint, but removal is now bucket-scan based.
7392
+ if _session_from_leaf(leaf) != session_name:
7393
+ _rekey_session_for_path(target_dict, fpath, session_name, group_key_hint=group_key)
7228
7394
 
7395
+
7396
+ # Tag always updates UI + cache
7229
7397
  self.session_tags[fpath] = session_name
7230
7398
  _set_leaf_session_text(leaf, session_name)
7231
7399
  changed += 1
7232
7400
 
7233
- # ✅ Only LIGHT needs reassignment of best master files (session affects flat matching)
7234
- if not is_flat:
7401
+ self._normalize_sessioned_files_map(target_dict)
7402
+
7403
+ # Only LIGHT needs reassignment of best master files
7404
+ if is_light:
7235
7405
  try:
7236
7406
  self.assign_best_master_files(fill_only=True)
7237
7407
  except Exception:
@@ -7240,7 +7410,6 @@ class StackingSuiteDialog(QDialog):
7240
7410
  tree.viewport().update()
7241
7411
  self.update_status(self.tr(f"🟢 Assigned session '{session_name}' to {changed} file(s)."))
7242
7412
 
7243
-
7244
7413
  def _quad_coverage_add(self, cov: np.ndarray, quad: np.ndarray):
7245
7414
  """
7246
7415
  Rasterize a convex quad (4x2 float array of (x,y) in aligned coords) into 'cov' by +1 filling.
@@ -8467,6 +8636,28 @@ class StackingSuiteDialog(QDialog):
8467
8636
  self.settings.setValue("stacking/master_darks", dark_paths)
8468
8637
  self.settings.setValue("stacking/master_flats", flat_paths)
8469
8638
 
8639
+ def _purge_removed_paths(self, removed_paths: list[str]):
8640
+ if not removed_paths:
8641
+ return
8642
+ # purge session override cache
8643
+ if hasattr(self, "session_tags") and isinstance(self.session_tags, dict):
8644
+ for p in removed_paths:
8645
+ self.session_tags.pop(p, None)
8646
+
8647
+ # If you have any "ingested" caches, clear those too:
8648
+ if hasattr(self, "_ingested_paths") and isinstance(self._ingested_paths, set):
8649
+ for p in removed_paths:
8650
+ self._ingested_paths.discard(p)
8651
+
8652
+ if hasattr(self, "manual_flat_files") and isinstance(self.manual_flat_files, list):
8653
+ dead = {os.path.normcase(os.path.abspath(p)) for p in removed_paths if isinstance(p, str)}
8654
+ self.manual_flat_files = [p for p in self.manual_flat_files if os.path.normcase(os.path.abspath(p)) not in dead]
8655
+
8656
+ if hasattr(self, "manual_light_files") and isinstance(self.manual_light_files, list):
8657
+ dead = {os.path.normcase(os.path.abspath(p)) for p in removed_paths if isinstance(p, str)}
8658
+ self.manual_light_files = [p for p in self.manual_light_files if os.path.normcase(os.path.abspath(p)) not in dead]
8659
+
8660
+
8470
8661
  def clear_tree_selection(self, tree, file_dict):
8471
8662
  """Clears selected items from a simple (non-tuple-keyed) tree like Master Darks or Darks tab."""
8472
8663
  selected_items = tree.selectedItems()
@@ -8491,96 +8682,249 @@ class StackingSuiteDialog(QDialog):
8491
8682
  del file_dict[key]
8492
8683
  parent.removeChild(item)
8493
8684
 
8494
-
8495
- def clear_tree_selection_light(self, tree):
8496
- """Clears the selection in the light tree and updates self.light_files accordingly."""
8685
+ def clear_tree_selection_dark(self, tree, file_dict):
8497
8686
  selected_items = tree.selectedItems()
8498
8687
  if not selected_items:
8499
8688
  return
8500
8689
 
8690
+ removed_paths = []
8691
+
8501
8692
  for item in selected_items:
8502
8693
  parent = item.parent()
8694
+
8503
8695
  if parent is None:
8504
- # Top-level filter node selected
8505
- filter_name = item.text(0)
8506
- # Remove all composite keys whose group_key starts with filter_name
8507
- keys_to_remove = [key for key in list(self.light_files.keys())
8508
- if isinstance(key, tuple) and key[0].startswith(f"{filter_name} - ")]
8509
- for key in keys_to_remove:
8510
- del self.light_files[key]
8696
+ # top-level exposure group
8697
+ gk = item.text(0)
8698
+
8699
+ # remove ALL sessions for this exposure group
8700
+ keys_to_remove = []
8701
+ for k in list(file_dict.keys()):
8702
+ if isinstance(k, tuple) and len(k) >= 2:
8703
+ if str(k[0]) == gk:
8704
+ keys_to_remove.append(k)
8705
+ else:
8706
+ if str(k) == gk:
8707
+ keys_to_remove.append(k)
8708
+
8709
+ for k in keys_to_remove:
8710
+ for p in file_dict.get(k, []) or []:
8711
+ removed_paths.append(p)
8712
+ del file_dict[k]
8713
+
8511
8714
  tree.takeTopLevelItem(tree.indexOfTopLevelItem(item))
8512
- else:
8513
- if parent.parent() is None:
8514
- # Exposure node selected (child)
8515
- filter_name = parent.text(0)
8516
- exposure_text = item.text(0)
8517
- group_key = f"{filter_name} - {exposure_text}"
8518
- keys_to_remove = [key for key in list(self.light_files.keys())
8519
- if isinstance(key, tuple) and key[0] == group_key]
8520
- for key in keys_to_remove:
8521
- del self.light_files[key]
8522
- parent.removeChild(item)
8715
+ continue
8716
+
8717
+ # leaf file node under exposure group
8718
+ gk = parent.text(0)
8719
+ fpath = item.data(0, Qt.ItemDataRole.UserRole)
8720
+ filename = item.text(0).lstrip("⚠️ ").strip()
8721
+
8722
+ keys_to_check = []
8723
+ for k in list(file_dict.keys()):
8724
+ if isinstance(k, tuple) and len(k) >= 2:
8725
+ if str(k[0]) == gk:
8726
+ keys_to_check.append(k)
8523
8727
  else:
8524
- # Grandchild file node selected
8525
- filter_name = parent.parent().text(0)
8526
- exposure_text = parent.text(0)
8527
- group_key = f"{filter_name} - {exposure_text}"
8528
- filename = item.text(0)
8728
+ if str(k) == gk:
8729
+ keys_to_check.append(k)
8730
+
8731
+ for k in keys_to_check:
8732
+ lst = file_dict.get(k, []) or []
8733
+ new_lst = []
8734
+ for p in lst:
8735
+ if fpath and p == fpath:
8736
+ removed_paths.append(p)
8737
+ continue
8738
+ if (not fpath) and os.path.basename(p) == filename:
8739
+ removed_paths.append(p)
8740
+ continue
8741
+ new_lst.append(p)
8742
+ if new_lst:
8743
+ file_dict[k] = new_lst
8744
+ else:
8745
+ del file_dict[k]
8746
+
8747
+ parent.removeChild(item)
8748
+
8749
+ self._purge_removed_paths(removed_paths)
8750
+
8751
+ # normalize if sessioned (or if legacy)
8752
+ self._normalize_sessioned_files_map(file_dict)
8753
+
8754
+ def clear_tree_selection_light(self, tree):
8755
+ selected_items = tree.selectedItems()
8756
+ if not selected_items:
8757
+ return
8529
8758
 
8530
- keys_to_check = [key for key in list(self.light_files.keys())
8531
- if isinstance(key, tuple) and key[0] == group_key]
8759
+ removed_paths = []
8532
8760
 
8533
- for key in keys_to_check:
8534
- self.light_files[key] = [
8535
- f for f in self.light_files[key] if os.path.basename(f) != filename
8536
- ]
8537
- if not self.light_files[key]:
8538
- del self.light_files[key]
8539
- parent.removeChild(item)
8761
+ def _norm(p: str) -> str:
8762
+ return os.path.normcase(os.path.abspath(p))
8540
8763
 
8541
- self._refresh_light_tree_summaries()
8764
+ def _remove_path_everywhere(fpath: str):
8765
+ if not fpath:
8766
+ return
8767
+ f_norm = _norm(fpath)
8768
+ keys_to_delete = []
8769
+ for k, lst in list(self.light_files.items()):
8770
+ if not (isinstance(k, tuple) and len(k) >= 2):
8771
+ continue
8772
+ keep = []
8773
+ removed = False
8774
+ for p in (lst or []):
8775
+ if _norm(p) == f_norm:
8776
+ removed = True
8777
+ else:
8778
+ keep.append(p)
8779
+ if removed:
8780
+ removed_paths.append(fpath)
8781
+ if keep:
8782
+ self.light_files[k] = keep
8783
+ else:
8784
+ keys_to_delete.append(k)
8785
+ for k in keys_to_delete:
8786
+ self.light_files.pop(k, None)
8787
+
8788
+ def _collect_leaf_paths_under(node):
8789
+ out = []
8790
+ stack = [node]
8791
+ while stack:
8792
+ cur = stack.pop()
8793
+ if cur.childCount() == 0:
8794
+ fp = cur.data(0, Qt.ItemDataRole.UserRole)
8795
+ if isinstance(fp, str) and fp.strip():
8796
+ out.append(fp)
8797
+ continue
8798
+ for j in range(cur.childCount()):
8799
+ stack.append(cur.child(j))
8800
+ return out
8801
+
8802
+ for item in selected_items:
8803
+ parent = item.parent()
8804
+
8805
+ if parent is None:
8806
+ for fp in _collect_leaf_paths_under(item):
8807
+ _remove_path_everywhere(fp)
8808
+ idx = tree.indexOfTopLevelItem(item)
8809
+ if idx >= 0:
8810
+ tree.takeTopLevelItem(idx)
8811
+ continue
8812
+
8813
+ for fp in _collect_leaf_paths_under(item):
8814
+ _remove_path_everywhere(fp)
8815
+
8816
+ parent.removeChild(item)
8817
+
8818
+ self._purge_removed_paths(removed_paths)
8819
+ self._normalize_sessioned_files_map(self.light_files)
8820
+
8821
+ try:
8822
+ self.rebuild_light_tree()
8823
+ except Exception:
8824
+ try:
8825
+ self._refresh_light_tree_summaries()
8826
+ except Exception:
8827
+ pass
8828
+
8542
8829
 
8543
8830
  def clear_tree_selection_flat(self, tree, file_dict):
8544
- """Clears the selection in the given tree widget and removes items from the corresponding dictionary."""
8831
+ """
8832
+ Clears selection in FLATS tree and removes from (group_key, session)->[paths].
8833
+
8834
+ Works for BOTH layouts:
8835
+ - 2-level: group -> file leaves (current rebuild_flat_tree)
8836
+ - 3-level: filter -> exposure -> file leaves (older layout)
8837
+ """
8545
8838
  selected_items = tree.selectedItems()
8546
8839
  if not selected_items:
8547
8840
  return
8548
8841
 
8842
+ removed_paths = []
8843
+
8844
+ def _norm(p: str) -> str:
8845
+ return os.path.normcase(os.path.abspath(p))
8846
+
8847
+ def _remove_path_everywhere(fpath: str):
8848
+ """Remove fpath from ALL buckets in file_dict (robust against group_key mismatches)."""
8849
+ if not fpath:
8850
+ return
8851
+ f_norm = _norm(fpath)
8852
+
8853
+ keys_to_delete = []
8854
+ for k, lst in list(file_dict.items()):
8855
+ if not (isinstance(k, tuple) and len(k) >= 2):
8856
+ continue
8857
+ keep = []
8858
+ removed = False
8859
+ for p in (lst or []):
8860
+ if _norm(p) == f_norm:
8861
+ removed = True
8862
+ else:
8863
+ keep.append(p)
8864
+
8865
+ if removed:
8866
+ removed_paths.append(fpath)
8867
+ if keep:
8868
+ file_dict[k] = keep
8869
+ else:
8870
+ keys_to_delete.append(k)
8871
+
8872
+ for k in keys_to_delete:
8873
+ file_dict.pop(k, None)
8874
+
8875
+ def _collect_leaf_paths_under(node):
8876
+ """Return all descendant leaf file paths under a node (supports group nodes)."""
8877
+ out = []
8878
+ stack = [node]
8879
+ while stack:
8880
+ cur = stack.pop()
8881
+ if cur.childCount() == 0:
8882
+ fp = cur.data(0, Qt.ItemDataRole.UserRole)
8883
+ if isinstance(fp, str) and fp.strip():
8884
+ out.append(fp)
8885
+ continue
8886
+ for j in range(cur.childCount()):
8887
+ stack.append(cur.child(j))
8888
+ return out
8889
+
8890
+ # We’ll delete dict entries by file paths (most robust), then rebuild UI.
8549
8891
  for item in selected_items:
8550
8892
  parent = item.parent()
8551
8893
 
8552
- if parent:
8553
- # Grandchild level (actual file)
8554
- if parent.parent() is not None:
8555
- filter_name = parent.parent().text(0)
8556
- exposure_text = parent.text(0)
8557
- group_key = f"{filter_name} - {exposure_text}"
8558
- else:
8559
- # Exposure level
8560
- filter_name = parent.text(0)
8561
- exposure_text = item.text(0)
8562
- group_key = f"{filter_name} - {exposure_text}"
8894
+ if parent is None:
8895
+ # Selected a top-level node (either "group" in 2-level, or "filter" in 3-level).
8896
+ # Remove every leaf path under it from the dict.
8897
+ for fp in _collect_leaf_paths_under(item):
8898
+ _remove_path_everywhere(fp)
8563
8899
 
8564
- filename = item.text(0)
8900
+ # Remove UI node
8901
+ idx = tree.indexOfTopLevelItem(item)
8902
+ if idx >= 0:
8903
+ tree.takeTopLevelItem(idx)
8904
+ continue
8565
8905
 
8566
- # Remove from all matching (group_key, session) tuples
8567
- keys_to_check = [key for key in list(file_dict.keys())
8568
- if isinstance(key, tuple) and key[0] == group_key]
8906
+ # Selected a leaf or mid-level node; remove all descendant leaf paths
8907
+ for fp in _collect_leaf_paths_under(item):
8908
+ _remove_path_everywhere(fp)
8909
+
8910
+ # Remove UI node
8911
+ parent.removeChild(item)
8912
+
8913
+ # purge caches + normalize
8914
+ self._purge_removed_paths(removed_paths)
8915
+ self._normalize_sessioned_files_map(file_dict)
8916
+
8917
+ # Rebuild from dict (this ensures UI reflects the dict truth)
8918
+ try:
8919
+ self.rebuild_flat_tree()
8920
+ except Exception:
8921
+ # If you really don't want rebuild here, at least:
8922
+ try:
8923
+ self._refresh_flat_tree_summaries()
8924
+ except Exception:
8925
+ pass
8569
8926
 
8570
- for key in keys_to_check:
8571
- file_dict[key] = [f for f in file_dict[key] if os.path.basename(f) != filename]
8572
- if not file_dict[key]:
8573
- del file_dict[key]
8574
8927
 
8575
- parent.removeChild(item)
8576
- else:
8577
- # Top-level (filter group) selected
8578
- filter_name = item.text(0)
8579
- keys_to_remove = [key for key in list(file_dict.keys())
8580
- if isinstance(key, tuple) and key[0].startswith(f"{filter_name} - ")]
8581
- for key in keys_to_remove:
8582
- del file_dict[key]
8583
- tree.takeTopLevelItem(tree.indexOfTopLevelItem(item))
8584
8928
 
8585
8929
  def _sync_group_userrole(self, top_item: QTreeWidgetItem):
8586
8930
  paths = []
@@ -8603,9 +8947,13 @@ class StackingSuiteDialog(QDialog):
8603
8947
  # ensure attrs exist
8604
8948
  if not hasattr(self, "_reg_excluded_files"):
8605
8949
  self._reg_excluded_files = set()
8606
- if not hasattr(self, "deleted_calibrated_files"):
8607
- self.deleted_calibrated_files = []
8608
8950
 
8951
+ # Track "removed from Registration tab" for this session so stacking won't use them
8952
+ if (not hasattr(self, "deleted_calibrated_files")) or (self.deleted_calibrated_files is None):
8953
+ self.deleted_calibrated_files = set()
8954
+ elif isinstance(self.deleted_calibrated_files, list):
8955
+ # backward compat if you previously used list
8956
+ self.deleted_calibrated_files = set(self.deleted_calibrated_files)
8609
8957
  removed_paths = []
8610
8958
 
8611
8959
  for item in selected_items:
@@ -8657,22 +9005,27 @@ class StackingSuiteDialog(QDialog):
8657
9005
  # Keep parent's stored list in sync (your helper)
8658
9006
  self._sync_group_userrole(parent)
8659
9007
 
8660
- # Persist the exclusions so they won't reappear on refresh
8661
- self._reg_excluded_files.update(p for p in removed_paths if isinstance(p, str))
9008
+ # --- DO NOT persist exclusions for manual removals in reg tab ---
9009
+ # If you want a separate "Exclude" feature later, keep _reg_excluded_files for that.
9010
+ # For now, removing should be reversible via "Add Light Files".
8662
9011
 
8663
- # Maintain your legacy list too (if you still use it elsewhere)
8664
- for p in removed_paths:
8665
- if p not in self.deleted_calibrated_files:
8666
- self.deleted_calibrated_files.append(p)
9012
+ # Persist "removed from registration" list (session)
9013
+ dead = {os.path.normcase(os.path.abspath(p)) for p in removed_paths if isinstance(p, str)}
9014
+ if dead:
9015
+ self.deleted_calibrated_files |= dead
8667
9016
 
8668
- # Also prune manual list so it doesn't re-inject removed files
9017
+ # Also prune manual list so it doesn't re-inject removed files *in this session*
8669
9018
  if hasattr(self, "manual_light_files") and self.manual_light_files:
8670
- self.manual_light_files = [p for p in self.manual_light_files if p not in self._reg_excluded_files]
9019
+ self.manual_light_files = [
9020
+ p for p in self.manual_light_files
9021
+ if os.path.normcase(os.path.abspath(p)) not in dead
9022
+ ]
8671
9023
 
8672
- # Optional but helpful: rebuild so empty groups disappear cleanly
8673
- self.populate_calibrated_lights()
9024
+ # refresh UI
9025
+ # IMPORTANT: do NOT call populate_calibrated_lights() here, it can resurrect removed items
8674
9026
  self._refresh_reg_tree_summaries()
8675
9027
 
9028
+
8676
9029
  def rebuild_flat_tree(self):
8677
9030
  """Regroup flat frames in the flat_tree based on the exposure tolerance."""
8678
9031
  self.flat_tree.clear()
@@ -9017,6 +9370,22 @@ class StackingSuiteDialog(QDialog):
9017
9370
  return (f"Drizzle: True, Scale: {scale:g}x, Drop: {drop:.2f}"
9018
9371
  if enabled else "Drizzle: False")
9019
9372
 
9373
+ def _get_group_key(self, top_item) -> str:
9374
+ """Stable key for a group item; survives UI text decoration."""
9375
+ key = top_item.data(0, Qt.ItemDataRole.UserRole)
9376
+ if key:
9377
+ return str(key)
9378
+ # fallback to visible text if older items don't have it yet
9379
+ return str(top_item.text(0)).strip()
9380
+
9381
+ def _ensure_group_key(self, top_item, group_key: str | None = None) -> str:
9382
+ """Set canonical key on item if missing."""
9383
+ if group_key is None:
9384
+ group_key = str(top_item.text(0)).strip()
9385
+ if not top_item.data(0, Qt.ItemDataRole.UserRole):
9386
+ top_item.setData(0, Qt.ItemDataRole.UserRole, group_key)
9387
+ return str(group_key)
9388
+
9020
9389
  def _set_drizzle_on_items(self, items, enabled: bool, scale: float, drop: float):
9021
9390
  txt_on = self._format_drizzle_text(True, scale, drop)
9022
9391
  txt_off = self._format_drizzle_text(False, scale, drop)
@@ -9024,7 +9393,8 @@ class StackingSuiteDialog(QDialog):
9024
9393
  # dedupe child selection → parent group
9025
9394
  if it.parent() is not None:
9026
9395
  it = it.parent()
9027
- group_key = it.text(0)
9396
+ # Canonical key stored on the item (NOT display label)
9397
+ group_key = self._ensure_group_key(it)
9028
9398
  it.setText(2, txt_on if enabled else txt_off)
9029
9399
  self.per_group_drizzle[group_key] = {
9030
9400
  "enabled": bool(enabled),
@@ -9049,11 +9419,10 @@ class StackingSuiteDialog(QDialog):
9049
9419
  return
9050
9420
 
9051
9421
  for item in selected_items:
9052
- # If the user selected a child row, go up to its parent group
9053
9422
  if item.parent() is not None:
9054
9423
  item = item.parent()
9055
9424
 
9056
- group_key = item.text(0)
9425
+ group_key = self._ensure_group_key(item) # ✅ stable key
9057
9426
 
9058
9427
  if drizzle_enabled:
9059
9428
  # Show scale + drop shrink
@@ -9085,7 +9454,7 @@ class StackingSuiteDialog(QDialog):
9085
9454
  seen, targets = set(), []
9086
9455
  for it in sel:
9087
9456
  top = it if it.parent() is None else it.parent()
9088
- key = top.text(0)
9457
+ key = self._ensure_group_key(top)
9089
9458
  if key not in seen:
9090
9459
  seen.add(key); targets.append(top)
9091
9460
  else:
@@ -9108,7 +9477,7 @@ class StackingSuiteDialog(QDialog):
9108
9477
 
9109
9478
  out = {}
9110
9479
  for top in self._iter_group_items():
9111
- group_key = top.text(0)
9480
+ group_key = self._ensure_group_key(top) # ✅ stable key
9112
9481
  state = self.per_group_drizzle.get(group_key)
9113
9482
  if not state:
9114
9483
  state = {"enabled": global_enabled, "scale": global_scale, "drop": global_drop}
@@ -9186,6 +9555,58 @@ class StackingSuiteDialog(QDialog):
9186
9555
  self.add_directory(self.light_tree, "Select Light Directory", "LIGHT")
9187
9556
  self.assign_best_master_files()
9188
9557
 
9558
+ def _normalize_sessioned_files_map(self, files_map: dict):
9559
+ """
9560
+ Canonicalize dict that should be keyed like: (group_key, session) -> [paths]
9561
+
9562
+ - Drops empty lists
9563
+ - Dedupe paths
9564
+ - Coerces keys to (str, str)
9565
+ """
9566
+ if not isinstance(files_map, dict):
9567
+ return
9568
+
9569
+ new_map = {}
9570
+ for k, lst in list(files_map.items()):
9571
+ if not lst:
9572
+ continue
9573
+
9574
+ # Coerce key to (group_key, session)
9575
+ if isinstance(k, tuple) and len(k) >= 2:
9576
+ gk = str(k[0])
9577
+ sess = str(k[1])
9578
+ else:
9579
+ # legacy/no-session dict; keep but force Default
9580
+ gk = str(k)
9581
+ sess = "Default"
9582
+
9583
+ # Deduplicate paths while preserving order
9584
+ seen = set()
9585
+ out = []
9586
+ for p in lst:
9587
+ if not p:
9588
+ continue
9589
+ p = str(p)
9590
+ if p in seen:
9591
+ continue
9592
+ seen.add(p)
9593
+ out.append(p)
9594
+
9595
+ if not out:
9596
+ continue
9597
+
9598
+ ck = (gk, sess)
9599
+ if ck not in new_map:
9600
+ new_map[ck] = out
9601
+ else:
9602
+ # merge
9603
+ for p in out:
9604
+ if p not in new_map[ck]:
9605
+ new_map[ck].append(p)
9606
+
9607
+ files_map.clear()
9608
+ files_map.update(new_map)
9609
+
9189
9610
 
9190
9611
  def prompt_session_before_adding(self, frame_type, directory_mode=False):
9191
9612
  # Respect auto-detect; do nothing here if auto is ON
@@ -9678,24 +10099,32 @@ class StackingSuiteDialog(QDialog):
9678
10099
  manual_session_name = self._resolve_manual_session_name_for_ingest()
9679
10100
 
9680
10101
  added = 0
9681
- for i, path in enumerate(paths, start=1):
9682
- if dlg.wasCanceled():
9683
- break
9684
- try:
9685
- base = os.path.basename(path)
9686
- dlg.setLabelText(f"{base} ({i}/{total})")
9687
- QCoreApplication.processEvents()
10102
+ tree.setUpdatesEnabled(False)
10103
+ tree.blockSignals(True)
10104
+ try:
10105
+ for i, path in enumerate(paths, start=1):
10106
+ if dlg.wasCanceled():
10107
+ break
10108
+ try:
10109
+ base = os.path.basename(path)
10110
+ dlg.setLabelText(f"{base} ({i}/{total})")
10111
+ QCoreApplication.processEvents()
9688
10112
 
9689
- self.process_fits_header(
9690
- path, tree, expected_type,
9691
- manual_session_name=manual_session_name
9692
- )
9693
- added += 1
9694
- except Exception:
9695
- pass
10113
+ self.process_fits_header(
10114
+ path, tree, expected_type,
10115
+ manual_session_name=manual_session_name
10116
+ )
10117
+ added += 1
10118
+ except Exception:
10119
+ pass
10120
+
10121
+ dlg.setValue(i)
10122
+ QCoreApplication.processEvents()
10123
+ finally:
10124
+ tree.blockSignals(False)
10125
+ tree.setUpdatesEnabled(True)
10126
+ tree.viewport().update()
9696
10127
 
9697
- dlg.setValue(i)
9698
- QCoreApplication.processEvents()
9699
10128
 
9700
10129
  dlg.setValue(total)
9701
10130
  QCoreApplication.processEvents()
@@ -9898,16 +10327,16 @@ class StackingSuiteDialog(QDialog):
9898
10327
  if expected_type_u == "DARK":
9899
10328
  key = f"{exposure_text} ({image_size})"
9900
10329
  self.dark_files.setdefault(key, []).append(path)
9901
- self.session_tags[path] = session_tag # not strictly needed, but consistent
9902
10330
 
9903
- items = tree.findItems(key, Qt.MatchFlag.MatchExactly, 0)
9904
- exposure_item = items[0] if items else QTreeWidgetItem([key])
9905
- if not items:
10331
+ exposure_item = self._dark_group_item.get(key)
10332
+ if exposure_item is None:
10333
+ exposure_item = QTreeWidgetItem([key])
9906
10334
  tree.addTopLevelItem(exposure_item)
10335
+ self._dark_group_item[key] = exposure_item
9907
10336
 
9908
10337
  leaf = QTreeWidgetItem([os.path.basename(path), meta_text])
9909
10338
  leaf.setData(0, Qt.ItemDataRole.UserRole, path)
9910
- leaf.setData(0, Qt.ItemDataRole.UserRole + 1, session_tag) # ✅ helpful later for retag/rekey
10339
+ leaf.setData(0, Qt.ItemDataRole.UserRole + 1, session_tag)
9911
10340
  exposure_item.addChild(leaf)
9912
10341
 
9913
10342
  # === FLATs ===
@@ -9917,20 +10346,20 @@ class StackingSuiteDialog(QDialog):
9917
10346
  self.flat_files.setdefault(composite_key, []).append(path)
9918
10347
  self.session_tags[path] = session_tag
9919
10348
 
9920
- filter_items = tree.findItems(filter_name, Qt.MatchFlag.MatchExactly, 0)
9921
- filter_item = filter_items[0] if filter_items else QTreeWidgetItem([filter_name])
9922
- if not filter_items:
10349
+ filter_item = self._flat_filter_item.get(filter_name)
10350
+ if filter_item is None:
10351
+ filter_item = QTreeWidgetItem([filter_name])
9923
10352
  tree.addTopLevelItem(filter_item)
10353
+ self._flat_filter_item[filter_name] = filter_item
9924
10354
 
9925
10355
  want_label = f"{exposure_text} ({image_size})"
9926
- exposure_item = None
9927
- for i in range(filter_item.childCount()):
9928
- if filter_item.child(i).text(0) == want_label:
9929
- exposure_item = filter_item.child(i)
9930
- break
10356
+ exp_key = (filter_name, want_label)
10357
+
10358
+ exposure_item = self._flat_exp_item.get(exp_key)
9931
10359
  if exposure_item is None:
9932
10360
  exposure_item = QTreeWidgetItem([want_label])
9933
10361
  filter_item.addChild(exposure_item)
10362
+ self._flat_exp_item[exp_key] = exposure_item
9934
10363
 
9935
10364
  leaf = QTreeWidgetItem([os.path.basename(path), meta_text])
9936
10365
  leaf.setData(0, Qt.ItemDataRole.UserRole, path)
@@ -9944,23 +10373,25 @@ class StackingSuiteDialog(QDialog):
9944
10373
  self.light_files.setdefault(composite_key, []).append(path)
9945
10374
  self.session_tags[path] = session_tag
9946
10375
 
9947
- filter_items = tree.findItems(filter_name, Qt.MatchFlag.MatchExactly, 0)
9948
- filter_item = filter_items[0] if filter_items else QTreeWidgetItem([filter_name])
9949
- if not filter_items:
10376
+ # Cached filter item
10377
+ filter_item = self._light_filter_item.get(filter_name)
10378
+ if filter_item is None:
10379
+ filter_item = QTreeWidgetItem([filter_name])
9950
10380
  tree.addTopLevelItem(filter_item)
10381
+ self._light_filter_item[filter_name] = filter_item
9951
10382
 
9952
10383
  want_label = f"{exposure_text} ({image_size})"
9953
- exposure_item = None
9954
- for i in range(filter_item.childCount()):
9955
- if filter_item.child(i).text(0) == want_label:
9956
- exposure_item = filter_item.child(i)
9957
- break
10384
+ exp_key = (filter_name, want_label)
10385
+
10386
+ # Cached exposure item
10387
+ exposure_item = self._light_exp_item.get(exp_key)
9958
10388
  if exposure_item is None:
9959
10389
  exposure_item = QTreeWidgetItem([want_label])
9960
10390
  filter_item.addChild(exposure_item)
10391
+ self._light_exp_item[exp_key] = exposure_item
9961
10392
 
9962
10393
  leaf = QTreeWidgetItem([os.path.basename(path), meta_text])
9963
- leaf.setData(0, Qt.ItemDataRole.UserRole, path) # ✅ needed for date-aware flat fallback
10394
+ leaf.setData(0, Qt.ItemDataRole.UserRole, path) # ✅ keep this
9964
10395
  leaf.setData(0, Qt.ItemDataRole.UserRole + 1, session_tag)
9965
10396
  exposure_item.addChild(leaf)
9966
10397
 
@@ -10051,32 +10482,58 @@ class StackingSuiteDialog(QDialog):
10051
10482
  QMessageBox.warning(self, "Error", "Output directory is not set.")
10052
10483
  return
10053
10484
 
10054
- # Keep both paths available; we'll override algo selection per group.
10055
10485
  ui_algo = getattr(self, "calib_rejection_algorithm", "Windsorized Sigma Clipping")
10056
10486
  if ui_algo == "Weighted Windsorized Sigma Clipping":
10057
10487
  ui_algo = "Windsorized Sigma Clipping"
10058
10488
 
10059
10489
  exposure_tolerance = self.exposure_tolerance_spinbox.value()
10060
- dark_files_by_group: dict[tuple[float, str], list[str]] = {}
10061
10490
 
10062
10491
  # -------------------------------------------------------------------------
10063
- # Group darks by (exposure +/- tolerance, image size string)
10492
+ # Group darks by (exposure +/- tolerance, image size string, session)
10493
+ # self.dark_files can be either:
10494
+ # legacy: exposure_key -> [paths]
10495
+ # session: (exposure_key, session) -> [paths]
10064
10496
  # -------------------------------------------------------------------------
10065
- for exposure_key, file_list in self.dark_files.items():
10066
- # exposure_key is like "300.0s (4144x2822)"
10067
- exposure_time_str, image_size = exposure_key.split(" (")
10068
- image_size = image_size.rstrip(")")
10069
- exposure_time = float(exposure_time_str.replace("s", "")) if "Unknown" not in exposure_time_str else 0.0
10497
+ dark_files_by_group: dict[tuple[float, str, str], list[str]] = {} # (exp, size, session)->list
10498
+
10499
+ for key, file_list in (self.dark_files or {}).items():
10500
+ if isinstance(key, tuple) and len(key) >= 2:
10501
+ exposure_key = str(key[0])
10502
+ session = str(key[1]) if str(key[1]).strip() else "Default"
10503
+ else:
10504
+ exposure_key = str(key)
10505
+ session = "Default"
10506
+
10507
+ try:
10508
+ exposure_time_str, image_size = exposure_key.split(" (", 1)
10509
+ image_size = image_size.rstrip(")")
10510
+ except ValueError:
10511
+ # If some malformed key got in, skip safely
10512
+ continue
10513
+
10514
+ if "Unknown" in exposure_time_str:
10515
+ exposure_time = 0.0
10516
+ else:
10517
+ try:
10518
+ exposure_time = float(exposure_time_str.replace("s", "").strip())
10519
+ except Exception:
10520
+ exposure_time = 0.0
10070
10521
 
10071
10522
  matched_group = None
10072
- for (existing_exposure, existing_size) in dark_files_by_group.keys():
10073
- if abs(existing_exposure - exposure_time) <= exposure_tolerance and existing_size == image_size:
10074
- matched_group = (existing_exposure, existing_size)
10523
+ for (existing_exposure, existing_size, existing_session) in list(dark_files_by_group.keys()):
10524
+ if (
10525
+ existing_session == session
10526
+ and existing_size == image_size
10527
+ and abs(existing_exposure - exposure_time) <= exposure_tolerance
10528
+ ):
10529
+ matched_group = (existing_exposure, existing_size, existing_session)
10075
10530
  break
10531
+
10076
10532
  if matched_group is None:
10077
- matched_group = (exposure_time, image_size)
10533
+ matched_group = (exposure_time, image_size, session)
10078
10534
  dark_files_by_group[matched_group] = []
10079
- dark_files_by_group[matched_group].extend(file_list)
10535
+
10536
+ dark_files_by_group[matched_group].extend(file_list or [])
10080
10537
 
10081
10538
  master_dir = os.path.join(self.stacking_directory, "Master_Calibration_Files")
10082
10539
  os.makedirs(master_dir, exist_ok=True)
@@ -10085,7 +10542,7 @@ class StackingSuiteDialog(QDialog):
10085
10542
  # Informative status about discovery
10086
10543
  # -------------------------------------------------------------------------
10087
10544
  try:
10088
- n_groups = sum(1 for k, v in dark_files_by_group.items() if len(v) >= 2)
10545
+ n_groups = sum(1 for _, v in dark_files_by_group.items() if len(v) >= 2)
10089
10546
  total_files = sum(len(v) for v in dark_files_by_group.values())
10090
10547
  self.update_status(self.tr(
10091
10548
  f"🔎 Discovered {len(dark_files_by_group)} grouped exposures "
@@ -10096,15 +10553,15 @@ class StackingSuiteDialog(QDialog):
10096
10553
  QApplication.processEvents()
10097
10554
 
10098
10555
  # -------------------------------------------------------------------------
10099
- # Pre-count tiles for progress bar (using per-group safe chunk sizes)
10556
+ # Pre-count tiles for progress bar (per-group safe chunk sizes)
10100
10557
  # -------------------------------------------------------------------------
10101
10558
  total_tiles = 0
10102
- group_shapes: dict[tuple[float, str], tuple[int, int, int, int, int]] = {}
10559
+ group_shapes: dict[tuple[float, str, str], tuple[int, int, int, int, int]] = {} # (exp,size,session)->(H,W,C,ch,cw)
10103
10560
  pref_chunk_h = self.chunk_height
10104
10561
  pref_chunk_w = self.chunk_width
10105
- DTYPE = np.float32 # master darks are always 32-bit float internally
10562
+ DTYPE = np.float32
10106
10563
 
10107
- for (exposure_time, image_size), file_list in dark_files_by_group.items():
10564
+ for (exposure_time, image_size, session), file_list in dark_files_by_group.items():
10108
10565
  if len(file_list) < 2:
10109
10566
  continue
10110
10567
 
@@ -10117,16 +10574,12 @@ class StackingSuiteDialog(QDialog):
10117
10574
  C = max(1, C)
10118
10575
  N = len(file_list)
10119
10576
 
10120
- # Use the same safe-chunk logic as normal integration
10121
10577
  try:
10122
- chunk_h, chunk_w = compute_safe_chunk(
10123
- H, W, N, C, DTYPE, pref_chunk_h, pref_chunk_w
10124
- )
10578
+ chunk_h, chunk_w = compute_safe_chunk(H, W, N, C, DTYPE, pref_chunk_h, pref_chunk_w)
10125
10579
  except MemoryError:
10126
- # Fall back to user chunk config if memory check failed
10127
10580
  chunk_h, chunk_w = pref_chunk_h, pref_chunk_w
10128
10581
 
10129
- group_shapes[(exposure_time, image_size)] = (H, W, C, chunk_h, chunk_w)
10582
+ group_shapes[(exposure_time, image_size, session)] = (H, W, C, chunk_h, chunk_w)
10130
10583
  total_tiles += _count_tiles(H, W, chunk_h, chunk_w)
10131
10584
 
10132
10585
  if total_tiles == 0:
@@ -10139,7 +10592,7 @@ class StackingSuiteDialog(QDialog):
10139
10592
  QApplication.processEvents()
10140
10593
 
10141
10594
  # -------------------------------------------------------------------------
10142
- # Local CPU reducers for fallback (same behavior as before)
10595
+ # Local CPU reducers (unchanged)
10143
10596
  # -------------------------------------------------------------------------
10144
10597
  def _select_reducer(kind: str, N: int):
10145
10598
  if kind == "dark":
@@ -10149,8 +10602,7 @@ class StackingSuiteDialog(QDialog):
10149
10602
  return ("Simple Median (No Rejection)", {}, "median")
10150
10603
  else:
10151
10604
  return ("Trimmed Mean", {"trim_fraction": 0.05}, "trimmed")
10152
- else:
10153
- raise ValueError("wrong kind")
10605
+ raise ValueError("wrong kind")
10154
10606
 
10155
10607
  def _cpu_tile_median(ts4: np.ndarray) -> np.ndarray:
10156
10608
  return np.median(ts4, axis=0).astype(np.float32, copy=False)
@@ -10178,17 +10630,16 @@ class StackingSuiteDialog(QDialog):
10178
10630
  return out.astype(np.float32, copy=False)
10179
10631
 
10180
10632
  pd = _Progress(self, "Create Master Darks", total_tiles)
10181
-
10182
10633
  from concurrent.futures import ThreadPoolExecutor
10183
10634
 
10184
10635
  try:
10185
10636
  # ---------------------------------------------------------------------
10186
10637
  # Per-group stacking loop
10187
10638
  # ---------------------------------------------------------------------
10188
- for (exposure_time, image_size), file_list in dark_files_by_group.items():
10639
+ for (exposure_time, image_size, session), file_list in dark_files_by_group.items():
10189
10640
  if len(file_list) < 2:
10190
10641
  self.update_status(self.tr(
10191
- f"⚠️ Skipping {exposure_time}s ({image_size}) - Not enough frames to stack."
10642
+ f"⚠️ Skipping {exposure_time}s ({image_size}) [{session}] - Not enough frames to stack."
10192
10643
  ))
10193
10644
  QApplication.processEvents()
10194
10645
  continue
@@ -10198,21 +10649,17 @@ class StackingSuiteDialog(QDialog):
10198
10649
  break
10199
10650
 
10200
10651
  self.update_status(self.tr(
10201
- f"🟢 Processing {len(file_list)} darks for {exposure_time}s ({image_size}) exposure…"
10652
+ f"🟢 Processing {len(file_list)} darks for {exposure_time}s ({image_size}) in session '{session}'…"
10202
10653
  ))
10203
10654
  QApplication.processEvents()
10204
10655
 
10205
10656
  # --- reference shape and per-group chunk size ---
10206
- if (exposure_time, image_size) in group_shapes:
10207
- height, width, channels, chunk_height, chunk_width = group_shapes[
10208
- (exposure_time, image_size)
10209
- ]
10657
+ if (exposure_time, image_size, session) in group_shapes:
10658
+ height, width, channels, chunk_height, chunk_width = group_shapes[(exposure_time, image_size, session)]
10210
10659
  else:
10211
10660
  ref_data, _, _, _ = load_image(file_list[0])
10212
10661
  if ref_data is None:
10213
- self.update_status(self.tr(
10214
- f"❌ Failed to load reference {os.path.basename(file_list[0])}"
10215
- ))
10662
+ self.update_status(self.tr(f"❌ Failed to load reference {os.path.basename(file_list[0])}"))
10216
10663
  continue
10217
10664
  height, width = ref_data.shape[:2]
10218
10665
  channels = 1 if ref_data.ndim == 2 else 3
@@ -10220,31 +10667,25 @@ class StackingSuiteDialog(QDialog):
10220
10667
  N_tmp = len(file_list)
10221
10668
  try:
10222
10669
  chunk_height, chunk_width = compute_safe_chunk(
10223
- height, width, N_tmp, channels, DTYPE,
10224
- pref_chunk_h, pref_chunk_w
10670
+ height, width, N_tmp, channels, DTYPE, pref_chunk_h, pref_chunk_w
10225
10671
  )
10226
10672
  except MemoryError:
10227
10673
  chunk_height, chunk_width = pref_chunk_h, pref_chunk_w
10228
10674
 
10229
- channels = max(1, channels)
10230
10675
  N = len(file_list)
10231
10676
 
10232
- # --- choose reducer adaptively ---
10233
10677
  algo_name, params, cpu_label = _select_reducer("dark", N)
10234
10678
  use_gpu = bool(self._hw_accel_enabled()) and _torch_ok() and _gpu_algo_supported(algo_name)
10235
10679
  algo_brief = ("GPU" if use_gpu else "CPU") + " " + algo_name
10236
- self.update_status(self.tr(
10237
- f"⚙️ {algo_brief} selected for {N} frames (channels={channels})"
10238
- ))
10680
+ self.update_status(self.tr(f"⚙️ {algo_brief} selected for {N} frames (channels={channels})"))
10239
10681
  QApplication.processEvents()
10240
10682
 
10241
- # --- open all dark frames as memmapped sources (once per group) ---
10683
+ # --- open sources ---
10242
10684
  sources = []
10243
10685
  try:
10244
10686
  for p in file_list:
10245
- sources.append(_MMImage(p)) # same class used in normal integration
10687
+ sources.append(_MMImage(p))
10246
10688
  except Exception as e:
10247
- # Clean up any partially opened sources
10248
10689
  for s in sources:
10249
10690
  try:
10250
10691
  s.close()
@@ -10254,93 +10695,64 @@ class StackingSuiteDialog(QDialog):
10254
10695
  QApplication.processEvents()
10255
10696
  continue
10256
10697
 
10257
- # Temporary memmap for the master stack
10258
- memmap_path = os.path.join(
10259
- master_dir, f"temp_dark_{exposure_time}_{image_size}.dat"
10260
- )
10698
+ # Include session to prevent collisions
10699
+ memmap_path = os.path.join(master_dir, f"temp_dark_{session}_{exposure_time}_{image_size}.dat")
10700
+
10261
10701
  self.update_status(self.tr(
10262
10702
  f"🗂️ Creating temp memmap: {os.path.basename(memmap_path)} "
10263
10703
  f"(shape={height}×{width}×{channels}, dtype=float32)"
10264
10704
  ))
10265
10705
  QApplication.processEvents()
10266
- final_stacked = np.memmap(
10267
- memmap_path,
10268
- dtype=np.float32,
10269
- mode="w+",
10270
- shape=(height, width, channels),
10271
- )
10272
10706
 
10273
- # Tile grid for this group
10707
+ final_stacked = np.memmap(memmap_path, dtype=np.float32, mode="w+", shape=(height, width, channels))
10708
+
10274
10709
  tiles = _tile_grid(height, width, chunk_height, chunk_width)
10275
10710
  total_tiles_group = len(tiles)
10276
10711
  self.update_status(self.tr(
10277
- f"📦 {total_tiles_group} tiles to process for this group "
10278
- f"(chunk {chunk_height}×{chunk_width})."
10712
+ f"📦 {total_tiles_group} tiles to process for this group (chunk {chunk_height}×{chunk_width})."
10279
10713
  ))
10280
10714
  QApplication.processEvents()
10281
10715
 
10282
- # --- reusable double-buffer tile storage ---
10283
- buf0 = np.empty(
10284
- (N, chunk_height, chunk_width, channels),
10285
- dtype=np.float32,
10286
- order="C",
10287
- )
10716
+ buf0 = np.empty((N, chunk_height, chunk_width, channels), dtype=np.float32, order="C")
10288
10717
  buf1 = np.empty_like(buf0)
10289
10718
 
10290
- # Helper: read one tile into the given buffer from all memmapped sources
10291
10719
  def _read_tile_into(buf, y0, y1, x0, x1):
10292
10720
  th = y1 - y0
10293
10721
  tw = x1 - x0
10294
10722
  ts = buf[:N, :th, :tw, :channels]
10295
10723
  for i, src in enumerate(sources):
10296
- sub = src.read_tile(y0, y1, x0, x1) # float32, (th,tw) or (th,tw,3)
10724
+ sub = src.read_tile(y0, y1, x0, x1)
10297
10725
  if sub.ndim == 2:
10298
- if channels == 3:
10299
- sub = sub[:, :, None].repeat(3, axis=2)
10300
- else:
10301
- sub = sub[:, :, None]
10726
+ sub = sub[:, :, None] if channels == 1 else sub[:, :, None].repeat(3, axis=2)
10302
10727
  ts[i, :, :, :] = sub
10303
- return th, tw # actual extents for edge tiles
10728
+ return th, tw
10304
10729
 
10305
10730
  tp = ThreadPoolExecutor(max_workers=1)
10306
10731
 
10307
- # Prime first read
10308
10732
  (y0, y1, x0, x1) = tiles[0]
10309
10733
  fut = tp.submit(_read_tile_into, buf0, y0, y1, x0, x1)
10310
10734
  use0 = True
10311
-
10312
- # Uniform weights for darks (no quality weighting)
10313
10735
  weights_np = np.ones((N,), dtype=np.float32)
10314
10736
 
10315
- # --- per-tile loop ---
10316
10737
  cancelled_group = False
10317
10738
  for t_idx, (y0, y1, x0, x1) in enumerate(tiles, start=1):
10318
10739
  if pd.cancelled:
10319
10740
  cancelled_group = True
10320
- self.update_status(self.tr(
10321
- "⛔ Master Dark creation cancelled during tile processing."
10322
- ))
10741
+ self.update_status(self.tr("⛔ Master Dark creation cancelled during tile processing."))
10323
10742
  break
10324
10743
 
10325
10744
  th, tw = fut.result()
10326
10745
  ts_np = (buf0 if use0 else buf1)[:N, :th, :tw, :channels]
10327
10746
 
10328
- # Prefetch next tile
10329
10747
  if t_idx < total_tiles_group:
10330
10748
  ny0, ny1, nx0, nx1 = tiles[t_idx]
10331
- fut = tp.submit(
10332
- _read_tile_into,
10333
- (buf1 if use0 else buf0),
10334
- ny0, ny1, nx0, nx1,
10335
- )
10749
+ fut = tp.submit(_read_tile_into, (buf1 if use0 else buf0), ny0, ny1, nx0, nx1)
10336
10750
 
10337
10751
  pd.set_label(
10338
- f"{int(exposure_time)}s ({image_size}) — "
10339
- f"tile {t_idx}/{total_tiles_group} "
10340
- f"y:{y0}-{y1} x:{x0}-{x1}"
10752
+ f"{int(exposure_time)}s ({image_size}) [{session}] — "
10753
+ f"tile {t_idx}/{total_tiles_group} y:{y0}-{y1} x:{x0}-{x1}"
10341
10754
  )
10342
10755
 
10343
- # ---- reduction (GPU or CPU) ----
10344
10756
  if use_gpu:
10345
10757
  tile_result, _ = _torch_reduce_tile(
10346
10758
  ts_np,
@@ -10350,59 +10762,39 @@ class StackingSuiteDialog(QDialog):
10350
10762
  iterations=int(params.get("iterations", getattr(self, "iterations", 1))),
10351
10763
  sigma_low=float(getattr(self, "sigma_low", 2.5)),
10352
10764
  sigma_high=float(getattr(self, "sigma_high", 2.5)),
10353
- trim_fraction=float(
10354
- params.get("trim_fraction", getattr(self, "trim_fraction", 0.05))
10355
- ),
10765
+ trim_fraction=float(params.get("trim_fraction", getattr(self, "trim_fraction", 0.05))),
10356
10766
  esd_threshold=float(getattr(self, "esd_threshold", 3.0)),
10357
- biweight_constant=float(
10358
- getattr(self, "biweight_constant", 6.0)
10359
- ),
10767
+ biweight_constant=float(getattr(self, "biweight_constant", 6.0)),
10360
10768
  modz_threshold=float(getattr(self, "modz_threshold", 3.5)),
10361
- comet_hclip_k=float(
10362
- self.settings.value("stacking/comet_hclip_k", 1.30, type=float)
10363
- ),
10364
- comet_hclip_p=float(
10365
- self.settings.value("stacking/comet_hclip_p", 25.0, type=float)
10366
- ),
10769
+ comet_hclip_k=float(self.settings.value("stacking/comet_hclip_k", 1.30, type=float)),
10770
+ comet_hclip_p=float(self.settings.value("stacking/comet_hclip_p", 25.0, type=float)),
10367
10771
  )
10368
10772
  else:
10369
10773
  if cpu_label == "median":
10370
10774
  tile_result = _cpu_tile_median(ts_np)
10371
10775
  elif cpu_label == "trimmed":
10372
- tile_result = _cpu_tile_trimmed_mean(
10373
- ts_np,
10374
- float(params.get("trim_fraction", 0.05)),
10375
- )
10376
- else: # 'kappa1'
10377
- tile_result = _cpu_tile_kappa_sigma_1iter(
10378
- ts_np,
10379
- float(params.get("kappa", 3.0)),
10380
- )
10776
+ tile_result = _cpu_tile_trimmed_mean(ts_np, float(params.get("trim_fraction", 0.05)))
10777
+ else:
10778
+ tile_result = _cpu_tile_kappa_sigma_1iter(ts_np, float(params.get("kappa", 3.0)))
10381
10779
 
10382
- # Ensure tile_result has correct shape (th, tw, channels)
10383
10780
  if tile_result.ndim == 2:
10384
10781
  tile_result = tile_result[:, :, None]
10385
10782
  expected_shape = (th, tw, channels)
10386
10783
  if tile_result.shape != expected_shape:
10387
- if tile_result.shape[2] == 0:
10388
- tile_result = np.zeros(expected_shape, dtype=np.float32)
10389
- elif tile_result.shape[:2] == (th, tw):
10784
+ if tile_result.shape[:2] == (th, tw):
10390
10785
  if tile_result.shape[2] > channels:
10391
10786
  tile_result = tile_result[:, :, :channels]
10392
10787
  else:
10393
- tile_result = np.repeat(
10394
- tile_result, channels, axis=2
10395
- )[:, :, :channels]
10788
+ tile_result = np.repeat(tile_result, channels, axis=2)[:, :, :channels]
10789
+ else:
10790
+ tile_result = np.zeros(expected_shape, dtype=np.float32)
10396
10791
 
10397
- # Commit tile result into final memmap
10398
10792
  final_stacked[y0:y1, x0:x1, :] = tile_result
10399
-
10400
10793
  pd.step()
10401
10794
  use0 = not use0
10402
10795
 
10403
10796
  tp.shutdown(wait=True)
10404
10797
 
10405
- # Close memmapped sources for this group
10406
10798
  for s in sources:
10407
10799
  try:
10408
10800
  s.close()
@@ -10410,9 +10802,7 @@ class StackingSuiteDialog(QDialog):
10410
10802
  pass
10411
10803
 
10412
10804
  if cancelled_group:
10413
- self.update_status(self.tr(
10414
- "⛔ Master Dark creation cancelled; cleaning up temporary files."
10415
- ))
10805
+ self.update_status(self.tr("⛔ Master Dark creation cancelled; cleaning up temporary files."))
10416
10806
  try:
10417
10807
  del final_stacked
10418
10808
  except Exception:
@@ -10423,7 +10813,6 @@ class StackingSuiteDialog(QDialog):
10423
10813
  pass
10424
10814
  break
10425
10815
 
10426
- # Convert memmap to regular array and free the file
10427
10816
  master_dark_data = np.asarray(final_stacked, dtype=np.float32)
10428
10817
  del final_stacked
10429
10818
  gc.collect()
@@ -10432,38 +10821,29 @@ class StackingSuiteDialog(QDialog):
10432
10821
  except Exception:
10433
10822
  pass
10434
10823
 
10435
- master_dark_stem = f"MasterDark_{int(exposure_time)}s_{image_size}"
10824
+ # Include session in output name
10825
+ master_dark_stem = f"MasterDark_{session}_{int(exposure_time)}s_{image_size}"
10436
10826
  master_dark_path = self._build_out(master_dir, master_dark_stem, "fit")
10437
10827
 
10438
10828
  master_header = fits.Header()
10439
10829
  master_header["IMAGETYP"] = "DARK"
10440
- master_header["EXPTIME"] = (
10441
- exposure_time,
10442
- "User-specified or from grouping",
10443
- )
10830
+ master_header["EXPTIME"] = (exposure_time, "User-specified or from grouping")
10831
+ master_header["SESSION"] = (session, "User session tag") # optional but useful
10444
10832
  master_header["NAXIS"] = 3 if channels == 3 else 2
10445
10833
  master_header["NAXIS1"] = master_dark_data.shape[1]
10446
10834
  master_header["NAXIS2"] = master_dark_data.shape[0]
10447
10835
  if channels == 3:
10448
10836
  master_header["NAXIS3"] = 3
10449
10837
 
10450
- save_image(
10451
- master_dark_data,
10452
- master_dark_path,
10453
- "fit",
10454
- "32-bit floating point",
10455
- master_header,
10456
- is_mono=(channels == 1),
10457
- )
10458
- self.add_master_dark_to_tree(
10459
- f"{exposure_time}s ({image_size})", master_dark_path
10460
- )
10838
+ save_image(master_dark_data, master_dark_path, "fit", "32-bit floating point", master_header, is_mono=(channels == 1))
10839
+
10840
+ self.add_master_dark_to_tree(f"{exposure_time}s ({image_size}) [{session}]", master_dark_path)
10461
10841
  self.update_status(self.tr(f"✅ Master Dark saved: {master_dark_path}"))
10462
10842
  QApplication.processEvents()
10843
+
10463
10844
  self.assign_best_master_files()
10464
10845
  self.save_master_paths_to_settings()
10465
10846
 
10466
- # wrap-up
10467
10847
  self.assign_best_master_dark()
10468
10848
  self.update_override_dark_combo()
10469
10849
  self.assign_best_master_files()
@@ -10475,6 +10855,7 @@ class StackingSuiteDialog(QDialog):
10475
10855
  import logging
10476
10856
  logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
10477
10857
  pd.close()
10858
+
10478
10859
 
10479
10860
  def add_master_dark_to_tree(self, exposure_label: str, master_dark_path: str):
10480
10861
  """
@@ -10691,7 +11072,15 @@ class StackingSuiteDialog(QDialog):
10691
11072
  # -------------------------------------------------------------------------
10692
11073
  # Group flats exactly as before
10693
11074
  # -------------------------------------------------------------------------
10694
- for (filter_exposure, session), file_list in self.flat_files.items():
11075
+ for key, file_list in (self.flat_files or {}).items():
11076
+ # Support both legacy and new key formats
11077
+ if isinstance(key, tuple) and len(key) >= 2:
11078
+ filter_exposure = str(key[0])
11079
+ session = str(key[1] or "Default").strip() or "Default"
11080
+ else:
11081
+ filter_exposure = str(key)
11082
+ session = "Default"
11083
+
10695
11084
  try:
10696
11085
  filter_name, exposure_size = filter_exposure.split(" - ")
10697
11086
  exposure_time_str, image_size = exposure_size.split(" (")
@@ -10704,21 +11093,35 @@ class StackingSuiteDialog(QDialog):
10704
11093
  exposure_time = float(match.group(1)) if match else -10.0
10705
11094
 
10706
11095
  matched_group = None
10707
- for key in flat_files_by_group:
10708
- existing_exposure, existing_size, existing_filter, existing_session = key
11096
+ for k in flat_files_by_group:
11097
+ existing_exposure, existing_size, existing_filter, existing_session = k
10709
11098
  if (
10710
11099
  abs(existing_exposure - exposure_time) <= exposure_tolerance
10711
11100
  and existing_size == image_size
10712
11101
  and existing_filter == filter_name
10713
11102
  and existing_session == session
10714
11103
  ):
10715
- matched_group = key
11104
+ matched_group = k
10716
11105
  break
10717
11106
 
10718
11107
  if matched_group is None:
10719
11108
  matched_group = (exposure_time, image_size, filter_name, session)
10720
11109
  flat_files_by_group[matched_group] = []
10721
- flat_files_by_group[matched_group].extend(file_list)
11110
+
11111
+ flat_files_by_group[matched_group].extend(file_list or [])
11112
+
11113
+ # Dedupe paths within each group (prevents accidental double-counts)
11114
+ for k, lst in list(flat_files_by_group.items()):
11115
+ seen = set()
11116
+ out = []
11117
+ for p in (lst or []):
11118
+ pn = os.path.normcase(os.path.abspath(p))
11119
+ if pn in seen:
11120
+ continue
11121
+ seen.add(pn)
11122
+ out.append(p)
11123
+ flat_files_by_group[k] = out
11124
+
10722
11125
 
10723
11126
  # Discovery summary
10724
11127
  try:
@@ -12522,6 +12925,20 @@ class StackingSuiteDialog(QDialog):
12522
12925
  "drop": float(self.drizzle_drop_shrink_spin.value())
12523
12926
  }
12524
12927
 
12928
+ def _global_drizzle_state(self) -> dict:
12929
+ # UI is the source of truth at runtime
12930
+ enabled = bool(self.drizzle_checkbox.isChecked())
12931
+
12932
+ # Scale from combo text like "1x", "2x", "3x"
12933
+ try:
12934
+ scale = float(self.drizzle_scale_combo.currentText().replace("x", "", 1).strip())
12935
+ except Exception:
12936
+ scale = 1.0
12937
+
12938
+ drop = float(self.drizzle_drop_shrink_spin.value())
12939
+
12940
+ return {"enabled": enabled, "scale": scale, "drop": drop}
12941
+
12525
12942
  def _split_dual_band_osc(self, selected_groups=None):
12526
12943
  """
12527
12944
  Create mono Ha/SII/OIII frames from dual-band OSC files and
@@ -13284,6 +13701,24 @@ class StackingSuiteDialog(QDialog):
13284
13701
  self.update_status(self.tr("🔄 Image Registration Started..."))
13285
13702
  self.extract_light_files_from_tree(debug=True)
13286
13703
 
13704
+ # --- Apply "removed from Registration tab" exclusions (session-level) ---
13705
+ dead = set()
13706
+ if hasattr(self, "deleted_calibrated_files") and self.deleted_calibrated_files:
13707
+ dead = set(self.deleted_calibrated_files)
13708
+
13709
+ if dead:
13710
+ for g in list(self.light_files.keys()):
13711
+ self.light_files[g] = [
13712
+ p for p in self.light_files[g]
13713
+ if os.path.normcase(os.path.abspath(p)) not in dead
13714
+ ]
13715
+ if not self.light_files[g]:
13716
+ del self.light_files[g]
13717
+
13718
+ self.update_status(self.tr(f"🚫 Excluding {len(dead)} removed frame(s) from registration/stacking."))
13719
+ QApplication.processEvents()
13720
+
13721
+
13287
13722
  comet_mode = bool(getattr(self, "comet_cb", None) and self.comet_cb.isChecked())
13288
13723
  if comet_mode:
13289
13724
  self.update_status(self.tr("🌠 Comet mode: please click the comet center to continue…"))
@@ -14896,6 +15331,14 @@ class StackingSuiteDialog(QDialog):
14896
15331
  # Snapshot UI-dependent settings (your existing code)
14897
15332
  # ----------------------------
14898
15333
  drizzle_dict = self.gather_drizzle_settings_from_tree()
15334
+ try:
15335
+ self.update_status(self.tr(
15336
+ "🧾 Drizzle dict: " + ", ".join(f"{k}:{'ON' if v.get('drizzle_enabled') else 'off'}"
15337
+ for k, v in drizzle_dict.items())
15338
+ ))
15339
+ except Exception:
15340
+ pass
15341
+ QApplication.processEvents()
14899
15342
  try:
14900
15343
  autocrop_enabled = self.autocrop_cb.isChecked()
14901
15344
  autocrop_pct = float(self.autocrop_pct.value())
@@ -15310,6 +15753,22 @@ class StackingSuiteDialog(QDialog):
15310
15753
 
15311
15754
  self._set_registration_busy(False)
15312
15755
 
15756
+ def _on_after_align_finished(self, success: bool, message: str):
15757
+ # Stop thread/progress UI first (whatever you already do)
15758
+
15759
+ if success:
15760
+ QMessageBox.information(
15761
+ self,
15762
+ self.tr("Stacking Complete"),
15763
+ message
15764
+ )
15765
+ else:
15766
+ QMessageBox.critical(
15767
+ self,
15768
+ self.tr("Stacking Failed"),
15769
+ message
15770
+ )
15771
+
15313
15772
  def _on_mf_progress(self, s: str):
15314
15773
  # Mirror non-token messages
15315
15774
  if not s.startswith("__PROGRESS__"):
@@ -15338,25 +15797,48 @@ class StackingSuiteDialog(QDialog):
15338
15797
 
15339
15798
  @pyqtSlot(bool, str)
15340
15799
  def _on_post_pipeline_finished(self, ok: bool, message: str):
15800
+ # ---- close progress dialog ----
15341
15801
  try:
15342
- if getattr(self, "post_progress", None):
15802
+ if getattr(self, "post_progress", None) is not None:
15343
15803
  self.post_progress.close()
15804
+ self.post_progress.deleteLater()
15344
15805
  self.post_progress = None
15345
15806
  except Exception:
15346
15807
  pass
15347
15808
 
15809
+ # ---- stop thread ----
15348
15810
  try:
15349
- self.post_thread.quit()
15350
- self.post_thread.wait()
15811
+ if getattr(self, "post_thread", None) is not None:
15812
+ self.post_thread.quit()
15813
+ self.post_thread.wait()
15351
15814
  except Exception:
15352
15815
  pass
15816
+
15817
+ # ---- cleanup objects ----
15353
15818
  try:
15354
- self.post_worker.deleteLater()
15355
- self.post_thread.deleteLater()
15819
+ if getattr(self, "post_worker", None) is not None:
15820
+ self.post_worker.deleteLater()
15821
+ self.post_worker = None
15822
+ if getattr(self, "post_thread", None) is not None:
15823
+ self.post_thread.deleteLater()
15824
+ self.post_thread = None
15356
15825
  except Exception:
15357
15826
  pass
15358
15827
 
15359
- self.update_status(self.tr(message))
15828
+ # ---- update status (keep this behavior) ----
15829
+ try:
15830
+ # message already includes "Post-alignment complete..." text
15831
+ self.update_status(self.tr(message))
15832
+ except Exception:
15833
+ pass
15834
+
15835
+ # ---- popup summary ----
15836
+ # (Do this after progress dialog is gone so it doesn't hide behind it)
15837
+ if ok:
15838
+ QMessageBox.information(self, self.tr("Post-Alignment Complete"), message)
15839
+ else:
15840
+ QMessageBox.critical(self, self.tr("Post-Alignment Failed"), message)
15841
+
15360
15842
  self._cfa_for_this_run = None
15361
15843
  QApplication.processEvents()
15362
15844
 
@@ -15463,6 +15945,8 @@ class StackingSuiteDialog(QDialog):
15463
15945
  log(f"📁 Post-align: {n_groups} group(s), {n_frames} aligned frame(s).")
15464
15946
  QApplication.processEvents()
15465
15947
 
15948
+ drizzle_enabled_global = self._get_drizzle_enabled()
15949
+
15466
15950
  # Precompute a single global crop rect if enabled (pure computation, no UI).
15467
15951
  global_rect = None
15468
15952
  if autocrop_enabled:
@@ -15896,8 +16380,7 @@ class StackingSuiteDialog(QDialog):
15896
16380
  log(f"✂️ Saved CometBlend (auto-cropped) → {blend_path_crop}")
15897
16381
 
15898
16382
  # ---- Drizzle bookkeeping for this group ----
15899
- dconf = drizzle_dict.get(group_key, {})
15900
- if dconf.get("drizzle_enabled", False):
16383
+ if drizzle_enabled_global:
15901
16384
  sasr_path = os.path.join(self.stacking_directory, f"{group_key}_rejections.sasr")
15902
16385
  self.save_rejection_map_sasr(rejection_map, sasr_path)
15903
16386
  log(f"✅ Saved rejection map to {sasr_path}")
@@ -15929,17 +16412,17 @@ class StackingSuiteDialog(QDialog):
15929
16412
  originals_by_group[group] = orig_list
15930
16413
  # ---- Drizzle pass (only for groups with drizzle enabled) ----
15931
16414
  for group_key, file_list in grouped_files.items():
15932
- dconf = drizzle_dict.get(group_key)
15933
- if not (dconf and dconf.get("drizzle_enabled", False)):
15934
- log(f"✅ Group '{group_key}' not set for drizzle. Integrated image already saved.")
16415
+ if not drizzle_enabled_global:
16416
+ log(f"✅ Drizzle disabled (checkbox off). Group '{group_key}' integrated image already saved.")
15935
16417
  continue
15936
16418
 
16419
+ # Use your existing getters (they can read UI/settings)
15937
16420
  scale_factor = self._get_drizzle_scale()
15938
16421
  drop_shrink = self._get_drizzle_pixfrac()
15939
16422
 
15940
- # Optional: also read kernel for logging/branching
15941
16423
  kernel = (self.settings.value("stacking/drizzle_kernel", "square", type=str) or "square").lower()
15942
- status_cb(f"Drizzle cfg → scale={scale_factor}×, pixfrac={drop_shrink:.3f}, kernel={kernel}")
16424
+ log(f"Drizzle cfg → scale={scale_factor}×, pixfrac={drop_shrink:.3f}, kernel={kernel}")
16425
+
15943
16426
  rejections_for_group = group_integration_data[group_key]["rejection_map"]
15944
16427
  n_frames_group = group_integration_data[group_key]["n_frames"]
15945
16428
 
@@ -15947,8 +16430,8 @@ class StackingSuiteDialog(QDialog):
15947
16430
 
15948
16431
  self.drizzle_stack_one_group(
15949
16432
  group_key=group_key,
15950
- file_list=file_list, # registered (for headers/labels)
15951
- original_list=originals_by_group.get(group_key, []), # <-- NEW
16433
+ file_list=file_list,
16434
+ original_list=originals_by_group.get(group_key, []),
15952
16435
  transforms_dict=transforms_dict,
15953
16436
  frame_weights=frame_weights,
15954
16437
  scale_factor=scale_factor,
@@ -17809,12 +18292,7 @@ class StackingSuiteDialog(QDialog):
17809
18292
  # --- reusable C-order tile buffers (avoid copies before GPU) ---
17810
18293
  def _mk_buf():
17811
18294
  buf = np.empty((N, chunk_h, chunk_w, channels), dtype=np.float32, order='C')
17812
- if use_gpu:
17813
- # We'll pin tensors inside _torch_reduce_tile; nothing to do here.
17814
- try:
17815
- import torch # noqa: F401
17816
- except Exception:
17817
- pass
18295
+
17818
18296
  return buf
17819
18297
 
17820
18298
  buf0 = _mk_buf()