setiastrosuitepro 1.6.4__py3-none-any.whl → 1.6.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (115) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/colorwheel.svg +97 -0
  4. setiastro/images/cosmic.svg +40 -0
  5. setiastro/images/cosmicsat.svg +24 -0
  6. setiastro/images/first_quarter.png +0 -0
  7. setiastro/images/full_moon.png +0 -0
  8. setiastro/images/graxpert.svg +19 -0
  9. setiastro/images/last_quarter.png +0 -0
  10. setiastro/images/linearfit.svg +32 -0
  11. setiastro/images/new_moon.png +0 -0
  12. setiastro/images/pixelmath.svg +42 -0
  13. setiastro/images/waning_crescent_1.png +0 -0
  14. setiastro/images/waning_crescent_2.png +0 -0
  15. setiastro/images/waning_crescent_3.png +0 -0
  16. setiastro/images/waning_crescent_4.png +0 -0
  17. setiastro/images/waning_crescent_5.png +0 -0
  18. setiastro/images/waning_gibbous_1.png +0 -0
  19. setiastro/images/waning_gibbous_2.png +0 -0
  20. setiastro/images/waning_gibbous_3.png +0 -0
  21. setiastro/images/waning_gibbous_4.png +0 -0
  22. setiastro/images/waning_gibbous_5.png +0 -0
  23. setiastro/images/waxing_crescent_1.png +0 -0
  24. setiastro/images/waxing_crescent_2.png +0 -0
  25. setiastro/images/waxing_crescent_3.png +0 -0
  26. setiastro/images/waxing_crescent_4.png +0 -0
  27. setiastro/images/waxing_crescent_5.png +0 -0
  28. setiastro/images/waxing_gibbous_1.png +0 -0
  29. setiastro/images/waxing_gibbous_2.png +0 -0
  30. setiastro/images/waxing_gibbous_3.png +0 -0
  31. setiastro/images/waxing_gibbous_4.png +0 -0
  32. setiastro/images/waxing_gibbous_5.png +0 -0
  33. setiastro/qml/ResourceMonitor.qml +84 -82
  34. setiastro/saspro/__main__.py +20 -1
  35. setiastro/saspro/_generated/build_info.py +2 -2
  36. setiastro/saspro/abe.py +37 -4
  37. setiastro/saspro/aberration_ai.py +237 -21
  38. setiastro/saspro/acv_exporter.py +379 -0
  39. setiastro/saspro/add_stars.py +33 -6
  40. setiastro/saspro/backgroundneutral.py +108 -40
  41. setiastro/saspro/blemish_blaster.py +4 -1
  42. setiastro/saspro/blink_comparator_pro.py +74 -24
  43. setiastro/saspro/clahe.py +4 -1
  44. setiastro/saspro/continuum_subtract.py +4 -1
  45. setiastro/saspro/convo.py +13 -7
  46. setiastro/saspro/cosmicclarity.py +129 -18
  47. setiastro/saspro/crop_dialog_pro.py +123 -7
  48. setiastro/saspro/curve_editor_pro.py +109 -42
  49. setiastro/saspro/doc_manager.py +245 -15
  50. setiastro/saspro/exoplanet_detector.py +120 -28
  51. setiastro/saspro/frequency_separation.py +1158 -204
  52. setiastro/saspro/ghs_dialog_pro.py +81 -16
  53. setiastro/saspro/graxpert.py +1 -0
  54. setiastro/saspro/gui/main_window.py +429 -228
  55. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  56. setiastro/saspro/gui/mixins/menu_mixin.py +27 -1
  57. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  58. setiastro/saspro/gui/mixins/toolbar_mixin.py +384 -18
  59. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  60. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  61. setiastro/saspro/halobgon.py +4 -0
  62. setiastro/saspro/histogram.py +5 -1
  63. setiastro/saspro/image_combine.py +4 -0
  64. setiastro/saspro/image_peeker_pro.py +4 -0
  65. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  66. setiastro/saspro/imageops/stretch.py +582 -62
  67. setiastro/saspro/isophote.py +4 -0
  68. setiastro/saspro/layers.py +13 -9
  69. setiastro/saspro/layers_dock.py +183 -3
  70. setiastro/saspro/legacy/image_manager.py +154 -20
  71. setiastro/saspro/legacy/numba_utils.py +67 -47
  72. setiastro/saspro/legacy/xisf.py +240 -98
  73. setiastro/saspro/live_stacking.py +180 -79
  74. setiastro/saspro/luminancerecombine.py +228 -27
  75. setiastro/saspro/mask_creation.py +174 -15
  76. setiastro/saspro/mfdeconv.py +113 -35
  77. setiastro/saspro/mfdeconvcudnn.py +119 -70
  78. setiastro/saspro/mfdeconvsport.py +112 -35
  79. setiastro/saspro/morphology.py +4 -0
  80. setiastro/saspro/multiscale_decomp.py +51 -12
  81. setiastro/saspro/numba_utils.py +72 -57
  82. setiastro/saspro/ops/commands.py +18 -18
  83. setiastro/saspro/ops/script_editor.py +10 -2
  84. setiastro/saspro/ops/scripts.py +122 -0
  85. setiastro/saspro/perfect_palette_picker.py +37 -3
  86. setiastro/saspro/plate_solver.py +84 -49
  87. setiastro/saspro/psf_viewer.py +119 -37
  88. setiastro/saspro/resources.py +67 -0
  89. setiastro/saspro/rgbalign.py +4 -0
  90. setiastro/saspro/selective_color.py +4 -1
  91. setiastro/saspro/sfcc.py +364 -152
  92. setiastro/saspro/shortcuts.py +160 -29
  93. setiastro/saspro/signature_insert.py +692 -33
  94. setiastro/saspro/stacking_suite.py +1331 -484
  95. setiastro/saspro/star_alignment.py +247 -123
  96. setiastro/saspro/star_spikes.py +4 -0
  97. setiastro/saspro/star_stretch.py +38 -3
  98. setiastro/saspro/stat_stretch.py +743 -128
  99. setiastro/saspro/subwindow.py +786 -360
  100. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  101. setiastro/saspro/wavescale_hdr.py +4 -1
  102. setiastro/saspro/wavescalede.py +4 -1
  103. setiastro/saspro/whitebalance.py +84 -12
  104. setiastro/saspro/widgets/common_utilities.py +28 -21
  105. setiastro/saspro/widgets/resource_monitor.py +109 -59
  106. setiastro/saspro/widgets/spinboxes.py +10 -13
  107. setiastro/saspro/wimi.py +27 -656
  108. setiastro/saspro/wims.py +13 -3
  109. setiastro/saspro/xisf.py +101 -11
  110. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +2 -1
  111. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +115 -82
  112. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
  113. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
  114. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
  115. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/license.txt +0 -0
setiastro/saspro/sfcc.py CHANGED
@@ -48,6 +48,9 @@ from PyQt6.QtWidgets import (QToolBar, QWidget, QToolButton, QMenu, QApplication
48
48
  QInputDialog, QMessageBox, QDialog, QFileDialog,
49
49
  QFormLayout, QDialogButtonBox, QDoubleSpinBox, QCheckBox, QLabel, QRubberBand, QRadioButton, QMainWindow, QPushButton)
50
50
 
51
+ from setiastro.saspro.backgroundneutral import run_background_neutral_via_preset
52
+ from setiastro.saspro.backgroundneutral import background_neutralize_rgb, auto_rect_50x50
53
+
51
54
 
52
55
  # ──────────────────────────────────────────────────────────────────────────────
53
56
  # Utilities
@@ -188,6 +191,12 @@ def compute_gradient_map(sources, delta_flux, shape, method="poly2"):
188
191
  else:
189
192
  raise ValueError("method must be one of 'poly2','poly3','rbf'")
190
193
 
194
+ def _pivot_scale_channel(ch: np.ndarray, gain: np.ndarray | float, pivot: float) -> np.ndarray:
195
+ """
196
+ Apply gain around a pivot: pivot + (x - pivot)*gain.
197
+ gain can be scalar or per-pixel array.
198
+ """
199
+ return pivot + (ch - pivot) * gain
191
200
 
192
201
  # ──────────────────────────────────────────────────────────────────────────────
193
202
  # Simple responses viewer (unchanged core logic; useful for diagnostics)
@@ -349,6 +358,10 @@ class SFCCDialog(QDialog):
349
358
  self.setWindowFlag(Qt.WindowType.Window, True)
350
359
  self.setWindowModality(Qt.WindowModality.NonModal)
351
360
  self.setModal(False)
361
+ try:
362
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
363
+ except Exception:
364
+ pass # older PyQt6 versions
352
365
  self.setMinimumSize(800, 600)
353
366
 
354
367
  self.doc_manager = doc_manager
@@ -375,6 +388,7 @@ class SFCCDialog(QDialog):
375
388
  self.lp_filter_combo2.currentIndexChanged.connect(self.save_lp2_setting)
376
389
  self.sens_combo.currentIndexChanged.connect(self.save_sensor_setting)
377
390
  self.star_combo.currentIndexChanged.connect(self.save_star_setting)
391
+ self.finished.connect(lambda *_: self._cleanup())
378
392
 
379
393
  self.grad_method = "poly3"
380
394
  self.grad_method_combo.currentTextChanged.connect(lambda m: setattr(self, "grad_method", m))
@@ -528,12 +542,12 @@ class SFCCDialog(QDialog):
528
542
  self.remove_curve_btn = QPushButton(self.tr("Remove Filter/Sensor Curve…"))
529
543
  self.remove_curve_btn.clicked.connect(self.remove_custom_curve); row4.addWidget(self.remove_curve_btn)
530
544
  row4.addStretch()
531
- self.close_btn = QPushButton(self.tr("Close")); self.close_btn.clicked.connect(self.close); row4.addWidget(self.close_btn)
545
+ self.close_btn = QPushButton(self.tr("Close")); self.close_btn.clicked.connect(self.reject); row4.addWidget(self.close_btn)
532
546
 
533
547
  self.count_label = QLabel(""); layout.addWidget(self.count_label)
534
548
 
535
549
  self.figure = Figure(figsize=(6, 4)); self.canvas = FigureCanvas(self.figure); self.canvas.setVisible(False); layout.addWidget(self.canvas, stretch=1)
536
- self.reset_btn = QPushButton(self.tr("Reset View/Close")); self.reset_btn.clicked.connect(self.close); layout.addWidget(self.reset_btn)
550
+ self.reset_btn = QPushButton(self.tr("Reset View/Close")); self.reset_btn.clicked.connect(self.reject); layout.addWidget(self.reset_btn)
537
551
 
538
552
  # hide gradient controls by default (enable if you like)
539
553
  self.run_grad_btn.hide(); self.grad_method_combo.hide()
@@ -870,28 +884,89 @@ class SFCCDialog(QDialog):
870
884
  return self.wcs.all_pix2world(x, y, 0)
871
885
 
872
886
  # ── Background neutralization ───────────────────────────────────────
887
+ def _neutralize_background(self, rgb_f: np.ndarray, *, remove_pedestal: bool = False) -> np.ndarray:
888
+ img = np.asarray(rgb_f, dtype=np.float32)
889
+
890
+ if img.ndim != 3 or img.shape[2] != 3:
891
+ raise ValueError("Expected RGB image (H,W,3)")
873
892
 
874
- def _neutralize_background(self, rgb_img: np.ndarray, patch_size: int = 50) -> np.ndarray:
875
- img = rgb_img.copy()
893
+ img = np.clip(img, 0.0, 1.0)
894
+
895
+ try:
896
+ rect = auto_rect_50x50(img) # same SASv2-ish auto finder
897
+ out = background_neutralize_rgb(
898
+ img,
899
+ rect,
900
+ mode="pivot1", # or "offset" if you prefer
901
+ remove_pedestal=remove_pedestal,
902
+ )
903
+ return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
904
+
905
+ except Exception as e:
906
+ print(f"[SFCC] BN preset failed, falling back to simple neutralization: {e}")
907
+ return self._neutralize_background_simple(img, patch_size=10)
908
+
909
+ def _neutralize_background_simple(self, rgb_f: np.ndarray, patch_size: int = 50) -> np.ndarray:
910
+ """
911
+ Simple neutralization: find darkest patch by summed medians,
912
+ then equalize channel medians around the mean.
913
+ Assumes rgb_f is float in [0,1] with no negatives.
914
+ """
915
+ img = np.asarray(rgb_f, dtype=np.float32).copy()
876
916
  h, w = img.shape[:2]
877
- ph, pw = h // patch_size, w // patch_size
917
+ ph, pw = max(1, h // patch_size), max(1, w // patch_size)
918
+
878
919
  min_sum, best_med = np.inf, None
879
920
  for i in range(patch_size):
880
921
  for j in range(patch_size):
881
922
  y0, x0 = i * ph, j * pw
882
- patch = img[y0:min(y0+ph, h), x0:min(x0+pw, w), :]
883
- med = np.median(patch, axis=(0, 1))
884
- s = med.sum()
923
+ patch = img[y0:min(y0 + ph, h), x0:min(x0 + pw, w), :]
924
+ if patch.size == 0:
925
+ continue
926
+ med = np.median(patch, axis=(0, 1))
927
+ s = float(med.sum())
885
928
  if s < min_sum:
886
929
  min_sum, best_med = s, med
930
+
887
931
  if best_med is None:
888
- return img
889
- target = float(best_med.mean()); eps = 1e-8
932
+ return np.clip(img, 0.0, 1.0)
933
+
934
+ target = float(best_med.mean())
935
+ eps = 1e-8
890
936
  for c in range(3):
891
937
  diff = float(best_med[c] - target)
892
- if abs(diff) < eps: continue
938
+ if abs(diff) < eps:
939
+ continue
940
+ # Preserve [0,1] scale; keep the same form you were using.
893
941
  img[..., c] = np.clip((img[..., c] - diff) / (1.0 - diff), 0.0, 1.0)
894
- return img
942
+
943
+ return np.clip(img, 0.0, 1.0)
944
+
945
+ def _make_working_base_for_sep(self, img_float: np.ndarray) -> np.ndarray:
946
+ """
947
+ Build a working copy for SEP + calibration.
948
+
949
+ Pedestal removal (per channel):
950
+ ch <- ch - min(ch)
951
+
952
+ Then clamp to [0,1] for stability.
953
+ """
954
+ base = np.asarray(img_float, dtype=np.float32).copy()
955
+
956
+ if base.ndim != 3 or base.shape[2] != 3:
957
+ raise ValueError("Expected RGB image (H,W,3)")
958
+
959
+ # --- Per-channel pedestal removal: ch -= min(ch) ---
960
+ mins = base.reshape(-1, 3).min(axis=0) # (3,)
961
+ base[..., 0] -= float(mins[0])
962
+ base[..., 1] -= float(mins[1])
963
+ base[..., 2] -= float(mins[2])
964
+
965
+ # Stability clamp (SEP likes non-negative; your pipeline assumes [0,1])
966
+ base = np.clip(base, 0.0, 1.0)
967
+
968
+ return base
969
+
895
970
 
896
971
  # ── SIMBAD/Star fetch ──────────────────────────────────────────────
897
972
 
@@ -1030,7 +1105,6 @@ class SFCCDialog(QDialog):
1030
1105
  self.canvas.setVisible(False); self.canvas.draw()
1031
1106
 
1032
1107
  # ── Core SFCC ───────────────────────────────────────────────────────
1033
-
1034
1108
  def run_spcc(self):
1035
1109
  ref_sed_name = self.star_combo.currentData()
1036
1110
  r_filt = self.r_filter_combo.currentText()
@@ -1041,13 +1115,15 @@ class SFCCDialog(QDialog):
1041
1115
  lp_filt2 = self.lp_filter_combo2.currentText()
1042
1116
 
1043
1117
  if not ref_sed_name:
1044
- QMessageBox.warning(self, "Error", "Select a reference spectral type (e.g. A0V)."); return
1118
+ QMessageBox.warning(self, "Error", "Select a reference spectral type (e.g. A0V).")
1119
+ return
1045
1120
  if r_filt == "(None)" and g_filt == "(None)" and b_filt == "(None)":
1046
- QMessageBox.warning(self, "Error", "Pick at least one of R, G or B filters."); return
1121
+ QMessageBox.warning(self, "Error", "Pick at least one of R, G or B filters.")
1122
+ return
1047
1123
  if sens_name == "(None)":
1048
- QMessageBox.warning(self, "Error", "Select a sensor QE curve."); return
1124
+ QMessageBox.warning(self, "Error", "Select a sensor QE curve.")
1125
+ return
1049
1126
 
1050
- # -- Step 1A: get active image as float32 in [0..1]
1051
1127
  doc = self.doc_manager.get_active_document()
1052
1128
  if doc is None or doc.image is None:
1053
1129
  QMessageBox.critical(self, "Error", "No active document.")
@@ -1059,29 +1135,32 @@ class SFCCDialog(QDialog):
1059
1135
  QMessageBox.critical(self, "Error", "Active document must be RGB (3 channels).")
1060
1136
  return
1061
1137
 
1138
+ # ---- Convert to float working space ----
1062
1139
  if img.dtype == np.uint8:
1063
- base = img.astype(np.float32) / 255.0
1140
+ img_float = img.astype(np.float32) / 255.0
1064
1141
  else:
1065
- base = img.astype(np.float32, copy=True)
1142
+ img_float = img.astype(np.float32, copy=False)
1066
1143
 
1067
- # pedestal removal
1068
- base = np.clip(base - np.min(base, axis=(0,1)), 0.0, None)
1069
- # light neutralization
1070
- base = self._neutralize_background(base, patch_size=10)
1144
+ # ---- Build SEP working copy (ONE pedestal handling only) ----
1145
+ base = self._make_working_base_for_sep(img_float)
1146
+
1147
+ # Optional BN after calibration:
1148
+ # IMPORTANT: do NOT remove pedestal here either (avoid double pedestal removal).
1149
+ if self.neutralize_chk.isChecked():
1150
+ base = self._neutralize_background(base, remove_pedestal=False)
1071
1151
 
1072
1152
  # SEP on grayscale
1073
- gray = np.mean(base, axis=2)
1074
-
1153
+ gray = np.mean(base, axis=2).astype(np.float32)
1154
+
1075
1155
  bkg = sep.Background(gray)
1076
1156
  data_sub = gray - bkg.back()
1077
- err = bkg.globalrms
1157
+ err = float(bkg.globalrms)
1158
+
1159
+ # User threshold
1160
+ sep_sigma = float(self.sep_thr_spin.value()) if hasattr(self, "sep_thr_spin") else 5.0
1161
+ self.count_label.setText(f"Detecting stars (SEP σ={sep_sigma:.1f})…")
1162
+ QApplication.processEvents()
1078
1163
 
1079
- # 👇 get user threshold (default 5.0)
1080
- if hasattr(self, "sep_thr_spin"):
1081
- sep_sigma = float(self.sep_thr_spin.value())
1082
- else:
1083
- sep_sigma = 5.0
1084
- self.count_label.setText(f"Detecting stars (SEP σ={sep_sigma:.1f})…"); QApplication.processEvents()
1085
1164
  sources = sep.extract(data_sub, sep_sigma, err=err)
1086
1165
 
1087
1166
  MAX_SOURCES = 300_000
@@ -1095,32 +1174,51 @@ class SFCCDialog(QDialog):
1095
1174
  return
1096
1175
 
1097
1176
  if sources.size == 0:
1098
- QMessageBox.critical(self, "SEP Error", "SEP found no sources."); return
1099
- r_fluxrad, _ = sep.flux_radius(gray, sources["x"], sources["y"], 2.0*sources["a"], 0.5, normflux=sources["flux"], subpix=5)
1100
- mask = (r_fluxrad > .2) & (r_fluxrad <= 10); sources = sources[mask]
1177
+ QMessageBox.critical(self, "SEP Error", "SEP found no sources.")
1178
+ return
1179
+
1180
+ # Radius filtering (unchanged)
1181
+ r_fluxrad, _ = sep.flux_radius(
1182
+ gray, sources["x"], sources["y"],
1183
+ 2.0 * sources["a"], 0.5,
1184
+ normflux=sources["flux"], subpix=5
1185
+ )
1186
+ mask = (r_fluxrad > 0.2) & (r_fluxrad <= 10)
1187
+ sources = sources[mask]
1101
1188
  if sources.size == 0:
1102
- QMessageBox.critical(self, "SEP Error", "All SEP detections rejected by radius filter."); return
1189
+ QMessageBox.critical(self, "SEP Error", "All SEP detections rejected by radius filter.")
1190
+ return
1103
1191
 
1104
1192
  if not getattr(self, "star_list", None):
1105
- QMessageBox.warning(self, "Error", "Fetch Stars (with WCS) before running SFCC."); return
1193
+ QMessageBox.warning(self, "Error", "Fetch Stars (with WCS) before running SFCC.")
1194
+ return
1106
1195
 
1196
+ # ---- Match SIMBAD stars to SEP detections ----
1107
1197
  raw_matches = []
1108
1198
  for i, star in enumerate(self.star_list):
1109
- dx = sources["x"] - star["x"]; dy = sources["y"] - star["y"]
1110
- j = np.argmin(dx*dx + dy*dy)
1111
- if (dx[j]**2 + dy[j]**2) < 3.0**2:
1112
- xi, yi = int(round(sources["x"][j])), int(round(sources["y"][j]))
1199
+ dx = sources["x"] - star["x"]
1200
+ dy = sources["y"] - star["y"]
1201
+ j = int(np.argmin(dx * dx + dy * dy))
1202
+ if (dx[j] * dx[j] + dy[j] * dy[j]) < (3.0 ** 2):
1203
+ xi, yi = int(round(float(sources["x"][j]))), int(round(float(sources["y"][j])))
1113
1204
  if 0 <= xi < W and 0 <= yi < H:
1114
- raw_matches.append({"sim_index": i, "template": star.get("pickles_match") or star["sp_clean"], "x_pix": xi, "y_pix": yi})
1205
+ raw_matches.append({
1206
+ "sim_index": i,
1207
+ "template": star.get("pickles_match") or star["sp_clean"],
1208
+ "x_pix": xi,
1209
+ "y_pix": yi
1210
+ })
1211
+
1115
1212
  if not raw_matches:
1116
- QMessageBox.warning(self, "No Matches", "No SIMBAD star matched to SEP detections."); return
1213
+ QMessageBox.warning(self, "No Matches", "No SIMBAD star matched to SEP detections.")
1214
+ return
1117
1215
 
1118
1216
  wl_min, wl_max = 3000, 11000
1119
- wl_grid = np.arange(wl_min, wl_max+1)
1217
+ wl_grid = np.arange(wl_min, wl_max + 1)
1120
1218
 
1121
1219
  def load_curve(ext):
1122
1220
  for p in (self.user_custom_path, self.sasp_data_path):
1123
- with fits.open(p) as hd:
1221
+ with fits.open(p, memmap=False) as hd:
1124
1222
  if ext in hd:
1125
1223
  d = hd[ext].data
1126
1224
  wl = _ensure_angstrom(d["WAVELENGTH"].astype(float))
@@ -1130,7 +1228,7 @@ class SFCCDialog(QDialog):
1130
1228
 
1131
1229
  def load_sed(ext):
1132
1230
  for p in (self.user_custom_path, self.sasp_data_path):
1133
- with fits.open(p) as hd:
1231
+ with fits.open(p, memmap=False) as hd:
1134
1232
  if ext in hd:
1135
1233
  d = hd[ext].data
1136
1234
  wl = _ensure_angstrom(d["WAVELENGTH"].astype(float))
@@ -1138,18 +1236,21 @@ class SFCCDialog(QDialog):
1138
1236
  return wl, fl
1139
1237
  raise KeyError(f"SED '{ext}' not found")
1140
1238
 
1141
- interp = lambda wl_o, tp_o: np.interp(wl_grid, wl_o, tp_o, left=0., right=0.)
1142
- T_R = interp(*load_curve(r_filt)) if r_filt!="(None)" else np.ones_like(wl_grid)
1143
- T_G = interp(*load_curve(g_filt)) if g_filt!="(None)" else np.ones_like(wl_grid)
1144
- T_B = interp(*load_curve(b_filt)) if b_filt!="(None)" else np.ones_like(wl_grid)
1145
- QE = interp(*load_curve(sens_name)) if sens_name!="(None)" else np.ones_like(wl_grid)
1146
- LP1 = interp(*load_curve(lp_filt)) if lp_filt != "(None)" else np.ones_like(wl_grid)
1147
- LP2 = interp(*load_curve(lp_filt2)) if lp_filt2!= "(None)" else np.ones_like(wl_grid)
1239
+ interp = lambda wl_o, tp_o: np.interp(wl_grid, wl_o, tp_o, left=0.0, right=0.0)
1240
+
1241
+ T_R = interp(*load_curve(r_filt)) if r_filt != "(None)" else np.ones_like(wl_grid)
1242
+ T_G = interp(*load_curve(g_filt)) if g_filt != "(None)" else np.ones_like(wl_grid)
1243
+ T_B = interp(*load_curve(b_filt)) if b_filt != "(None)" else np.ones_like(wl_grid)
1244
+ QE = interp(*load_curve(sens_name)) if sens_name != "(None)" else np.ones_like(wl_grid)
1245
+ LP1 = interp(*load_curve(lp_filt)) if lp_filt != "(None)" else np.ones_like(wl_grid)
1246
+ LP2 = interp(*load_curve(lp_filt2)) if lp_filt2 != "(None)" else np.ones_like(wl_grid)
1148
1247
  LP = LP1 * LP2
1149
- T_sys_R, T_sys_G, T_sys_B = T_R*QE*LP, T_G*QE*LP, T_B*QE*LP
1248
+
1249
+ T_sys_R, T_sys_G, T_sys_B = T_R * QE * LP, T_G * QE * LP, T_B * QE * LP
1150
1250
 
1151
1251
  wl_ref, fl_ref = load_sed(ref_sed_name)
1152
- fr_i = np.interp(wl_grid, wl_ref, fl_ref, left=0., right=0.)
1252
+ fr_i = np.interp(wl_grid, wl_ref, fl_ref, left=0.0, right=0.0)
1253
+
1153
1254
  S_ref_R = np.trapezoid(fr_i * T_sys_R, x=wl_grid)
1154
1255
  S_ref_G = np.trapezoid(fr_i * T_sys_G, x=wl_grid)
1155
1256
  S_ref_B = np.trapezoid(fr_i * T_sys_B, x=wl_grid)
@@ -1158,155 +1259,207 @@ class SFCCDialog(QDialog):
1158
1259
  diag_meas_BG, diag_exp_BG = [], []
1159
1260
  enriched = []
1160
1261
 
1161
- # --- Optimization: Pre-calculate integrals for unique templates ---
1262
+ # ---- Pre-calc integrals for unique templates ----
1162
1263
  unique_simbad_types = set(m["template"] for m in raw_matches)
1163
-
1164
- # Map simbad_type -> pickles_template_name
1264
+
1165
1265
  simbad_to_pickles = {}
1166
1266
  pickles_templates_needed = set()
1167
-
1168
1267
  for sp in unique_simbad_types:
1169
1268
  cands = pickles_match_for_simbad(sp, getattr(self, "pickles_templates", []))
1170
1269
  if cands:
1171
- pickles_name = cands[0]
1172
- simbad_to_pickles[sp] = pickles_name
1173
- pickles_templates_needed.add(pickles_name)
1270
+ pname = cands[0]
1271
+ simbad_to_pickles[sp] = pname
1272
+ pickles_templates_needed.add(pname)
1174
1273
 
1175
- # Pre-calc integrals for each unique Pickles template
1176
- # Cache structure: template_name -> (S_sr, S_sg, S_sb)
1177
1274
  template_integrals = {}
1178
-
1179
- # Cache for load_sed to avoid re-reading even across different calls if desired,
1180
- # but here we just optimize the loop.
1181
-
1182
1275
  for pname in pickles_templates_needed:
1183
1276
  try:
1184
1277
  wl_s, fl_s = load_sed(pname)
1185
- fs_i = np.interp(wl_grid, wl_s, fl_s, left=0., right=0.)
1186
-
1278
+ fs_i = np.interp(wl_grid, wl_s, fl_s, left=0.0, right=0.0)
1187
1279
  S_sr = np.trapezoid(fs_i * T_sys_R, x=wl_grid)
1188
1280
  S_sg = np.trapezoid(fs_i * T_sys_G, x=wl_grid)
1189
1281
  S_sb = np.trapezoid(fs_i * T_sys_B, x=wl_grid)
1190
-
1191
1282
  template_integrals[pname] = (S_sr, S_sg, S_sb)
1192
1283
  except Exception as e:
1193
1284
  print(f"[SFCC] Warning: failed to load/integrate template {pname}: {e}")
1194
1285
 
1195
- # --- Main Match Loop ---
1286
+ # ---- Main match loop (measure from 'base' only) ----
1196
1287
  for m in raw_matches:
1197
1288
  xi, yi, sp = m["x_pix"], m["y_pix"], m["template"]
1198
- Rm = float(base[yi, xi, 0]); Gm = float(base[yi, xi, 1]); Bm = float(base[yi, xi, 2])
1199
- if Gm <= 0: continue
1200
1289
 
1201
- # 1. Resolve Simbad -> Pickles
1290
+ # measure on the SEP working copy (already BN’d, only one pedestal handling)
1291
+ Rm = float(base[yi, xi, 0])
1292
+ Gm = float(base[yi, xi, 1])
1293
+ Bm = float(base[yi, xi, 2])
1294
+ if Gm <= 0:
1295
+ continue
1296
+
1202
1297
  pname = simbad_to_pickles.get(sp)
1203
- if not pname: continue
1204
-
1205
- # 2. Retrieve pre-calced integrals
1298
+ if not pname:
1299
+ continue
1300
+
1206
1301
  integrals = template_integrals.get(pname)
1207
- if not integrals: continue
1208
-
1302
+ if not integrals:
1303
+ continue
1304
+
1209
1305
  S_sr, S_sg, S_sb = integrals
1210
-
1211
- if S_sg <= 0: continue
1306
+ if S_sg <= 0:
1307
+ continue
1212
1308
 
1213
- exp_RG = S_sr / S_sg; exp_BG = S_sb / S_sg
1214
- meas_RG = Rm / Gm; meas_BG = Bm / Gm
1309
+ exp_RG = S_sr / S_sg
1310
+ exp_BG = S_sb / S_sg
1311
+ meas_RG = Rm / Gm
1312
+ meas_BG = Bm / Gm
1215
1313
 
1216
1314
  diag_meas_RG.append(meas_RG); diag_exp_RG.append(exp_RG)
1217
1315
  diag_meas_BG.append(meas_BG); diag_exp_BG.append(exp_BG)
1218
1316
 
1219
1317
  enriched.append({
1220
- **m, "R_meas": Rm, "G_meas": Gm, "B_meas": Bm,
1318
+ **m,
1319
+ "R_meas": Rm, "G_meas": Gm, "B_meas": Bm,
1221
1320
  "S_star_R": S_sr, "S_star_G": S_sg, "S_star_B": S_sb,
1222
1321
  "exp_RG": exp_RG, "exp_BG": exp_BG
1223
1322
  })
1224
-
1323
+
1225
1324
  self._last_matched = enriched
1226
- diag_meas_RG = np.array(diag_meas_RG); diag_exp_RG = np.array(diag_exp_RG)
1227
- diag_meas_BG = np.array(diag_meas_BG); diag_exp_BG = np.array(diag_exp_BG)
1325
+ diag_meas_RG = np.asarray(diag_meas_RG, dtype=np.float64)
1326
+ diag_exp_RG = np.asarray(diag_exp_RG, dtype=np.float64)
1327
+ diag_meas_BG = np.asarray(diag_meas_BG, dtype=np.float64)
1328
+ diag_exp_BG = np.asarray(diag_exp_BG, dtype=np.float64)
1329
+
1228
1330
  if diag_meas_RG.size == 0 or diag_meas_BG.size == 0:
1229
- QMessageBox.information(self, "No Valid Stars", "No stars with valid measured vs expected ratios."); return
1230
- n_stars = diag_meas_RG.size
1331
+ QMessageBox.information(self, "No Valid Stars", "No stars with valid measured vs expected ratios.")
1332
+ return
1333
+
1334
+ n_stars = int(diag_meas_RG.size)
1231
1335
 
1232
- def rms_frac(pred, exp): return np.sqrt(np.mean(((pred/exp) - 1.0) ** 2))
1233
- slope_only = lambda x, m: m*x
1234
- affine = lambda x, m, b: m*x + b
1235
- quad = lambda x, a, b, c: a*x**2 + b*x + c
1336
+ def rms_frac(pred, exp):
1337
+ return float(np.sqrt(np.mean(((pred / exp) - 1.0) ** 2)))
1236
1338
 
1237
- denR = np.sum(diag_meas_RG**2); denB = np.sum(diag_meas_BG**2)
1238
- mR_s = (np.sum(diag_meas_RG * diag_exp_RG) / denR) if denR > 0 else 1.0
1239
- mB_s = (np.sum(diag_meas_BG * diag_exp_BG) / denB) if denB > 0 else 1.0
1339
+ slope_only = lambda x, m: m * x
1340
+ affine = lambda x, m, b: m * x + b
1341
+ quad = lambda x, a, b, c: a * x**2 + b * x + c
1342
+
1343
+ denR = float(np.sum(diag_meas_RG**2))
1344
+ denB = float(np.sum(diag_meas_BG**2))
1345
+ mR_s = (float(np.sum(diag_meas_RG * diag_exp_RG)) / denR) if denR > 0 else 1.0
1346
+ mB_s = (float(np.sum(diag_meas_BG * diag_exp_BG)) / denB) if denB > 0 else 1.0
1240
1347
  rms_s = rms_frac(slope_only(diag_meas_RG, mR_s), diag_exp_RG) + rms_frac(slope_only(diag_meas_BG, mB_s), diag_exp_BG)
1241
1348
 
1242
- mR_a, bR_a = np.linalg.lstsq(np.vstack([diag_meas_RG, np.ones_like(diag_meas_RG)]).T, diag_exp_RG, rcond=None)[0]
1243
- mB_a, bB_a = np.linalg.lstsq(np.vstack([diag_meas_BG, np.ones_like(diag_meas_BG)]).T, diag_exp_BG, rcond=None)[0]
1349
+ mR_a, bR_a = np.linalg.lstsq(
1350
+ np.vstack([diag_meas_RG, np.ones_like(diag_meas_RG)]).T, diag_exp_RG, rcond=None
1351
+ )[0]
1352
+ mB_a, bB_a = np.linalg.lstsq(
1353
+ np.vstack([diag_meas_BG, np.ones_like(diag_meas_BG)]).T, diag_exp_BG, rcond=None
1354
+ )[0]
1244
1355
  rms_a = rms_frac(affine(diag_meas_RG, mR_a, bR_a), diag_exp_RG) + rms_frac(affine(diag_meas_BG, mB_a, bB_a), diag_exp_BG)
1245
1356
 
1246
1357
  aR_q, bR_q, cR_q = np.polyfit(diag_meas_RG, diag_exp_RG, 2)
1247
1358
  aB_q, bB_q, cB_q = np.polyfit(diag_meas_BG, diag_exp_BG, 2)
1248
1359
  rms_q = rms_frac(quad(diag_meas_RG, aR_q, bR_q, cR_q), diag_exp_RG) + rms_frac(quad(diag_meas_BG, aB_q, bB_q, cB_q), diag_exp_BG)
1249
1360
 
1250
- idx = np.argmin([rms_s, rms_a, rms_q])
1251
- if idx == 0: coeff_R, coeff_B, model_choice = (0, mR_s, 0), (0, mB_s, 0), "slope-only"
1252
- elif idx == 1: coeff_R, coeff_B, model_choice = (0, mR_a, bR_a), (0, mB_a, bB_a), "affine"
1253
- else: coeff_R, coeff_B, model_choice = (aR_q, bR_q, cR_q), (aB_q, bB_q, cB_q), "quadratic"
1361
+ idx = int(np.argmin([rms_s, rms_a, rms_q]))
1362
+ if idx == 0:
1363
+ coeff_R, coeff_B, model_choice = (0.0, float(mR_s), 0.0), (0.0, float(mB_s), 0.0), "slope-only"
1364
+ elif idx == 1:
1365
+ coeff_R, coeff_B, model_choice = (0.0, float(mR_a), float(bR_a)), (0.0, float(mB_a), float(bB_a)), "affine"
1366
+ else:
1367
+ coeff_R, coeff_B, model_choice = (float(aR_q), float(bR_q), float(cR_q)), (float(aB_q), float(bB_q), float(cB_q)), "quadratic"
1368
+
1369
+ poly = lambda c, x: c[0] * x**2 + c[1] * x + c[2]
1254
1370
 
1255
- poly = lambda c, x: c[0]*x**2 + c[1]*x + c[2]
1371
+ # ---- Diagnostics plot (unchanged) ----
1256
1372
  self.figure.clf()
1257
- #ax1 = self.figure.add_subplot(1, 3, 1); bins=20
1258
- #ax1.hist(diag_meas_RG, bins=bins, alpha=.65, label="meas R/G", color="firebrick", edgecolor="black")
1259
- #ax1.hist(diag_exp_RG, bins=bins, alpha=.55, label="exp R/G", color="salmon", edgecolor="black")
1260
- #ax1.hist(diag_meas_BG, bins=bins, alpha=.65, label="meas B/G", color="royalblue", edgecolor="black")
1261
- #ax1.hist(diag_exp_BG, bins=bins, alpha=.55, label="exp B/G", color="lightskyblue", edgecolor="black")
1262
- #ax1.set_xlabel("Ratio (band / G)"); ax1.set_ylabel("Count"); ax1.set_title("Measured vs expected"); ax1.legend(fontsize=7, frameon=False)
1263
-
1264
1373
  res0_RG = (diag_meas_RG / diag_exp_RG) - 1.0
1265
1374
  res0_BG = (diag_meas_BG / diag_exp_BG) - 1.0
1266
1375
  res1_RG = (poly(coeff_R, diag_meas_RG) / diag_exp_RG) - 1.0
1267
1376
  res1_BG = (poly(coeff_B, diag_meas_BG) / diag_exp_BG) - 1.0
1268
1377
 
1269
- ymin = np.min(np.concatenate([res0_RG, res0_BG])); ymax = np.max(np.concatenate([res0_RG, res0_BG]))
1270
- pad = 0.05 * (ymax - ymin) if ymax > ymin else 0.02; y_lim = (ymin - pad, ymax + pad)
1378
+ ymin = float(np.min(np.concatenate([res0_RG, res0_BG])))
1379
+ ymax = float(np.max(np.concatenate([res0_RG, res0_BG])))
1380
+ pad = 0.05 * (ymax - ymin) if ymax > ymin else 0.02
1381
+ y_lim = (ymin - pad, ymax + pad)
1382
+
1271
1383
  def shade(ax, yvals, color):
1272
- q1, q3 = np.percentile(yvals, [25,75]); ax.axhspan(q1, q3, color=color, alpha=.10, zorder=0)
1384
+ q1, q3 = np.percentile(yvals, [25, 75])
1385
+ ax.axhspan(q1, q3, color=color, alpha=0.10, zorder=0)
1273
1386
 
1274
1387
  ax2 = self.figure.add_subplot(1, 2, 1)
1275
- ax2.axhline(0, color="0.65", ls="--", lw=1); shade(ax2, res0_RG, "firebrick"); shade(ax2, res0_BG, "royalblue")
1276
- ax2.scatter(diag_exp_RG, res0_RG, c="firebrick", marker="o", alpha=.7, label="R/G residual")
1277
- ax2.scatter(diag_exp_BG, res0_BG, c="royalblue", marker="s", alpha=.7, label="B/G residual")
1278
- ax2.set_ylim(*y_lim); ax2.set_xlabel("Expected (band/G)"); ax2.set_ylabel("Frac residual (meas/exp − 1)")
1279
- ax2.set_title("Residuals • BEFORE"); ax2.legend(frameon=False, fontsize=7, loc="lower right")
1388
+ ax2.axhline(0, color="0.65", ls="--", lw=1)
1389
+ shade(ax2, res0_RG, "firebrick"); shade(ax2, res0_BG, "royalblue")
1390
+ ax2.scatter(diag_exp_RG, res0_RG, c="firebrick", marker="o", alpha=0.7, label="R/G residual")
1391
+ ax2.scatter(diag_exp_BG, res0_BG, c="royalblue", marker="s", alpha=0.7, label="B/G residual")
1392
+ ax2.set_ylim(*y_lim)
1393
+ ax2.set_xlabel("Expected (band/G)")
1394
+ ax2.set_ylabel("Frac residual (meas/exp − 1)")
1395
+ ax2.set_title("Residuals • BEFORE")
1396
+ ax2.legend(frameon=False, fontsize=7, loc="lower right")
1280
1397
 
1281
1398
  ax3 = self.figure.add_subplot(1, 2, 2)
1282
- ax3.axhline(0, color="0.65", ls="--", lw=1); shade(ax3, res1_RG, "firebrick"); shade(ax3, res1_BG, "royalblue")
1283
- ax3.scatter(diag_exp_RG, res1_RG, c="firebrick", marker="o", alpha=.7)
1284
- ax3.scatter(diag_exp_BG, res1_BG, c="royalblue", marker="s", alpha=.7)
1285
- ax3.set_ylim(*y_lim); ax3.set_xlabel("Expected (band/G)"); ax3.set_ylabel("Frac residual (corrected/exp − 1)")
1399
+ ax3.axhline(0, color="0.65", ls="--", lw=1)
1400
+ shade(ax3, res1_RG, "firebrick"); shade(ax3, res1_BG, "royalblue")
1401
+ ax3.scatter(diag_exp_RG, res1_RG, c="firebrick", marker="o", alpha=0.7)
1402
+ ax3.scatter(diag_exp_BG, res1_BG, c="royalblue", marker="s", alpha=0.7)
1403
+ ax3.set_ylim(*y_lim)
1404
+ ax3.set_xlabel("Expected (band/G)")
1405
+ ax3.set_ylabel("Frac residual (corrected/exp − 1)")
1286
1406
  ax3.set_title("Residuals • AFTER")
1287
- self.canvas.setVisible(True); self.figure.tight_layout(w_pad=2.); self.canvas.draw()
1288
-
1289
- self.count_label.setText("Applying SFCC color scales to image…"); QApplication.processEvents()
1290
- if img.dtype == np.uint8: img_float = img.astype(np.float32) / 255.0
1291
- else: img_float = img.astype(np.float32)
1292
-
1293
- RG = img_float[..., 0] / np.maximum(img_float[..., 1], 1e-8)
1294
- BG = img_float[..., 2] / np.maximum(img_float[..., 1], 1e-8)
1295
- aR, bR, cR = coeff_R; aB, bB, cB = coeff_B
1296
- RG_corr = aR*RG**2 + bR*RG + cR
1297
- BG_corr = aB*BG**2 + bB*BG + cB
1298
- calibrated = img_float.copy()
1299
- calibrated[..., 0] = RG_corr * img_float[..., 1]
1300
- calibrated[..., 2] = BG_corr * img_float[..., 1]
1301
- calibrated = np.clip(calibrated, 0, 1)
1302
1407
 
1408
+ self.canvas.setVisible(True)
1409
+ self.figure.tight_layout(w_pad=2.0)
1410
+ self.canvas.draw()
1411
+
1412
+ # ---- Apply SFCC correction to ORIGINAL floats (not the SEP base) ----
1413
+ self.count_label.setText("Applying SFCC color scales to image…")
1414
+ QApplication.processEvents()
1415
+
1416
+ eps = 1e-8
1417
+ calibrated = base.copy()
1418
+
1419
+ R = calibrated[..., 0]
1420
+ G = calibrated[..., 1]
1421
+ B = calibrated[..., 2]
1422
+
1423
+ RG = R / np.maximum(G, eps)
1424
+ BG = B / np.maximum(G, eps)
1425
+
1426
+ aR, bR, cR = coeff_R
1427
+ aB, bB, cB = coeff_B
1428
+
1429
+ mR = aR * RG**2 + bR * RG + cR
1430
+ mB = aB * BG**2 + bB * BG + cB
1431
+
1432
+ mR = np.clip(mR, 0.25, 4.0)
1433
+ mB = np.clip(mB, 0.25, 4.0)
1434
+
1435
+ pR = float(np.median(R))
1436
+ pB = float(np.median(B))
1437
+
1438
+ calibrated[..., 0] = _pivot_scale_channel(R, mR, pR)
1439
+ calibrated[..., 2] = _pivot_scale_channel(B, mB, pB)
1440
+
1441
+ calibrated = np.clip(calibrated, 0.0, 1.0)
1442
+
1443
+ # --- OPTIONAL: apply BN/pedestal to the FINAL calibrated image, not just SEP base ---
1303
1444
  if self.neutralize_chk.isChecked():
1304
- calibrated = self._neutralize_background(calibrated, patch_size=10)
1445
+ try:
1446
+ print("[SFCC] Applying background neutralization to final calibrated image...")
1447
+ _debug_probe_channels(calibrated, "final_before_BN")
1448
+
1449
+ # If you want pedestal removal as part of BN, set remove_pedestal=True here
1450
+ # (and/or make this a checkbox)
1451
+ calibrated = self._neutralize_background(calibrated, remove_pedestal=True)
1305
1452
 
1453
+ _debug_probe_channels(calibrated, "final_after_BN")
1454
+ except Exception as e:
1455
+ print(f"[SFCC] Final BN failed: {e}")
1456
+
1457
+
1458
+ # Convert back to original dtype
1306
1459
  if img.dtype == np.uint8:
1307
- calibrated = (np.clip(calibrated, 0, 1) * 255.0).astype(np.uint8)
1460
+ out_img = (np.clip(calibrated, 0.0, 1.0) * 255.0).astype(np.uint8)
1308
1461
  else:
1309
- calibrated = np.clip(calibrated, 0, 1).astype(np.float32)
1462
+ out_img = np.clip(calibrated, 0.0, 1.0).astype(np.float32)
1310
1463
 
1311
1464
  new_meta = dict(doc.metadata or {})
1312
1465
  new_meta.update({
@@ -1318,25 +1471,31 @@ class SFCCDialog(QDialog):
1318
1471
  })
1319
1472
 
1320
1473
  self.doc_manager.update_active_document(
1321
- calibrated,
1474
+ out_img,
1322
1475
  metadata=new_meta,
1323
1476
  step_name="SFCC Calibrated",
1324
- doc=doc, # 👈 pin to the document we started from
1477
+ doc=doc,
1325
1478
  )
1326
1479
 
1327
1480
  self.count_label.setText(f"Applied SFCC color calibration using {n_stars} stars")
1328
1481
  QApplication.processEvents()
1329
1482
 
1330
- def pretty(coeff): return coeff[0] + coeff[1] + coeff[2]
1331
- ratio_R, ratio_B = pretty(coeff_R), pretty(coeff_B)
1332
- QMessageBox.information(self, "SFCC Complete",
1333
- f"Applied SFCC using {n_stars} stars\n"
1334
- f"Model: {model_choice}\n"
1335
- f"R ratio @ x=1: {ratio_R:.4f}\n"
1336
- f"B ratio @ x=1: {ratio_B:.4f}\n"
1337
- f"Background neutralisation: {'ON' if self.neutralize_chk.isChecked() else 'OFF'}")
1483
+ def pretty(coeff):
1484
+ # coefficient sum gives you f(1) for quadratic form a*x^2+b*x+c at x=1
1485
+ return float(coeff[0] + coeff[1] + coeff[2])
1486
+
1487
+ QMessageBox.information(
1488
+ self,
1489
+ "SFCC Complete",
1490
+ f"Applied SFCC using {n_stars} stars\n"
1491
+ f"Model: {model_choice}\n"
1492
+ f"R ratio @ x=1: {pretty(coeff_R):.4f}\n"
1493
+ f"B ratio @ x=1: {pretty(coeff_B):.4f}\n"
1494
+ f"Background neutralisation: {'ON' if self.neutralize_chk.isChecked() else 'OFF'}"
1495
+ )
1496
+
1497
+ self.current_image = out_img # keep for gradient step
1338
1498
 
1339
- self.current_image = calibrated # keep for gradient step
1340
1499
 
1341
1500
  # ── Chromatic gradient (optional) ──────────────────────────────────
1342
1501
 
@@ -1454,11 +1613,64 @@ class SFCCDialog(QDialog):
1454
1613
  self.sasp_viewer_window.show()
1455
1614
  self.sasp_viewer_window.destroyed.connect(self._on_sasp_closed)
1456
1615
 
1616
+ def _cleanup(self):
1617
+ # 1) Close/cleanup child window (SaspViewer)
1618
+ try:
1619
+ if getattr(self, "sasp_viewer_window", None) is not None:
1620
+ try:
1621
+ self.sasp_viewer_window.destroyed.disconnect(self._on_sasp_closed)
1622
+ except Exception:
1623
+ pass
1624
+ try:
1625
+ self.sasp_viewer_window.close()
1626
+ except Exception:
1627
+ pass
1628
+ self.sasp_viewer_window = None
1629
+ except Exception:
1630
+ pass
1631
+
1632
+ # 2) Disconnect any long-lived external signals (add these if/when used)
1633
+ # Example patterns:
1634
+ try:
1635
+ self.doc_manager.activeDocumentChanged.disconnect(self._on_active_doc_changed)
1636
+ except Exception:
1637
+ pass
1638
+ try:
1639
+ self.main_win.currentDocumentChanged.disconnect(self._on_active_doc_changed)
1640
+ except Exception:
1641
+ pass
1642
+
1643
+ # 3) Release large caches/refs (important since dialog may not be deleted)
1644
+ try:
1645
+ self.current_image = None
1646
+ self.current_header = None
1647
+ self.star_list = []
1648
+ self._last_matched = []
1649
+ if hasattr(self, "wcs"):
1650
+ self.wcs = None
1651
+ if hasattr(self, "wcs_header"):
1652
+ self.wcs_header = None
1653
+ except Exception:
1654
+ pass
1655
+
1656
+ # 4) Matplotlib cleanup
1657
+ try:
1658
+ if getattr(self, "figure", None) is not None:
1659
+ self.figure.clf()
1660
+ if getattr(self, "canvas", None) is not None:
1661
+ self.canvas.setVisible(False)
1662
+ self.canvas.draw_idle()
1663
+ except Exception:
1664
+ pass
1665
+
1666
+
1457
1667
  def _on_sasp_closed(self, _=None):
1458
1668
  # Called when the SaspViewer window is destroyed
1459
1669
  self.sasp_viewer_window = None
1670
+ self._cleanup()
1460
1671
 
1461
1672
  def closeEvent(self, event):
1673
+ self._cleanup()
1462
1674
  super().closeEvent(event)
1463
1675
 
1464
1676