setiastrosuitepro 1.6.2__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 (72) hide show
  1. setiastro/images/rotatearbitrary.png +0 -0
  2. setiastro/saspro/_generated/build_info.py +2 -2
  3. setiastro/saspro/backgroundneutral.py +10 -1
  4. setiastro/saspro/blink_comparator_pro.py +474 -251
  5. setiastro/saspro/crop_dialog_pro.py +11 -1
  6. setiastro/saspro/doc_manager.py +1 -1
  7. setiastro/saspro/function_bundle.py +16 -16
  8. setiastro/saspro/gui/main_window.py +93 -64
  9. setiastro/saspro/gui/mixins/dock_mixin.py +31 -18
  10. setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
  11. setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
  12. setiastro/saspro/gui/mixins/toolbar_mixin.py +33 -10
  13. setiastro/saspro/multiscale_decomp.py +710 -256
  14. setiastro/saspro/remove_stars_preset.py +55 -13
  15. setiastro/saspro/resources.py +30 -11
  16. setiastro/saspro/selective_color.py +79 -20
  17. setiastro/saspro/shortcuts.py +94 -21
  18. setiastro/saspro/stacking_suite.py +296 -107
  19. setiastro/saspro/star_alignment.py +275 -330
  20. setiastro/saspro/status_log_dock.py +1 -1
  21. setiastro/saspro/swap_manager.py +77 -42
  22. setiastro/saspro/translations/all_source_strings.json +1588 -516
  23. setiastro/saspro/translations/ar_translations.py +915 -684
  24. setiastro/saspro/translations/de_translations.py +442 -463
  25. setiastro/saspro/translations/es_translations.py +277 -47
  26. setiastro/saspro/translations/fr_translations.py +279 -47
  27. setiastro/saspro/translations/hi_translations.py +253 -21
  28. setiastro/saspro/translations/integrate_translations.py +3 -2
  29. setiastro/saspro/translations/it_translations.py +1211 -161
  30. setiastro/saspro/translations/ja_translations.py +3340 -3107
  31. setiastro/saspro/translations/pt_translations.py +3315 -3337
  32. setiastro/saspro/translations/ru_translations.py +351 -117
  33. setiastro/saspro/translations/saspro_ar.qm +0 -0
  34. setiastro/saspro/translations/saspro_ar.ts +15902 -138
  35. setiastro/saspro/translations/saspro_de.qm +0 -0
  36. setiastro/saspro/translations/saspro_de.ts +14428 -133
  37. setiastro/saspro/translations/saspro_es.qm +0 -0
  38. setiastro/saspro/translations/saspro_es.ts +11503 -7821
  39. setiastro/saspro/translations/saspro_fr.qm +0 -0
  40. setiastro/saspro/translations/saspro_fr.ts +11168 -7812
  41. setiastro/saspro/translations/saspro_hi.qm +0 -0
  42. setiastro/saspro/translations/saspro_hi.ts +14733 -135
  43. setiastro/saspro/translations/saspro_it.qm +0 -0
  44. setiastro/saspro/translations/saspro_it.ts +14347 -7821
  45. setiastro/saspro/translations/saspro_ja.qm +0 -0
  46. setiastro/saspro/translations/saspro_ja.ts +14860 -137
  47. setiastro/saspro/translations/saspro_pt.qm +0 -0
  48. setiastro/saspro/translations/saspro_pt.ts +14904 -137
  49. setiastro/saspro/translations/saspro_ru.qm +0 -0
  50. setiastro/saspro/translations/saspro_ru.ts +11766 -168
  51. setiastro/saspro/translations/saspro_sw.qm +0 -0
  52. setiastro/saspro/translations/saspro_sw.ts +15115 -135
  53. setiastro/saspro/translations/saspro_uk.qm +0 -0
  54. setiastro/saspro/translations/saspro_uk.ts +11206 -6729
  55. setiastro/saspro/translations/saspro_zh.qm +0 -0
  56. setiastro/saspro/translations/saspro_zh.ts +10581 -7812
  57. setiastro/saspro/translations/sw_translations.py +282 -56
  58. setiastro/saspro/translations/uk_translations.py +264 -35
  59. setiastro/saspro/translations/zh_translations.py +282 -47
  60. setiastro/saspro/view_bundle.py +17 -17
  61. setiastro/saspro/widgets/minigame/game.js +11 -6
  62. setiastro/saspro/widgets/resource_monitor.py +26 -0
  63. setiastro/saspro/widgets/spinboxes.py +18 -0
  64. setiastro/saspro/wimi.py +65 -65
  65. setiastro/saspro/wims.py +33 -33
  66. setiastro/saspro/window_shelf.py +2 -2
  67. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/METADATA +7 -7
  68. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/RECORD +72 -71
  69. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/WHEEL +0 -0
  70. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/entry_points.txt +0 -0
  71. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/LICENSE +0 -0
  72. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/license.txt +0 -0
@@ -154,64 +154,107 @@ _FITS_EXTS = ('.fits', '.fit', '.fts', '.fits.gz', '.fit.gz', '.fts.gz', '.fz')
154
154
 
155
155
  def get_valid_header(path: str):
156
156
  """
157
- Return a robust FITS header for both normal and compressed FITS.
157
+ Fast header-only FITS peek with a targeted fallback:
158
158
 
159
- - Opens the HDU list and picks the first image-like HDU (ndim >= 2).
160
- - Forces NAXIS, NAXIS1, NAXIS2 from the actual data.shape if possible.
161
- - Falls back to ZNAXIS1/2 for tile-compressed images.
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.
162
+
163
+ Returns: (hdr, ok_bool)
162
164
  """
163
165
  try:
164
166
  from astropy.io import fits
165
167
 
166
- 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:
167
178
  science_hdu = None
168
179
 
169
- # Prefer the first HDU that actually has 2D+ image data
170
180
  for hdu in hdul:
171
- data = getattr(hdu, "data", None)
172
- if data is None:
173
- continue
174
- 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:
175
200
  science_hdu = hdu
176
201
  break
177
202
 
178
203
  if science_hdu is None:
179
- # Fallback: just use primary
180
204
  science_hdu = hdul[0]
181
205
 
182
206
  hdr = science_hdu.header.copy()
183
- data = science_hdu.data
184
207
 
185
- # --- Ensure NAXIS / NAXIS1 / NAXIS2 are real numbers ---
186
- try:
187
- if data is not None and getattr(data, "ndim", 0) >= 2:
188
- shape = data.shape
189
- # FITS: final axes are X, Y
190
- ny, nx = shape[-2], shape[-1]
191
- hdr["NAXIS"] = int(data.ndim)
192
- hdr["NAXIS1"] = int(nx)
193
- hdr["NAXIS2"] = int(ny)
194
- except Exception:
195
- pass
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"])
196
213
 
197
- # --- Extra fallback from ZNAXISn (tile-compressed FITS) ---
198
- for ax in (1, 2):
199
- key = f"NAXIS{ax}"
200
- zkey = f"ZNAXIS{ax}"
201
- val = hdr.get(key, None)
202
- if (val is None or (isinstance(val, str) and not val.strip())) and zkey in hdr:
203
- try:
204
- hdr[key] = int(hdr[zkey])
205
- except Exception:
206
- pass
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
207
217
 
208
- return hdr, True
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)
209
228
 
210
- except Exception:
211
- return None, False
229
+ try:
230
+ if int(naxis) >= 2 or int(znaxis) >= 2:
231
+ target_hdu = hdu
232
+ break
233
+ except Exception:
234
+ continue
212
235
 
236
+ if target_hdu is None:
237
+ target_hdu = hdul[0]
213
238
 
239
+ # Now (and only now) touch data
240
+ data = getattr(target_hdu, "data", None)
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
214
252
 
253
+ # If still unknown, return header anyway (caller can show "Unknown")
254
+ return hdr2, True
255
+
256
+ except Exception:
257
+ return None, False
215
258
 
216
259
  def _read_tile_stack(file_list, y0, y1, x0, x1, channels, out_buf):
217
260
  """
@@ -3916,7 +3959,11 @@ class StackingSuiteDialog(QDialog):
3916
3959
  self._wrench_path = wrench_path
3917
3960
  self._spinner_path = spinner_path
3918
3961
  self._post_progress_label = None
3919
-
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
3920
3967
 
3921
3968
  self.setWindowTitle(self.tr("Stacking Suite"))
3922
3969
  self.setGeometry(300, 200, 800, 600)
@@ -5123,8 +5170,9 @@ class StackingSuiteDialog(QDialog):
5123
5170
  disto_form.addRow(self.tr("Max control points:"), self.align_max_cp)
5124
5171
 
5125
5172
  self.align_downsample = QSpinBox()
5126
- self.align_downsample.setRange(1, 8)
5127
- 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."))
5128
5176
  disto_form.addRow(self.tr("Solve downsample:"), self.align_downsample)
5129
5177
 
5130
5178
  # Homography / Similarity-specific RANSAC reprojection threshold
@@ -6012,12 +6060,19 @@ class StackingSuiteDialog(QDialog):
6012
6060
  w.blockSignals(True); w.setValue(v); w.blockSignals(False)
6013
6061
 
6014
6062
  def _get_drizzle_scale(self) -> float:
6015
- # Accepts "1x/2x/3x" or numeric
6016
- val = self.settings.value("stacking/drizzle_scale", "2x", type=str)
6017
- if isinstance(val, str) and val.endswith("x"):
6018
- try: return float(val[:-1])
6019
- except: return 2.0
6020
- 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
+
6021
6076
 
6022
6077
  def _set_drizzle_scale(self, r: float | str) -> None:
6023
6078
  if isinstance(r, str):
@@ -6033,6 +6088,25 @@ class StackingSuiteDialog(QDialog):
6033
6088
  self.drizzle_scale_combo.setCurrentText(txt)
6034
6089
  self.drizzle_scale_combo.blockSignals(False)
6035
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)
6036
6110
 
6037
6111
  def closeEvent(self, e):
6038
6112
  # Graceful shutdown for any running workers
@@ -8873,9 +8947,13 @@ class StackingSuiteDialog(QDialog):
8873
8947
  # ensure attrs exist
8874
8948
  if not hasattr(self, "_reg_excluded_files"):
8875
8949
  self._reg_excluded_files = set()
8876
- if not hasattr(self, "deleted_calibrated_files"):
8877
- self.deleted_calibrated_files = []
8878
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)
8879
8957
  removed_paths = []
8880
8958
 
8881
8959
  for item in selected_items:
@@ -8931,18 +9009,23 @@ class StackingSuiteDialog(QDialog):
8931
9009
  # If you want a separate "Exclude" feature later, keep _reg_excluded_files for that.
8932
9010
  # For now, removing should be reversible via "Add Light Files".
8933
9011
 
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
9016
+
8934
9017
  # Also prune manual list so it doesn't re-inject removed files *in this session*
8935
9018
  if hasattr(self, "manual_light_files") and self.manual_light_files:
8936
- dead = {os.path.normcase(os.path.abspath(p)) for p in removed_paths if isinstance(p, str)}
8937
9019
  self.manual_light_files = [
8938
9020
  p for p in self.manual_light_files
8939
9021
  if os.path.normcase(os.path.abspath(p)) not in dead
8940
9022
  ]
8941
9023
 
8942
9024
  # refresh UI
8943
- self.populate_calibrated_lights()
9025
+ # IMPORTANT: do NOT call populate_calibrated_lights() here, it can resurrect removed items
8944
9026
  self._refresh_reg_tree_summaries()
8945
9027
 
9028
+
8946
9029
  def rebuild_flat_tree(self):
8947
9030
  """Regroup flat frames in the flat_tree based on the exposure tolerance."""
8948
9031
  self.flat_tree.clear()
@@ -9287,6 +9370,22 @@ class StackingSuiteDialog(QDialog):
9287
9370
  return (f"Drizzle: True, Scale: {scale:g}x, Drop: {drop:.2f}"
9288
9371
  if enabled else "Drizzle: False")
9289
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
+
9290
9389
  def _set_drizzle_on_items(self, items, enabled: bool, scale: float, drop: float):
9291
9390
  txt_on = self._format_drizzle_text(True, scale, drop)
9292
9391
  txt_off = self._format_drizzle_text(False, scale, drop)
@@ -9294,7 +9393,8 @@ class StackingSuiteDialog(QDialog):
9294
9393
  # dedupe child selection → parent group
9295
9394
  if it.parent() is not None:
9296
9395
  it = it.parent()
9297
- group_key = it.text(0)
9396
+ # Canonical key stored on the item (NOT display label)
9397
+ group_key = self._ensure_group_key(it)
9298
9398
  it.setText(2, txt_on if enabled else txt_off)
9299
9399
  self.per_group_drizzle[group_key] = {
9300
9400
  "enabled": bool(enabled),
@@ -9319,11 +9419,10 @@ class StackingSuiteDialog(QDialog):
9319
9419
  return
9320
9420
 
9321
9421
  for item in selected_items:
9322
- # If the user selected a child row, go up to its parent group
9323
9422
  if item.parent() is not None:
9324
9423
  item = item.parent()
9325
9424
 
9326
- group_key = item.text(0)
9425
+ group_key = self._ensure_group_key(item) # ✅ stable key
9327
9426
 
9328
9427
  if drizzle_enabled:
9329
9428
  # Show scale + drop shrink
@@ -9355,7 +9454,7 @@ class StackingSuiteDialog(QDialog):
9355
9454
  seen, targets = set(), []
9356
9455
  for it in sel:
9357
9456
  top = it if it.parent() is None else it.parent()
9358
- key = top.text(0)
9457
+ key = self._ensure_group_key(top)
9359
9458
  if key not in seen:
9360
9459
  seen.add(key); targets.append(top)
9361
9460
  else:
@@ -9378,7 +9477,7 @@ class StackingSuiteDialog(QDialog):
9378
9477
 
9379
9478
  out = {}
9380
9479
  for top in self._iter_group_items():
9381
- group_key = top.text(0)
9480
+ group_key = self._ensure_group_key(top) # ✅ stable key
9382
9481
  state = self.per_group_drizzle.get(group_key)
9383
9482
  if not state:
9384
9483
  state = {"enabled": global_enabled, "scale": global_scale, "drop": global_drop}
@@ -10000,24 +10099,32 @@ class StackingSuiteDialog(QDialog):
10000
10099
  manual_session_name = self._resolve_manual_session_name_for_ingest()
10001
10100
 
10002
10101
  added = 0
10003
- for i, path in enumerate(paths, start=1):
10004
- if dlg.wasCanceled():
10005
- break
10006
- try:
10007
- base = os.path.basename(path)
10008
- dlg.setLabelText(f"{base} ({i}/{total})")
10009
- 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()
10010
10112
 
10011
- self.process_fits_header(
10012
- path, tree, expected_type,
10013
- manual_session_name=manual_session_name
10014
- )
10015
- added += 1
10016
- except Exception:
10017
- 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()
10018
10127
 
10019
- dlg.setValue(i)
10020
- QCoreApplication.processEvents()
10021
10128
 
10022
10129
  dlg.setValue(total)
10023
10130
  QCoreApplication.processEvents()
@@ -10220,16 +10327,16 @@ class StackingSuiteDialog(QDialog):
10220
10327
  if expected_type_u == "DARK":
10221
10328
  key = f"{exposure_text} ({image_size})"
10222
10329
  self.dark_files.setdefault(key, []).append(path)
10223
- self.session_tags[path] = session_tag # not strictly needed, but consistent
10224
10330
 
10225
- items = tree.findItems(key, Qt.MatchFlag.MatchExactly, 0)
10226
- exposure_item = items[0] if items else QTreeWidgetItem([key])
10227
- if not items:
10331
+ exposure_item = self._dark_group_item.get(key)
10332
+ if exposure_item is None:
10333
+ exposure_item = QTreeWidgetItem([key])
10228
10334
  tree.addTopLevelItem(exposure_item)
10335
+ self._dark_group_item[key] = exposure_item
10229
10336
 
10230
10337
  leaf = QTreeWidgetItem([os.path.basename(path), meta_text])
10231
10338
  leaf.setData(0, Qt.ItemDataRole.UserRole, path)
10232
- leaf.setData(0, Qt.ItemDataRole.UserRole + 1, session_tag) # ✅ helpful later for retag/rekey
10339
+ leaf.setData(0, Qt.ItemDataRole.UserRole + 1, session_tag)
10233
10340
  exposure_item.addChild(leaf)
10234
10341
 
10235
10342
  # === FLATs ===
@@ -10239,20 +10346,20 @@ class StackingSuiteDialog(QDialog):
10239
10346
  self.flat_files.setdefault(composite_key, []).append(path)
10240
10347
  self.session_tags[path] = session_tag
10241
10348
 
10242
- filter_items = tree.findItems(filter_name, Qt.MatchFlag.MatchExactly, 0)
10243
- filter_item = filter_items[0] if filter_items else QTreeWidgetItem([filter_name])
10244
- 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])
10245
10352
  tree.addTopLevelItem(filter_item)
10353
+ self._flat_filter_item[filter_name] = filter_item
10246
10354
 
10247
10355
  want_label = f"{exposure_text} ({image_size})"
10248
- exposure_item = None
10249
- for i in range(filter_item.childCount()):
10250
- if filter_item.child(i).text(0) == want_label:
10251
- exposure_item = filter_item.child(i)
10252
- break
10356
+ exp_key = (filter_name, want_label)
10357
+
10358
+ exposure_item = self._flat_exp_item.get(exp_key)
10253
10359
  if exposure_item is None:
10254
10360
  exposure_item = QTreeWidgetItem([want_label])
10255
10361
  filter_item.addChild(exposure_item)
10362
+ self._flat_exp_item[exp_key] = exposure_item
10256
10363
 
10257
10364
  leaf = QTreeWidgetItem([os.path.basename(path), meta_text])
10258
10365
  leaf.setData(0, Qt.ItemDataRole.UserRole, path)
@@ -10266,23 +10373,25 @@ class StackingSuiteDialog(QDialog):
10266
10373
  self.light_files.setdefault(composite_key, []).append(path)
10267
10374
  self.session_tags[path] = session_tag
10268
10375
 
10269
- filter_items = tree.findItems(filter_name, Qt.MatchFlag.MatchExactly, 0)
10270
- filter_item = filter_items[0] if filter_items else QTreeWidgetItem([filter_name])
10271
- 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])
10272
10380
  tree.addTopLevelItem(filter_item)
10381
+ self._light_filter_item[filter_name] = filter_item
10273
10382
 
10274
10383
  want_label = f"{exposure_text} ({image_size})"
10275
- exposure_item = None
10276
- for i in range(filter_item.childCount()):
10277
- if filter_item.child(i).text(0) == want_label:
10278
- exposure_item = filter_item.child(i)
10279
- break
10384
+ exp_key = (filter_name, want_label)
10385
+
10386
+ # Cached exposure item
10387
+ exposure_item = self._light_exp_item.get(exp_key)
10280
10388
  if exposure_item is None:
10281
10389
  exposure_item = QTreeWidgetItem([want_label])
10282
10390
  filter_item.addChild(exposure_item)
10391
+ self._light_exp_item[exp_key] = exposure_item
10283
10392
 
10284
10393
  leaf = QTreeWidgetItem([os.path.basename(path), meta_text])
10285
- leaf.setData(0, Qt.ItemDataRole.UserRole, path) # ✅ needed for date-aware flat fallback
10394
+ leaf.setData(0, Qt.ItemDataRole.UserRole, path) # ✅ keep this
10286
10395
  leaf.setData(0, Qt.ItemDataRole.UserRole + 1, session_tag)
10287
10396
  exposure_item.addChild(leaf)
10288
10397
 
@@ -12816,6 +12925,20 @@ class StackingSuiteDialog(QDialog):
12816
12925
  "drop": float(self.drizzle_drop_shrink_spin.value())
12817
12926
  }
12818
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
+
12819
12942
  def _split_dual_band_osc(self, selected_groups=None):
12820
12943
  """
12821
12944
  Create mono Ha/SII/OIII frames from dual-band OSC files and
@@ -13578,6 +13701,24 @@ class StackingSuiteDialog(QDialog):
13578
13701
  self.update_status(self.tr("🔄 Image Registration Started..."))
13579
13702
  self.extract_light_files_from_tree(debug=True)
13580
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
+
13581
13722
  comet_mode = bool(getattr(self, "comet_cb", None) and self.comet_cb.isChecked())
13582
13723
  if comet_mode:
13583
13724
  self.update_status(self.tr("🌠 Comet mode: please click the comet center to continue…"))
@@ -15190,6 +15331,14 @@ class StackingSuiteDialog(QDialog):
15190
15331
  # Snapshot UI-dependent settings (your existing code)
15191
15332
  # ----------------------------
15192
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()
15193
15342
  try:
15194
15343
  autocrop_enabled = self.autocrop_cb.isChecked()
15195
15344
  autocrop_pct = float(self.autocrop_pct.value())
@@ -15604,6 +15753,22 @@ class StackingSuiteDialog(QDialog):
15604
15753
 
15605
15754
  self._set_registration_busy(False)
15606
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
+
15607
15772
  def _on_mf_progress(self, s: str):
15608
15773
  # Mirror non-token messages
15609
15774
  if not s.startswith("__PROGRESS__"):
@@ -15632,25 +15797,48 @@ class StackingSuiteDialog(QDialog):
15632
15797
 
15633
15798
  @pyqtSlot(bool, str)
15634
15799
  def _on_post_pipeline_finished(self, ok: bool, message: str):
15800
+ # ---- close progress dialog ----
15635
15801
  try:
15636
- if getattr(self, "post_progress", None):
15802
+ if getattr(self, "post_progress", None) is not None:
15637
15803
  self.post_progress.close()
15804
+ self.post_progress.deleteLater()
15638
15805
  self.post_progress = None
15639
15806
  except Exception:
15640
15807
  pass
15641
15808
 
15809
+ # ---- stop thread ----
15810
+ try:
15811
+ if getattr(self, "post_thread", None) is not None:
15812
+ self.post_thread.quit()
15813
+ self.post_thread.wait()
15814
+ except Exception:
15815
+ pass
15816
+
15817
+ # ---- cleanup objects ----
15642
15818
  try:
15643
- self.post_thread.quit()
15644
- self.post_thread.wait()
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
15645
15825
  except Exception:
15646
15826
  pass
15827
+
15828
+ # ---- update status (keep this behavior) ----
15647
15829
  try:
15648
- self.post_worker.deleteLater()
15649
- self.post_thread.deleteLater()
15830
+ # message already includes "Post-alignment complete..." text
15831
+ self.update_status(self.tr(message))
15650
15832
  except Exception:
15651
15833
  pass
15652
15834
 
15653
- self.update_status(self.tr(message))
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
+
15654
15842
  self._cfa_for_this_run = None
15655
15843
  QApplication.processEvents()
15656
15844
 
@@ -15757,6 +15945,8 @@ class StackingSuiteDialog(QDialog):
15757
15945
  log(f"📁 Post-align: {n_groups} group(s), {n_frames} aligned frame(s).")
15758
15946
  QApplication.processEvents()
15759
15947
 
15948
+ drizzle_enabled_global = self._get_drizzle_enabled()
15949
+
15760
15950
  # Precompute a single global crop rect if enabled (pure computation, no UI).
15761
15951
  global_rect = None
15762
15952
  if autocrop_enabled:
@@ -16190,8 +16380,7 @@ class StackingSuiteDialog(QDialog):
16190
16380
  log(f"✂️ Saved CometBlend (auto-cropped) → {blend_path_crop}")
16191
16381
 
16192
16382
  # ---- Drizzle bookkeeping for this group ----
16193
- dconf = drizzle_dict.get(group_key, {})
16194
- if dconf.get("drizzle_enabled", False):
16383
+ if drizzle_enabled_global:
16195
16384
  sasr_path = os.path.join(self.stacking_directory, f"{group_key}_rejections.sasr")
16196
16385
  self.save_rejection_map_sasr(rejection_map, sasr_path)
16197
16386
  log(f"✅ Saved rejection map to {sasr_path}")
@@ -16223,17 +16412,17 @@ class StackingSuiteDialog(QDialog):
16223
16412
  originals_by_group[group] = orig_list
16224
16413
  # ---- Drizzle pass (only for groups with drizzle enabled) ----
16225
16414
  for group_key, file_list in grouped_files.items():
16226
- dconf = drizzle_dict.get(group_key)
16227
- if not (dconf and dconf.get("drizzle_enabled", False)):
16228
- 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.")
16229
16417
  continue
16230
16418
 
16419
+ # Use your existing getters (they can read UI/settings)
16231
16420
  scale_factor = self._get_drizzle_scale()
16232
16421
  drop_shrink = self._get_drizzle_pixfrac()
16233
16422
 
16234
- # Optional: also read kernel for logging/branching
16235
16423
  kernel = (self.settings.value("stacking/drizzle_kernel", "square", type=str) or "square").lower()
16236
- 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
+
16237
16426
  rejections_for_group = group_integration_data[group_key]["rejection_map"]
16238
16427
  n_frames_group = group_integration_data[group_key]["n_frames"]
16239
16428
 
@@ -16241,8 +16430,8 @@ class StackingSuiteDialog(QDialog):
16241
16430
 
16242
16431
  self.drizzle_stack_one_group(
16243
16432
  group_key=group_key,
16244
- file_list=file_list, # registered (for headers/labels)
16245
- original_list=originals_by_group.get(group_key, []), # <-- NEW
16433
+ file_list=file_list,
16434
+ original_list=originals_by_group.get(group_key, []),
16246
16435
  transforms_dict=transforms_dict,
16247
16436
  frame_weights=frame_weights,
16248
16437
  scale_factor=scale_factor,