setiastrosuitepro 1.6.4__py3-none-any.whl → 1.7.1.post2__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 (132) hide show
  1. setiastro/images/TextureClarity.svg +56 -0
  2. setiastro/images/abeicon.svg +16 -0
  3. setiastro/images/acv_icon.png +0 -0
  4. setiastro/images/colorwheel.svg +97 -0
  5. setiastro/images/cosmic.svg +40 -0
  6. setiastro/images/cosmicsat.svg +24 -0
  7. setiastro/images/first_quarter.png +0 -0
  8. setiastro/images/full_moon.png +0 -0
  9. setiastro/images/graxpert.svg +19 -0
  10. setiastro/images/last_quarter.png +0 -0
  11. setiastro/images/linearfit.svg +32 -0
  12. setiastro/images/narrowbandnormalization.png +0 -0
  13. setiastro/images/new_moon.png +0 -0
  14. setiastro/images/pixelmath.svg +42 -0
  15. setiastro/images/planetarystacker.png +0 -0
  16. setiastro/images/waning_crescent_1.png +0 -0
  17. setiastro/images/waning_crescent_2.png +0 -0
  18. setiastro/images/waning_crescent_3.png +0 -0
  19. setiastro/images/waning_crescent_4.png +0 -0
  20. setiastro/images/waning_crescent_5.png +0 -0
  21. setiastro/images/waning_gibbous_1.png +0 -0
  22. setiastro/images/waning_gibbous_2.png +0 -0
  23. setiastro/images/waning_gibbous_3.png +0 -0
  24. setiastro/images/waning_gibbous_4.png +0 -0
  25. setiastro/images/waning_gibbous_5.png +0 -0
  26. setiastro/images/waxing_crescent_1.png +0 -0
  27. setiastro/images/waxing_crescent_2.png +0 -0
  28. setiastro/images/waxing_crescent_3.png +0 -0
  29. setiastro/images/waxing_crescent_4.png +0 -0
  30. setiastro/images/waxing_crescent_5.png +0 -0
  31. setiastro/images/waxing_gibbous_1.png +0 -0
  32. setiastro/images/waxing_gibbous_2.png +0 -0
  33. setiastro/images/waxing_gibbous_3.png +0 -0
  34. setiastro/images/waxing_gibbous_4.png +0 -0
  35. setiastro/images/waxing_gibbous_5.png +0 -0
  36. setiastro/qml/ResourceMonitor.qml +84 -82
  37. setiastro/saspro/__main__.py +20 -1
  38. setiastro/saspro/_generated/build_info.py +2 -2
  39. setiastro/saspro/abe.py +37 -4
  40. setiastro/saspro/aberration_ai.py +364 -33
  41. setiastro/saspro/aberration_ai_preset.py +29 -3
  42. setiastro/saspro/acv_exporter.py +379 -0
  43. setiastro/saspro/add_stars.py +33 -6
  44. setiastro/saspro/astrospike_python.py +45 -3
  45. setiastro/saspro/backgroundneutral.py +108 -40
  46. setiastro/saspro/blemish_blaster.py +4 -1
  47. setiastro/saspro/blink_comparator_pro.py +150 -55
  48. setiastro/saspro/clahe.py +4 -1
  49. setiastro/saspro/continuum_subtract.py +4 -1
  50. setiastro/saspro/convo.py +13 -7
  51. setiastro/saspro/cosmicclarity.py +129 -18
  52. setiastro/saspro/crop_dialog_pro.py +123 -7
  53. setiastro/saspro/curve_editor_pro.py +181 -64
  54. setiastro/saspro/curves_preset.py +249 -47
  55. setiastro/saspro/doc_manager.py +245 -15
  56. setiastro/saspro/exoplanet_detector.py +120 -28
  57. setiastro/saspro/frequency_separation.py +1158 -204
  58. setiastro/saspro/ghs_dialog_pro.py +81 -16
  59. setiastro/saspro/graxpert.py +1 -0
  60. setiastro/saspro/gui/main_window.py +706 -264
  61. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  62. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  63. setiastro/saspro/gui/mixins/menu_mixin.py +35 -1
  64. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  65. setiastro/saspro/gui/mixins/toolbar_mixin.py +499 -24
  66. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  67. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  68. setiastro/saspro/halobgon.py +4 -0
  69. setiastro/saspro/histogram.py +184 -8
  70. setiastro/saspro/image_combine.py +4 -0
  71. setiastro/saspro/image_peeker_pro.py +4 -0
  72. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  73. setiastro/saspro/imageops/serloader.py +1345 -0
  74. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  75. setiastro/saspro/imageops/stretch.py +582 -62
  76. setiastro/saspro/isophote.py +4 -0
  77. setiastro/saspro/layers.py +13 -9
  78. setiastro/saspro/layers_dock.py +183 -3
  79. setiastro/saspro/legacy/image_manager.py +154 -20
  80. setiastro/saspro/legacy/numba_utils.py +68 -48
  81. setiastro/saspro/legacy/xisf.py +240 -98
  82. setiastro/saspro/live_stacking.py +203 -82
  83. setiastro/saspro/luminancerecombine.py +228 -27
  84. setiastro/saspro/mask_creation.py +174 -15
  85. setiastro/saspro/mfdeconv.py +113 -35
  86. setiastro/saspro/mfdeconvcudnn.py +119 -70
  87. setiastro/saspro/mfdeconvsport.py +112 -35
  88. setiastro/saspro/morphology.py +4 -0
  89. setiastro/saspro/multiscale_decomp.py +81 -29
  90. setiastro/saspro/narrowband_normalization.py +1618 -0
  91. setiastro/saspro/numba_utils.py +72 -57
  92. setiastro/saspro/ops/commands.py +18 -18
  93. setiastro/saspro/ops/script_editor.py +10 -2
  94. setiastro/saspro/ops/scripts.py +122 -0
  95. setiastro/saspro/perfect_palette_picker.py +37 -3
  96. setiastro/saspro/plate_solver.py +84 -49
  97. setiastro/saspro/psf_viewer.py +119 -37
  98. setiastro/saspro/remove_green.py +1 -1
  99. setiastro/saspro/resources.py +73 -0
  100. setiastro/saspro/rgbalign.py +460 -12
  101. setiastro/saspro/selective_color.py +4 -1
  102. setiastro/saspro/ser_stack_config.py +82 -0
  103. setiastro/saspro/ser_stacker.py +2321 -0
  104. setiastro/saspro/ser_stacker_dialog.py +1838 -0
  105. setiastro/saspro/ser_tracking.py +206 -0
  106. setiastro/saspro/serviewer.py +1625 -0
  107. setiastro/saspro/sfcc.py +662 -216
  108. setiastro/saspro/shortcuts.py +171 -33
  109. setiastro/saspro/signature_insert.py +692 -33
  110. setiastro/saspro/stacking_suite.py +1347 -485
  111. setiastro/saspro/star_alignment.py +247 -123
  112. setiastro/saspro/star_spikes.py +4 -0
  113. setiastro/saspro/star_stretch.py +38 -3
  114. setiastro/saspro/stat_stretch.py +892 -129
  115. setiastro/saspro/subwindow.py +787 -363
  116. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  117. setiastro/saspro/texture_clarity.py +593 -0
  118. setiastro/saspro/wavescale_hdr.py +4 -1
  119. setiastro/saspro/wavescalede.py +4 -1
  120. setiastro/saspro/whitebalance.py +84 -12
  121. setiastro/saspro/widgets/common_utilities.py +28 -21
  122. setiastro/saspro/widgets/resource_monitor.py +209 -111
  123. setiastro/saspro/widgets/spinboxes.py +10 -13
  124. setiastro/saspro/wimi.py +27 -656
  125. setiastro/saspro/wims.py +13 -3
  126. setiastro/saspro/xisf.py +101 -11
  127. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +4 -2
  128. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +132 -87
  129. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
  130. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
  131. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
  132. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/license.txt +0 -0
setiastro/saspro/sfcc.py CHANGED
@@ -20,6 +20,7 @@ from datetime import datetime
20
20
  from typing import List, Tuple, Optional
21
21
 
22
22
  import numpy as np
23
+ import numpy.ma as ma
23
24
  import pandas as pd
24
25
 
25
26
  # ── SciPy bits
@@ -32,6 +33,7 @@ from astropy.wcs import WCS
32
33
  import astropy.units as u
33
34
  from astropy.coordinates import SkyCoord
34
35
  from astroquery.simbad import Simbad
36
+ from astropy.wcs.wcs import NoConvergence
35
37
 
36
38
  # ── SEP (Source Extractor)
37
39
  import sep
@@ -48,6 +50,9 @@ from PyQt6.QtWidgets import (QToolBar, QWidget, QToolButton, QMenu, QApplication
48
50
  QInputDialog, QMessageBox, QDialog, QFileDialog,
49
51
  QFormLayout, QDialogButtonBox, QDoubleSpinBox, QCheckBox, QLabel, QRubberBand, QRadioButton, QMainWindow, QPushButton)
50
52
 
53
+ from setiastro.saspro.backgroundneutral import run_background_neutral_via_preset
54
+ from setiastro.saspro.backgroundneutral import background_neutralize_rgb, auto_rect_50x50
55
+
51
56
 
52
57
  # ──────────────────────────────────────────────────────────────────────────────
53
58
  # Utilities
@@ -188,6 +193,12 @@ def compute_gradient_map(sources, delta_flux, shape, method="poly2"):
188
193
  else:
189
194
  raise ValueError("method must be one of 'poly2','poly3','rbf'")
190
195
 
196
+ def _pivot_scale_channel(ch: np.ndarray, gain: np.ndarray | float, pivot: float) -> np.ndarray:
197
+ """
198
+ Apply gain around a pivot: pivot + (x - pivot)*gain.
199
+ gain can be scalar or per-pixel array.
200
+ """
201
+ return pivot + (ch - pivot) * gain
191
202
 
192
203
  # ──────────────────────────────────────────────────────────────────────────────
193
204
  # Simple responses viewer (unchanged core logic; useful for diagnostics)
@@ -349,6 +360,10 @@ class SFCCDialog(QDialog):
349
360
  self.setWindowFlag(Qt.WindowType.Window, True)
350
361
  self.setWindowModality(Qt.WindowModality.NonModal)
351
362
  self.setModal(False)
363
+ try:
364
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
365
+ except Exception:
366
+ pass # older PyQt6 versions
352
367
  self.setMinimumSize(800, 600)
353
368
 
354
369
  self.doc_manager = doc_manager
@@ -375,6 +390,7 @@ class SFCCDialog(QDialog):
375
390
  self.lp_filter_combo2.currentIndexChanged.connect(self.save_lp2_setting)
376
391
  self.sens_combo.currentIndexChanged.connect(self.save_sensor_setting)
377
392
  self.star_combo.currentIndexChanged.connect(self.save_star_setting)
393
+ self.finished.connect(lambda *_: self._cleanup())
378
394
 
379
395
  self.grad_method = "poly3"
380
396
  self.grad_method_combo.currentTextChanged.connect(lambda m: setattr(self, "grad_method", m))
@@ -528,12 +544,12 @@ class SFCCDialog(QDialog):
528
544
  self.remove_curve_btn = QPushButton(self.tr("Remove Filter/Sensor Curve…"))
529
545
  self.remove_curve_btn.clicked.connect(self.remove_custom_curve); row4.addWidget(self.remove_curve_btn)
530
546
  row4.addStretch()
531
- self.close_btn = QPushButton(self.tr("Close")); self.close_btn.clicked.connect(self.close); row4.addWidget(self.close_btn)
547
+ self.close_btn = QPushButton(self.tr("Close")); self.close_btn.clicked.connect(self.reject); row4.addWidget(self.close_btn)
532
548
 
533
549
  self.count_label = QLabel(""); layout.addWidget(self.count_label)
534
550
 
535
551
  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)
552
+ self.reset_btn = QPushButton(self.tr("Reset View/Close")); self.reset_btn.clicked.connect(self.reject); layout.addWidget(self.reset_btn)
537
553
 
538
554
  # hide gradient controls by default (enable if you like)
539
555
  self.run_grad_btn.hide(); self.grad_method_combo.hide()
@@ -870,32 +886,190 @@ class SFCCDialog(QDialog):
870
886
  return self.wcs.all_pix2world(x, y, 0)
871
887
 
872
888
  # ── Background neutralization ───────────────────────────────────────
889
+ def _neutralize_background(self, rgb_f: np.ndarray, *, remove_pedestal: bool = False) -> np.ndarray:
890
+ img = np.asarray(rgb_f, dtype=np.float32)
891
+
892
+ if img.ndim != 3 or img.shape[2] != 3:
893
+ raise ValueError("Expected RGB image (H,W,3)")
873
894
 
874
- def _neutralize_background(self, rgb_img: np.ndarray, patch_size: int = 50) -> np.ndarray:
875
- img = rgb_img.copy()
895
+ img = np.clip(img, 0.0, 1.0)
896
+
897
+ try:
898
+ rect = auto_rect_50x50(img) # same SASv2-ish auto finder
899
+ out = background_neutralize_rgb(
900
+ img,
901
+ rect,
902
+ mode="pivot1", # or "offset" if you prefer
903
+ remove_pedestal=remove_pedestal,
904
+ )
905
+ return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
906
+
907
+ except Exception as e:
908
+ print(f"[SFCC] BN preset failed, falling back to simple neutralization: {e}")
909
+ return self._neutralize_background_simple(img, patch_size=10)
910
+
911
+ def _neutralize_background_simple(self, rgb_f: np.ndarray, patch_size: int = 50) -> np.ndarray:
912
+ """
913
+ Simple neutralization: find darkest patch by summed medians,
914
+ then equalize channel medians around the mean.
915
+ Assumes rgb_f is float in [0,1] with no negatives.
916
+ """
917
+ img = np.asarray(rgb_f, dtype=np.float32).copy()
876
918
  h, w = img.shape[:2]
877
- ph, pw = h // patch_size, w // patch_size
919
+ ph, pw = max(1, h // patch_size), max(1, w // patch_size)
920
+
878
921
  min_sum, best_med = np.inf, None
879
922
  for i in range(patch_size):
880
923
  for j in range(patch_size):
881
924
  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()
925
+ patch = img[y0:min(y0 + ph, h), x0:min(x0 + pw, w), :]
926
+ if patch.size == 0:
927
+ continue
928
+ med = np.median(patch, axis=(0, 1))
929
+ s = float(med.sum())
885
930
  if s < min_sum:
886
931
  min_sum, best_med = s, med
932
+
887
933
  if best_med is None:
888
- return img
889
- target = float(best_med.mean()); eps = 1e-8
934
+ return np.clip(img, 0.0, 1.0)
935
+
936
+ target = float(best_med.mean())
937
+ eps = 1e-8
890
938
  for c in range(3):
891
939
  diff = float(best_med[c] - target)
892
- if abs(diff) < eps: continue
940
+ if abs(diff) < eps:
941
+ continue
942
+ # Preserve [0,1] scale; keep the same form you were using.
893
943
  img[..., c] = np.clip((img[..., c] - diff) / (1.0 - diff), 0.0, 1.0)
894
- return img
944
+
945
+ return np.clip(img, 0.0, 1.0)
946
+
947
+ def _make_working_base_for_sep(self, img_float: np.ndarray) -> np.ndarray:
948
+ """
949
+ Build a working copy for SEP + calibration.
950
+
951
+ Pedestal removal (per channel):
952
+ ch <- ch - min(ch)
953
+
954
+ Then clamp to [0,1] for stability.
955
+ """
956
+ base = np.asarray(img_float, dtype=np.float32).copy()
957
+
958
+ if base.ndim != 3 or base.shape[2] != 3:
959
+ raise ValueError("Expected RGB image (H,W,3)")
960
+
961
+ # --- Per-channel pedestal removal: ch -= min(ch) ---
962
+ mins = base.reshape(-1, 3).min(axis=0) # (3,)
963
+ base[..., 0] -= float(mins[0])
964
+ base[..., 1] -= float(mins[1])
965
+ base[..., 2] -= float(mins[2])
966
+
967
+ # Stability clamp (SEP likes non-negative; your pipeline assumes [0,1])
968
+ base = np.clip(base, 0.0, 1.0)
969
+
970
+ return base
971
+
895
972
 
896
973
  # ── SIMBAD/Star fetch ──────────────────────────────────────────────
974
+ def initialize_wcs_from_header(self, header):
975
+ """
976
+ Build a robust 2D celestial WCS from the provided header.
977
+
978
+ - Normalizes deprecated RADECSYS/EPOCH keywords.
979
+ - Uses relax=True.
980
+ - Stores:
981
+ self.wcs (WCS)
982
+ self.wcs_header (fits.Header)
983
+ self.pixscale (arcsec/pixel approx)
984
+ self.center_ra, self.center_dec (deg)
985
+ self.orientation (deg, if derivable)
986
+ """
987
+ if header is None:
988
+ print("[SFCC] No FITS header available; cannot build WCS.")
989
+ self.wcs = None
990
+ return
991
+
992
+ try:
993
+ hdr = header.copy()
994
+
995
+ # --- normalize deprecated keywords ---
996
+ if "RADECSYS" in hdr and "RADESYS" not in hdr:
997
+ radesys_val = str(hdr["RADECSYS"]).strip()
998
+ hdr["RADESYS"] = radesys_val
999
+ try:
1000
+ del hdr["RADECSYS"]
1001
+ except Exception:
1002
+ pass
1003
+
1004
+ # Carry to alternate WCS letters if present (CTYPE1A, CTYPE2A, etc.)
1005
+ alt_letters = {
1006
+ k[-1]
1007
+ for k in hdr.keys()
1008
+ if re.match(r"^CTYPE[12][A-Z]$", k)
1009
+ }
1010
+ for a in alt_letters:
1011
+ key = f"RADESYS{a}"
1012
+ if key not in hdr:
1013
+ hdr[key] = radesys_val
1014
+
1015
+ if "EPOCH" in hdr and "EQUINOX" not in hdr:
1016
+ hdr["EQUINOX"] = hdr["EPOCH"]
1017
+ try:
1018
+ del hdr["EPOCH"]
1019
+ except Exception:
1020
+ pass
1021
+
1022
+ # Build WCS
1023
+ self.wcs = WCS(hdr, naxis=2, relax=True)
1024
+
1025
+ # Pixel scale estimate (arcsec/px) from pixel_scale_matrix if available
1026
+ try:
1027
+ psm = self.wcs.pixel_scale_matrix
1028
+ self.pixscale = float(np.hypot(psm[0, 0], psm[1, 0]) * 3600.0)
1029
+ except Exception:
1030
+ self.pixscale = None
1031
+
1032
+ # CRVAL center
1033
+ try:
1034
+ self.center_ra, self.center_dec = [float(x) for x in self.wcs.wcs.crval]
1035
+ except Exception:
1036
+ self.center_ra, self.center_dec = None, None
1037
+
1038
+ # Save normalized header form
1039
+ try:
1040
+ self.wcs_header = self.wcs.to_header(relax=True)
1041
+ except Exception:
1042
+ self.wcs_header = None
1043
+
1044
+ # Orientation (optional)
1045
+ if "CROTA2" in hdr:
1046
+ try:
1047
+ self.orientation = float(hdr["CROTA2"])
1048
+ except Exception:
1049
+ self.orientation = None
1050
+ else:
1051
+ self.orientation = self.calculate_orientation(hdr)
1052
+
1053
+ if getattr(self, "orientation_label", None) is not None:
1054
+ if self.orientation is not None:
1055
+ self.orientation_label.setText(f"Orientation: {self.orientation:.2f}°")
1056
+ else:
1057
+ self.orientation_label.setText("Orientation: N/A")
1058
+
1059
+ except Exception as e:
1060
+ print("[SFCC] WCS initialization error:\n", e)
1061
+ self.wcs = None
1062
+
897
1063
 
898
1064
  def fetch_stars(self):
1065
+ import time
1066
+ from astropy.coordinates import SkyCoord
1067
+ import astropy.units as u
1068
+ from astropy.wcs.wcs import NoConvergence
1069
+ from astroquery.simbad import Simbad
1070
+ from astropy.io import fits
1071
+ from PyQt6.QtWidgets import QMessageBox, QApplication
1072
+
899
1073
  # 0) Grab current image + header from the active document
900
1074
  img, hdr, _meta = self._get_active_image_and_header()
901
1075
  self.current_image = img
@@ -911,7 +1085,7 @@ class SFCCDialog(QDialog):
911
1085
  self.pickles_templates = []
912
1086
  for p in (self.user_custom_path, self.sasp_data_path):
913
1087
  try:
914
- with fits.open(p) as hd:
1088
+ with fits.open(p, memmap=False) as hd:
915
1089
  for hdu in hd:
916
1090
  if (isinstance(hdu, fits.BinTableHDU)
917
1091
  and hdu.header.get("CTYPE", "").upper() == "SED"):
@@ -919,118 +1093,252 @@ class SFCCDialog(QDialog):
919
1093
  if extname and extname not in self.pickles_templates:
920
1094
  self.pickles_templates.append(extname)
921
1095
  except Exception as e:
922
- print(f"[fetch_stars] Could not load Pickles templates from {p}: {e}")
1096
+ print(f"[SFCC] [fetch_stars] Could not load Pickles templates from {p}: {e}")
1097
+ self.pickles_templates.sort()
923
1098
 
924
1099
  # Build WCS
925
1100
  try:
926
1101
  self.initialize_wcs_from_header(self.current_header)
927
1102
  except Exception:
928
- QMessageBox.critical(self, "WCS Error", "Could not build a 2D WCS from header."); return
1103
+ QMessageBox.critical(self, "WCS Error", "Could not build a 2D WCS from header.")
1104
+ return
1105
+
1106
+ if not getattr(self, "wcs", None):
1107
+ QMessageBox.critical(self, "WCS Error", "Could not build a 2D WCS from header.")
1108
+ return
1109
+
1110
+ # Use celestial WCS if possible (safe when WCS has extra axes)
1111
+ wcs2 = self.wcs.celestial if hasattr(self.wcs, "celestial") else self.wcs
929
1112
 
930
1113
  H, W = self.current_image.shape[:2]
931
- pix = np.array([[W/2, H/2], [0,0], [W,0], [0,H], [W,H]])
1114
+
1115
+ # --- original radius method (center + 4 corners) ---
1116
+ pix = np.array([[W / 2, H / 2], [0, 0], [W, 0], [0, H], [W, H]], dtype=float)
932
1117
  try:
933
- sky = self.wcs.all_pix2world(pix, 0)
1118
+ sky = wcs2.all_pix2world(pix, 0)
934
1119
  except Exception as e:
935
- QMessageBox.critical(self, "WCS Conversion Error", str(e)); return
936
- center_sky = SkyCoord(ra=sky[0,0]*u.deg, dec=sky[0,1]*u.deg, frame="icrs")
937
- corners_sky = SkyCoord(ra=sky[1:,0]*u.deg, dec=sky[1:,1]*u.deg, frame="icrs")
938
- radius_deg = center_sky.separation(corners_sky).max().deg
1120
+ QMessageBox.critical(self, "WCS Conversion Error", str(e))
1121
+ return
1122
+
1123
+ center_sky = SkyCoord(ra=float(sky[0, 0]) * u.deg, dec=float(sky[0, 1]) * u.deg, frame="icrs")
1124
+ corners_sky = SkyCoord(ra=sky[1:, 0] * u.deg, dec=sky[1:, 1] * u.deg, frame="icrs")
1125
+ radius = center_sky.separation(corners_sky).max() * 1.05 # small margin
939
1126
 
940
- # Simbad fields
1127
+ # --- SIMBAD fields (NEW first, fallback to legacy) ---
941
1128
  Simbad.reset_votable_fields()
942
- for attempt in range(1, 6):
1129
+
1130
+ def _try_new_fields():
1131
+ # new names: B,V,R + ra,dec
1132
+ Simbad.add_votable_fields("sp", "B", "V", "R", "ra", "dec")
1133
+
1134
+ def _try_legacy_fields():
1135
+ # legacy names
1136
+ Simbad.add_votable_fields("sp", "flux(B)", "flux(V)", "flux(R)", "ra(d)", "dec(d)")
1137
+
1138
+ ok = False
1139
+ for _ in range(5):
943
1140
  try:
944
- Simbad.add_votable_fields('sp', 'flux(B)', 'flux(V)', 'flux(R)')
1141
+ _try_new_fields()
1142
+ ok = True
945
1143
  break
946
1144
  except Exception:
947
1145
  QApplication.processEvents()
948
- time.sleep(1.2)
1146
+ time.sleep(0.8)
1147
+
1148
+ if not ok:
1149
+ for _ in range(5):
1150
+ try:
1151
+ _try_legacy_fields()
1152
+ ok = True
1153
+ break
1154
+ except Exception:
1155
+ QApplication.processEvents()
1156
+ time.sleep(0.8)
1157
+
1158
+ if not ok:
1159
+ QMessageBox.critical(self, "SIMBAD Error", "Could not configure SIMBAD votable fields.")
1160
+ return
1161
+
949
1162
  Simbad.ROW_LIMIT = 10000
950
1163
 
1164
+ # --- Query SIMBAD ---
1165
+ result = None
951
1166
  for attempt in range(1, 6):
952
1167
  try:
953
- result = Simbad.query_region(center_sky, radius=radius_deg * u.deg)
1168
+ if getattr(self, "count_label", None) is not None:
1169
+ self.count_label.setText(f"Attempt {attempt}/5 to query SIMBAD…")
1170
+ QApplication.processEvents()
1171
+ result = Simbad.query_region(center_sky, radius=radius)
954
1172
  break
955
- except Exception as e:
956
- self.count_label.setText(f"Attempt {attempt}/5 to query SIMBAD…")
957
- QApplication.processEvents(); time.sleep(1.2)
1173
+ except Exception:
1174
+ QApplication.processEvents()
1175
+ time.sleep(1.2)
958
1176
  result = None
1177
+
959
1178
  if result is None or len(result) == 0:
960
1179
  QMessageBox.information(self, "No Stars", "SIMBAD returned zero objects in that region.")
961
- self.star_list = []; self.star_combo.clear(); self.star_combo.addItem("Vega (A0V)", userData="A0V"); return
1180
+ self.star_list = []
1181
+ if getattr(self, "star_combo", None) is not None:
1182
+ self.star_combo.clear()
1183
+ self.star_combo.addItem("Vega (A0V)", userData="A0V")
1184
+ return
962
1185
 
963
- def infer_letter(bv):
964
- if bv is None or (isinstance(bv, float) and np.isnan(bv)): return None
965
- if bv < 0.00: return "B"
966
- elif bv < 0.30: return "A"
967
- elif bv < 0.58: return "F"
968
- elif bv < 0.81: return "G"
969
- elif bv < 1.40: return "K"
970
- elif bv > 1.40: return "M"
971
- else: return "U"
972
-
973
- self.star_list = []; templates_for_hist = []
974
- for row in result:
975
- raw_sp = row['sp_type']
976
- bmag, vmag, rmag = row['B'], row['V'], row['R']
977
- ra_deg, dec_deg = float(row['ra']), float(row['dec'])
1186
+ # --- helpers ---
1187
+ def _unmask_num(x):
978
1188
  try:
979
- sc = SkyCoord(ra=ra_deg*u.deg, dec=dec_deg*u.deg, frame="icrs")
1189
+ if x is None:
1190
+ return None
1191
+ if ma.isMaskedArray(x) and ma.is_masked(x):
1192
+ return None
1193
+ return float(x)
980
1194
  except Exception:
981
- continue
1195
+ return None
982
1196
 
983
- def _unmask_num(x):
1197
+ def infer_letter(bv):
1198
+ if bv is None or (isinstance(bv, float) and np.isnan(bv)):
1199
+ return None
1200
+ if bv < 0.00:
1201
+ return "B"
1202
+ elif bv < 0.30:
1203
+ return "A"
1204
+ elif bv < 0.58:
1205
+ return "F"
1206
+ elif bv < 0.81:
1207
+ return "G"
1208
+ elif bv < 1.40:
1209
+ return "K"
1210
+ elif bv > 1.40:
1211
+ return "M"
1212
+ return None
1213
+
1214
+ def safe_world2pix(ra_deg, dec_deg):
1215
+ try:
1216
+ xpix, ypix = wcs2.all_world2pix(ra_deg, dec_deg, 0)
1217
+ xpix, ypix = float(xpix), float(ypix)
1218
+ if np.isfinite(xpix) and np.isfinite(ypix):
1219
+ return xpix, ypix
1220
+ return None
1221
+ except NoConvergence as e:
984
1222
  try:
985
- if x is None or np.ma.isMaskedArray(x) and np.ma.is_masked(x):
986
- return None
987
- return float(x)
1223
+ xpix, ypix = e.best_solution
1224
+ xpix, ypix = float(xpix), float(ypix)
1225
+ if np.isfinite(xpix) and np.isfinite(ypix):
1226
+ return xpix, ypix
988
1227
  except Exception:
989
- return None
1228
+ pass
1229
+ return None
1230
+ except Exception:
1231
+ return None
1232
+
1233
+ # Column names (astroquery changed these)
1234
+ cols_lower = {c.lower(): c for c in result.colnames}
1235
+
1236
+ # RA/Dec in degrees:
1237
+ ra_col = cols_lower.get("ra", None) or cols_lower.get("ra(d)", None) or cols_lower.get("ra_d", None)
1238
+ dec_col = cols_lower.get("dec", None) or cols_lower.get("dec(d)", None) or cols_lower.get("dec_d", None)
1239
+
1240
+ # Mag columns:
1241
+ b_col = cols_lower.get("b", None) or cols_lower.get("flux_b", None)
1242
+ v_col = cols_lower.get("v", None) or cols_lower.get("flux_v", None)
1243
+ r_col = cols_lower.get("r", None) or cols_lower.get("flux_r", None)
1244
+
1245
+ if ra_col is None or dec_col is None:
1246
+ QMessageBox.critical(
1247
+ self,
1248
+ "SIMBAD Columns",
1249
+ "SIMBAD result did not include degree RA/Dec columns (ra/dec).\n"
1250
+ "Print result.colnames to see what's returned."
1251
+ )
1252
+ return
990
1253
 
991
- # inside your SIMBAD row loop:
992
- bmag = _unmask_num(row['B'])
993
- vmag = _unmask_num(row['V'])
1254
+ # --- main loop ---
1255
+ self.star_list = []
1256
+ templates_for_hist = []
1257
+
1258
+ for row in result:
1259
+ # spectral type column name in table
1260
+ raw_sp = None
1261
+ if "SP_TYPE" in result.colnames:
1262
+ raw_sp = row["SP_TYPE"]
1263
+ elif "sp_type" in result.colnames:
1264
+ raw_sp = row["sp_type"]
1265
+
1266
+ bmag = _unmask_num(row[b_col]) if b_col is not None else None
1267
+ vmag = _unmask_num(row[v_col]) if v_col is not None else None
1268
+ rmag = _unmask_num(row[r_col]) if r_col is not None else None
1269
+
1270
+ # ra/dec degrees
1271
+ ra_deg = _unmask_num(row[ra_col])
1272
+ dec_deg = _unmask_num(row[dec_col])
1273
+ if ra_deg is None or dec_deg is None:
1274
+ continue
1275
+
1276
+ try:
1277
+ sc = SkyCoord(ra=ra_deg * u.deg, dec=dec_deg * u.deg, frame="icrs")
1278
+ except Exception:
1279
+ continue
994
1280
 
995
1281
  sp_clean = None
996
1282
  if raw_sp and str(raw_sp).strip():
997
1283
  sp = str(raw_sp).strip().upper()
998
1284
  if not (sp.startswith("SN") or sp.startswith("KA")):
999
1285
  sp_clean = sp
1000
- elif bmag is not None and vmag is not None:
1001
- bv = bmag - vmag
1002
- sp_clean = infer_letter(bv)
1003
- if not sp_clean: continue
1286
+ elif (bmag is not None) and (vmag is not None):
1287
+ sp_clean = infer_letter(bmag - vmag)
1288
+
1289
+ if not sp_clean:
1290
+ continue
1004
1291
 
1005
1292
  match_list = pickles_match_for_simbad(sp_clean, self.pickles_templates)
1006
1293
  best_template = match_list[0] if match_list else None
1007
- xpix, ypix = self.wcs.all_world2pix(sc.ra.deg, sc.dec.deg, 0)
1294
+
1295
+ xy = safe_world2pix(sc.ra.deg, sc.dec.deg)
1296
+ if xy is None:
1297
+ continue
1298
+
1299
+ xpix, ypix = xy
1008
1300
  if 0 <= xpix < W and 0 <= ypix < H:
1009
1301
  self.star_list.append({
1010
- "ra": sc.ra.deg, "dec": sc.dec.deg, "sp_clean": sp_clean,
1011
- "pickles_match": best_template, "x": xpix, "y": ypix,
1012
- "Bmag": float(bmag) if bmag else None,
1013
- "Vmag": float(vmag) if vmag else None,
1014
- "Rmag": float(rmag) if rmag else None,
1302
+ "ra": sc.ra.deg, "dec": sc.dec.deg,
1303
+ "sp_clean": sp_clean,
1304
+ "pickles_match": best_template,
1305
+ "x": xpix, "y": ypix,
1306
+ # IMPORTANT: do not use "if bmag" (0.0 becomes None)
1307
+ "Bmag": float(bmag) if bmag is not None else None,
1308
+ "Vmag": float(vmag) if vmag is not None else None,
1309
+ "Rmag": float(rmag) if rmag is not None else None,
1015
1310
  })
1016
- if best_template is not None: templates_for_hist.append(best_template)
1311
+ if best_template is not None:
1312
+ templates_for_hist.append(best_template)
1313
+
1314
+ # --- plot / UI feedback (unchanged) ---
1315
+ if getattr(self, "figure", None) is not None:
1316
+ self.figure.clf()
1017
1317
 
1018
- self.figure.clf()
1019
1318
  if templates_for_hist:
1020
1319
  uniq, cnt = np.unique(templates_for_hist, return_counts=True)
1021
- types_str = ", ".join(uniq)
1022
- self.count_label.setText(f"Found {len(templates_for_hist)} stars; templates: {types_str}")
1023
- ax = self.figure.add_subplot(111)
1024
- ax.bar(uniq, cnt, edgecolor="black")
1025
- ax.set_xlabel("Spectral Type"); ax.set_ylabel("Count"); ax.set_title("Spectral Distribution")
1026
- ax.tick_params(axis='x', rotation=90); ax.grid(axis="y", linestyle="--", alpha=0.3)
1027
- self.canvas.setVisible(True); self.canvas.draw()
1320
+ types_str = ", ".join([str(u) for u in uniq])
1321
+ if getattr(self, "count_label", None) is not None:
1322
+ self.count_label.setText(f"Found {len(self.star_list)} stars; templates: {types_str}")
1323
+
1324
+ if getattr(self, "figure", None) is not None and getattr(self, "canvas", None) is not None:
1325
+ ax = self.figure.add_subplot(111)
1326
+ ax.bar(uniq, cnt, edgecolor="black")
1327
+ ax.set_xlabel("Spectral Type")
1328
+ ax.set_ylabel("Count")
1329
+ ax.set_title("Spectral Distribution")
1330
+ ax.tick_params(axis='x', rotation=90)
1331
+ ax.grid(axis="y", linestyle="--", alpha=0.3)
1332
+ self.canvas.setVisible(True)
1333
+ self.canvas.draw()
1028
1334
  else:
1029
- self.count_label.setText("Found 0 stars with Pickles matches.")
1030
- self.canvas.setVisible(False); self.canvas.draw()
1335
+ if getattr(self, "count_label", None) is not None:
1336
+ self.count_label.setText(f"Found {len(self.star_list)} in-frame SIMBAD stars (0 with Pickles matches).")
1337
+ if getattr(self, "canvas", None) is not None:
1338
+ self.canvas.setVisible(False)
1339
+ self.canvas.draw()
1031
1340
 
1032
1341
  # ── Core SFCC ───────────────────────────────────────────────────────
1033
-
1034
1342
  def run_spcc(self):
1035
1343
  ref_sed_name = self.star_combo.currentData()
1036
1344
  r_filt = self.r_filter_combo.currentText()
@@ -1041,13 +1349,15 @@ class SFCCDialog(QDialog):
1041
1349
  lp_filt2 = self.lp_filter_combo2.currentText()
1042
1350
 
1043
1351
  if not ref_sed_name:
1044
- QMessageBox.warning(self, "Error", "Select a reference spectral type (e.g. A0V)."); return
1352
+ QMessageBox.warning(self, "Error", "Select a reference spectral type (e.g. A0V).")
1353
+ return
1045
1354
  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
1355
+ QMessageBox.warning(self, "Error", "Pick at least one of R, G or B filters.")
1356
+ return
1047
1357
  if sens_name == "(None)":
1048
- QMessageBox.warning(self, "Error", "Select a sensor QE curve."); return
1358
+ QMessageBox.warning(self, "Error", "Select a sensor QE curve.")
1359
+ return
1049
1360
 
1050
- # -- Step 1A: get active image as float32 in [0..1]
1051
1361
  doc = self.doc_manager.get_active_document()
1052
1362
  if doc is None or doc.image is None:
1053
1363
  QMessageBox.critical(self, "Error", "No active document.")
@@ -1059,29 +1369,32 @@ class SFCCDialog(QDialog):
1059
1369
  QMessageBox.critical(self, "Error", "Active document must be RGB (3 channels).")
1060
1370
  return
1061
1371
 
1372
+ # ---- Convert to float working space ----
1062
1373
  if img.dtype == np.uint8:
1063
- base = img.astype(np.float32) / 255.0
1374
+ img_float = img.astype(np.float32) / 255.0
1064
1375
  else:
1065
- base = img.astype(np.float32, copy=True)
1376
+ img_float = img.astype(np.float32, copy=False)
1377
+
1378
+ # ---- Build SEP working copy (ONE pedestal handling only) ----
1379
+ base = self._make_working_base_for_sep(img_float)
1066
1380
 
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)
1381
+ # Optional BN after calibration:
1382
+ # IMPORTANT: do NOT remove pedestal here either (avoid double pedestal removal).
1383
+ if self.neutralize_chk.isChecked():
1384
+ base = self._neutralize_background(base, remove_pedestal=False)
1071
1385
 
1072
1386
  # SEP on grayscale
1073
- gray = np.mean(base, axis=2)
1074
-
1387
+ gray = np.mean(base, axis=2).astype(np.float32)
1388
+
1075
1389
  bkg = sep.Background(gray)
1076
1390
  data_sub = gray - bkg.back()
1077
- err = bkg.globalrms
1391
+ err = float(bkg.globalrms)
1392
+
1393
+ # User threshold
1394
+ sep_sigma = float(self.sep_thr_spin.value()) if hasattr(self, "sep_thr_spin") else 5.0
1395
+ self.count_label.setText(f"Detecting stars (SEP σ={sep_sigma:.1f})…")
1396
+ QApplication.processEvents()
1078
1397
 
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
1398
  sources = sep.extract(data_sub, sep_sigma, err=err)
1086
1399
 
1087
1400
  MAX_SOURCES = 300_000
@@ -1095,32 +1408,51 @@ class SFCCDialog(QDialog):
1095
1408
  return
1096
1409
 
1097
1410
  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]
1411
+ QMessageBox.critical(self, "SEP Error", "SEP found no sources.")
1412
+ return
1413
+
1414
+ # Radius filtering (unchanged)
1415
+ r_fluxrad, _ = sep.flux_radius(
1416
+ gray, sources["x"], sources["y"],
1417
+ 2.0 * sources["a"], 0.5,
1418
+ normflux=sources["flux"], subpix=5
1419
+ )
1420
+ mask = (r_fluxrad > 0.2) & (r_fluxrad <= 10)
1421
+ sources = sources[mask]
1101
1422
  if sources.size == 0:
1102
- QMessageBox.critical(self, "SEP Error", "All SEP detections rejected by radius filter."); return
1423
+ QMessageBox.critical(self, "SEP Error", "All SEP detections rejected by radius filter.")
1424
+ return
1103
1425
 
1104
1426
  if not getattr(self, "star_list", None):
1105
- QMessageBox.warning(self, "Error", "Fetch Stars (with WCS) before running SFCC."); return
1427
+ QMessageBox.warning(self, "Error", "Fetch Stars (with WCS) before running SFCC.")
1428
+ return
1106
1429
 
1430
+ # ---- Match SIMBAD stars to SEP detections ----
1107
1431
  raw_matches = []
1108
1432
  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]))
1433
+ dx = sources["x"] - star["x"]
1434
+ dy = sources["y"] - star["y"]
1435
+ j = int(np.argmin(dx * dx + dy * dy))
1436
+ if (dx[j] * dx[j] + dy[j] * dy[j]) < (3.0 ** 2):
1437
+ xi, yi = int(round(float(sources["x"][j]))), int(round(float(sources["y"][j])))
1113
1438
  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})
1439
+ raw_matches.append({
1440
+ "sim_index": i,
1441
+ "template": star.get("pickles_match") or star["sp_clean"],
1442
+ "x_pix": xi,
1443
+ "y_pix": yi
1444
+ })
1445
+
1115
1446
  if not raw_matches:
1116
- QMessageBox.warning(self, "No Matches", "No SIMBAD star matched to SEP detections."); return
1447
+ QMessageBox.warning(self, "No Matches", "No SIMBAD star matched to SEP detections.")
1448
+ return
1117
1449
 
1118
1450
  wl_min, wl_max = 3000, 11000
1119
- wl_grid = np.arange(wl_min, wl_max+1)
1451
+ wl_grid = np.arange(wl_min, wl_max + 1)
1120
1452
 
1121
1453
  def load_curve(ext):
1122
1454
  for p in (self.user_custom_path, self.sasp_data_path):
1123
- with fits.open(p) as hd:
1455
+ with fits.open(p, memmap=False) as hd:
1124
1456
  if ext in hd:
1125
1457
  d = hd[ext].data
1126
1458
  wl = _ensure_angstrom(d["WAVELENGTH"].astype(float))
@@ -1130,7 +1462,7 @@ class SFCCDialog(QDialog):
1130
1462
 
1131
1463
  def load_sed(ext):
1132
1464
  for p in (self.user_custom_path, self.sasp_data_path):
1133
- with fits.open(p) as hd:
1465
+ with fits.open(p, memmap=False) as hd:
1134
1466
  if ext in hd:
1135
1467
  d = hd[ext].data
1136
1468
  wl = _ensure_angstrom(d["WAVELENGTH"].astype(float))
@@ -1138,18 +1470,21 @@ class SFCCDialog(QDialog):
1138
1470
  return wl, fl
1139
1471
  raise KeyError(f"SED '{ext}' not found")
1140
1472
 
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)
1473
+ interp = lambda wl_o, tp_o: np.interp(wl_grid, wl_o, tp_o, left=0.0, right=0.0)
1474
+
1475
+ T_R = interp(*load_curve(r_filt)) if r_filt != "(None)" else np.ones_like(wl_grid)
1476
+ T_G = interp(*load_curve(g_filt)) if g_filt != "(None)" else np.ones_like(wl_grid)
1477
+ T_B = interp(*load_curve(b_filt)) if b_filt != "(None)" else np.ones_like(wl_grid)
1478
+ QE = interp(*load_curve(sens_name)) if sens_name != "(None)" else np.ones_like(wl_grid)
1479
+ LP1 = interp(*load_curve(lp_filt)) if lp_filt != "(None)" else np.ones_like(wl_grid)
1480
+ LP2 = interp(*load_curve(lp_filt2)) if lp_filt2 != "(None)" else np.ones_like(wl_grid)
1148
1481
  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
1482
+
1483
+ T_sys_R, T_sys_G, T_sys_B = T_R * QE * LP, T_G * QE * LP, T_B * QE * LP
1150
1484
 
1151
1485
  wl_ref, fl_ref = load_sed(ref_sed_name)
1152
- fr_i = np.interp(wl_grid, wl_ref, fl_ref, left=0., right=0.)
1486
+ fr_i = np.interp(wl_grid, wl_ref, fl_ref, left=0.0, right=0.0)
1487
+
1153
1488
  S_ref_R = np.trapezoid(fr_i * T_sys_R, x=wl_grid)
1154
1489
  S_ref_G = np.trapezoid(fr_i * T_sys_G, x=wl_grid)
1155
1490
  S_ref_B = np.trapezoid(fr_i * T_sys_B, x=wl_grid)
@@ -1158,155 +1493,207 @@ class SFCCDialog(QDialog):
1158
1493
  diag_meas_BG, diag_exp_BG = [], []
1159
1494
  enriched = []
1160
1495
 
1161
- # --- Optimization: Pre-calculate integrals for unique templates ---
1496
+ # ---- Pre-calc integrals for unique templates ----
1162
1497
  unique_simbad_types = set(m["template"] for m in raw_matches)
1163
-
1164
- # Map simbad_type -> pickles_template_name
1498
+
1165
1499
  simbad_to_pickles = {}
1166
1500
  pickles_templates_needed = set()
1167
-
1168
1501
  for sp in unique_simbad_types:
1169
1502
  cands = pickles_match_for_simbad(sp, getattr(self, "pickles_templates", []))
1170
1503
  if cands:
1171
- pickles_name = cands[0]
1172
- simbad_to_pickles[sp] = pickles_name
1173
- pickles_templates_needed.add(pickles_name)
1504
+ pname = cands[0]
1505
+ simbad_to_pickles[sp] = pname
1506
+ pickles_templates_needed.add(pname)
1174
1507
 
1175
- # Pre-calc integrals for each unique Pickles template
1176
- # Cache structure: template_name -> (S_sr, S_sg, S_sb)
1177
1508
  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
1509
  for pname in pickles_templates_needed:
1183
1510
  try:
1184
1511
  wl_s, fl_s = load_sed(pname)
1185
- fs_i = np.interp(wl_grid, wl_s, fl_s, left=0., right=0.)
1186
-
1512
+ fs_i = np.interp(wl_grid, wl_s, fl_s, left=0.0, right=0.0)
1187
1513
  S_sr = np.trapezoid(fs_i * T_sys_R, x=wl_grid)
1188
1514
  S_sg = np.trapezoid(fs_i * T_sys_G, x=wl_grid)
1189
1515
  S_sb = np.trapezoid(fs_i * T_sys_B, x=wl_grid)
1190
-
1191
1516
  template_integrals[pname] = (S_sr, S_sg, S_sb)
1192
1517
  except Exception as e:
1193
1518
  print(f"[SFCC] Warning: failed to load/integrate template {pname}: {e}")
1194
1519
 
1195
- # --- Main Match Loop ---
1520
+ # ---- Main match loop (measure from 'base' only) ----
1196
1521
  for m in raw_matches:
1197
1522
  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
1523
 
1201
- # 1. Resolve Simbad -> Pickles
1524
+ # measure on the SEP working copy (already BN’d, only one pedestal handling)
1525
+ Rm = float(base[yi, xi, 0])
1526
+ Gm = float(base[yi, xi, 1])
1527
+ Bm = float(base[yi, xi, 2])
1528
+ if Gm <= 0:
1529
+ continue
1530
+
1202
1531
  pname = simbad_to_pickles.get(sp)
1203
- if not pname: continue
1204
-
1205
- # 2. Retrieve pre-calced integrals
1532
+ if not pname:
1533
+ continue
1534
+
1206
1535
  integrals = template_integrals.get(pname)
1207
- if not integrals: continue
1208
-
1536
+ if not integrals:
1537
+ continue
1538
+
1209
1539
  S_sr, S_sg, S_sb = integrals
1210
-
1211
- if S_sg <= 0: continue
1540
+ if S_sg <= 0:
1541
+ continue
1212
1542
 
1213
- exp_RG = S_sr / S_sg; exp_BG = S_sb / S_sg
1214
- meas_RG = Rm / Gm; meas_BG = Bm / Gm
1543
+ exp_RG = S_sr / S_sg
1544
+ exp_BG = S_sb / S_sg
1545
+ meas_RG = Rm / Gm
1546
+ meas_BG = Bm / Gm
1215
1547
 
1216
1548
  diag_meas_RG.append(meas_RG); diag_exp_RG.append(exp_RG)
1217
1549
  diag_meas_BG.append(meas_BG); diag_exp_BG.append(exp_BG)
1218
1550
 
1219
1551
  enriched.append({
1220
- **m, "R_meas": Rm, "G_meas": Gm, "B_meas": Bm,
1552
+ **m,
1553
+ "R_meas": Rm, "G_meas": Gm, "B_meas": Bm,
1221
1554
  "S_star_R": S_sr, "S_star_G": S_sg, "S_star_B": S_sb,
1222
1555
  "exp_RG": exp_RG, "exp_BG": exp_BG
1223
1556
  })
1224
-
1557
+
1225
1558
  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)
1559
+ diag_meas_RG = np.asarray(diag_meas_RG, dtype=np.float64)
1560
+ diag_exp_RG = np.asarray(diag_exp_RG, dtype=np.float64)
1561
+ diag_meas_BG = np.asarray(diag_meas_BG, dtype=np.float64)
1562
+ diag_exp_BG = np.asarray(diag_exp_BG, dtype=np.float64)
1563
+
1228
1564
  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
1565
+ QMessageBox.information(self, "No Valid Stars", "No stars with valid measured vs expected ratios.")
1566
+ return
1567
+
1568
+ n_stars = int(diag_meas_RG.size)
1231
1569
 
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
1570
+ def rms_frac(pred, exp):
1571
+ return float(np.sqrt(np.mean(((pred / exp) - 1.0) ** 2)))
1236
1572
 
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
1573
+ slope_only = lambda x, m: m * x
1574
+ affine = lambda x, m, b: m * x + b
1575
+ quad = lambda x, a, b, c: a * x**2 + b * x + c
1576
+
1577
+ denR = float(np.sum(diag_meas_RG**2))
1578
+ denB = float(np.sum(diag_meas_BG**2))
1579
+ mR_s = (float(np.sum(diag_meas_RG * diag_exp_RG)) / denR) if denR > 0 else 1.0
1580
+ mB_s = (float(np.sum(diag_meas_BG * diag_exp_BG)) / denB) if denB > 0 else 1.0
1240
1581
  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
1582
 
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]
1583
+ mR_a, bR_a = np.linalg.lstsq(
1584
+ np.vstack([diag_meas_RG, np.ones_like(diag_meas_RG)]).T, diag_exp_RG, rcond=None
1585
+ )[0]
1586
+ mB_a, bB_a = np.linalg.lstsq(
1587
+ np.vstack([diag_meas_BG, np.ones_like(diag_meas_BG)]).T, diag_exp_BG, rcond=None
1588
+ )[0]
1244
1589
  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
1590
 
1246
1591
  aR_q, bR_q, cR_q = np.polyfit(diag_meas_RG, diag_exp_RG, 2)
1247
1592
  aB_q, bB_q, cB_q = np.polyfit(diag_meas_BG, diag_exp_BG, 2)
1248
1593
  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
1594
 
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"
1595
+ idx = int(np.argmin([rms_s, rms_a, rms_q]))
1596
+ if idx == 0:
1597
+ coeff_R, coeff_B, model_choice = (0.0, float(mR_s), 0.0), (0.0, float(mB_s), 0.0), "slope-only"
1598
+ elif idx == 1:
1599
+ coeff_R, coeff_B, model_choice = (0.0, float(mR_a), float(bR_a)), (0.0, float(mB_a), float(bB_a)), "affine"
1600
+ else:
1601
+ 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"
1254
1602
 
1255
- poly = lambda c, x: c[0]*x**2 + c[1]*x + c[2]
1256
- 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)
1603
+ poly = lambda c, x: c[0] * x**2 + c[1] * x + c[2]
1263
1604
 
1605
+ # ---- Diagnostics plot (unchanged) ----
1606
+ self.figure.clf()
1264
1607
  res0_RG = (diag_meas_RG / diag_exp_RG) - 1.0
1265
1608
  res0_BG = (diag_meas_BG / diag_exp_BG) - 1.0
1266
1609
  res1_RG = (poly(coeff_R, diag_meas_RG) / diag_exp_RG) - 1.0
1267
1610
  res1_BG = (poly(coeff_B, diag_meas_BG) / diag_exp_BG) - 1.0
1268
1611
 
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)
1612
+ ymin = float(np.min(np.concatenate([res0_RG, res0_BG])))
1613
+ ymax = float(np.max(np.concatenate([res0_RG, res0_BG])))
1614
+ pad = 0.05 * (ymax - ymin) if ymax > ymin else 0.02
1615
+ y_lim = (ymin - pad, ymax + pad)
1616
+
1271
1617
  def shade(ax, yvals, color):
1272
- q1, q3 = np.percentile(yvals, [25,75]); ax.axhspan(q1, q3, color=color, alpha=.10, zorder=0)
1618
+ q1, q3 = np.percentile(yvals, [25, 75])
1619
+ ax.axhspan(q1, q3, color=color, alpha=0.10, zorder=0)
1273
1620
 
1274
1621
  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")
1622
+ ax2.axhline(0, color="0.65", ls="--", lw=1)
1623
+ shade(ax2, res0_RG, "firebrick"); shade(ax2, res0_BG, "royalblue")
1624
+ ax2.scatter(diag_exp_RG, res0_RG, c="firebrick", marker="o", alpha=0.7, label="R/G residual")
1625
+ ax2.scatter(diag_exp_BG, res0_BG, c="royalblue", marker="s", alpha=0.7, label="B/G residual")
1626
+ ax2.set_ylim(*y_lim)
1627
+ ax2.set_xlabel("Expected (band/G)")
1628
+ ax2.set_ylabel("Frac residual (meas/exp − 1)")
1629
+ ax2.set_title("Residuals • BEFORE")
1630
+ ax2.legend(frameon=False, fontsize=7, loc="lower right")
1280
1631
 
1281
1632
  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)")
1633
+ ax3.axhline(0, color="0.65", ls="--", lw=1)
1634
+ shade(ax3, res1_RG, "firebrick"); shade(ax3, res1_BG, "royalblue")
1635
+ ax3.scatter(diag_exp_RG, res1_RG, c="firebrick", marker="o", alpha=0.7)
1636
+ ax3.scatter(diag_exp_BG, res1_BG, c="royalblue", marker="s", alpha=0.7)
1637
+ ax3.set_ylim(*y_lim)
1638
+ ax3.set_xlabel("Expected (band/G)")
1639
+ ax3.set_ylabel("Frac residual (corrected/exp − 1)")
1286
1640
  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
1641
 
1642
+ self.canvas.setVisible(True)
1643
+ self.figure.tight_layout(w_pad=2.0)
1644
+ self.canvas.draw()
1645
+
1646
+ # ---- Apply SFCC correction to ORIGINAL floats (not the SEP base) ----
1647
+ self.count_label.setText("Applying SFCC color scales to image…")
1648
+ QApplication.processEvents()
1649
+
1650
+ eps = 1e-8
1651
+ calibrated = base.copy()
1652
+
1653
+ R = calibrated[..., 0]
1654
+ G = calibrated[..., 1]
1655
+ B = calibrated[..., 2]
1656
+
1657
+ RG = R / np.maximum(G, eps)
1658
+ BG = B / np.maximum(G, eps)
1659
+
1660
+ aR, bR, cR = coeff_R
1661
+ aB, bB, cB = coeff_B
1662
+
1663
+ mR = aR * RG**2 + bR * RG + cR
1664
+ mB = aB * BG**2 + bB * BG + cB
1665
+
1666
+ mR = np.clip(mR, 0.25, 4.0)
1667
+ mB = np.clip(mB, 0.25, 4.0)
1668
+
1669
+ pR = float(np.median(R))
1670
+ pB = float(np.median(B))
1671
+
1672
+ calibrated[..., 0] = _pivot_scale_channel(R, mR, pR)
1673
+ calibrated[..., 2] = _pivot_scale_channel(B, mB, pB)
1674
+
1675
+ calibrated = np.clip(calibrated, 0.0, 1.0)
1676
+
1677
+ # --- OPTIONAL: apply BN/pedestal to the FINAL calibrated image, not just SEP base ---
1303
1678
  if self.neutralize_chk.isChecked():
1304
- calibrated = self._neutralize_background(calibrated, patch_size=10)
1679
+ try:
1680
+ print("[SFCC] Applying background neutralization to final calibrated image...")
1681
+ _debug_probe_channels(calibrated, "final_before_BN")
1305
1682
 
1683
+ # If you want pedestal removal as part of BN, set remove_pedestal=True here
1684
+ # (and/or make this a checkbox)
1685
+ calibrated = self._neutralize_background(calibrated, remove_pedestal=True)
1686
+
1687
+ _debug_probe_channels(calibrated, "final_after_BN")
1688
+ except Exception as e:
1689
+ print(f"[SFCC] Final BN failed: {e}")
1690
+
1691
+
1692
+ # Convert back to original dtype
1306
1693
  if img.dtype == np.uint8:
1307
- calibrated = (np.clip(calibrated, 0, 1) * 255.0).astype(np.uint8)
1694
+ out_img = (np.clip(calibrated, 0.0, 1.0) * 255.0).astype(np.uint8)
1308
1695
  else:
1309
- calibrated = np.clip(calibrated, 0, 1).astype(np.float32)
1696
+ out_img = np.clip(calibrated, 0.0, 1.0).astype(np.float32)
1310
1697
 
1311
1698
  new_meta = dict(doc.metadata or {})
1312
1699
  new_meta.update({
@@ -1318,25 +1705,31 @@ class SFCCDialog(QDialog):
1318
1705
  })
1319
1706
 
1320
1707
  self.doc_manager.update_active_document(
1321
- calibrated,
1708
+ out_img,
1322
1709
  metadata=new_meta,
1323
1710
  step_name="SFCC Calibrated",
1324
- doc=doc, # 👈 pin to the document we started from
1711
+ doc=doc,
1325
1712
  )
1326
1713
 
1327
1714
  self.count_label.setText(f"Applied SFCC color calibration using {n_stars} stars")
1328
1715
  QApplication.processEvents()
1329
1716
 
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'}")
1717
+ def pretty(coeff):
1718
+ # coefficient sum gives you f(1) for quadratic form a*x^2+b*x+c at x=1
1719
+ return float(coeff[0] + coeff[1] + coeff[2])
1720
+
1721
+ QMessageBox.information(
1722
+ self,
1723
+ "SFCC Complete",
1724
+ f"Applied SFCC using {n_stars} stars\n"
1725
+ f"Model: {model_choice}\n"
1726
+ f"R ratio @ x=1: {pretty(coeff_R):.4f}\n"
1727
+ f"B ratio @ x=1: {pretty(coeff_B):.4f}\n"
1728
+ f"Background neutralisation: {'ON' if self.neutralize_chk.isChecked() else 'OFF'}"
1729
+ )
1730
+
1731
+ self.current_image = out_img # keep for gradient step
1338
1732
 
1339
- self.current_image = calibrated # keep for gradient step
1340
1733
 
1341
1734
  # ── Chromatic gradient (optional) ──────────────────────────────────
1342
1735
 
@@ -1454,11 +1847,64 @@ class SFCCDialog(QDialog):
1454
1847
  self.sasp_viewer_window.show()
1455
1848
  self.sasp_viewer_window.destroyed.connect(self._on_sasp_closed)
1456
1849
 
1850
+ def _cleanup(self):
1851
+ # 1) Close/cleanup child window (SaspViewer)
1852
+ try:
1853
+ if getattr(self, "sasp_viewer_window", None) is not None:
1854
+ try:
1855
+ self.sasp_viewer_window.destroyed.disconnect(self._on_sasp_closed)
1856
+ except Exception:
1857
+ pass
1858
+ try:
1859
+ self.sasp_viewer_window.close()
1860
+ except Exception:
1861
+ pass
1862
+ self.sasp_viewer_window = None
1863
+ except Exception:
1864
+ pass
1865
+
1866
+ # 2) Disconnect any long-lived external signals (add these if/when used)
1867
+ # Example patterns:
1868
+ try:
1869
+ self.doc_manager.activeDocumentChanged.disconnect(self._on_active_doc_changed)
1870
+ except Exception:
1871
+ pass
1872
+ try:
1873
+ self.main_win.currentDocumentChanged.disconnect(self._on_active_doc_changed)
1874
+ except Exception:
1875
+ pass
1876
+
1877
+ # 3) Release large caches/refs (important since dialog may not be deleted)
1878
+ try:
1879
+ self.current_image = None
1880
+ self.current_header = None
1881
+ self.star_list = []
1882
+ self._last_matched = []
1883
+ if hasattr(self, "wcs"):
1884
+ self.wcs = None
1885
+ if hasattr(self, "wcs_header"):
1886
+ self.wcs_header = None
1887
+ except Exception:
1888
+ pass
1889
+
1890
+ # 4) Matplotlib cleanup
1891
+ try:
1892
+ if getattr(self, "figure", None) is not None:
1893
+ self.figure.clf()
1894
+ if getattr(self, "canvas", None) is not None:
1895
+ self.canvas.setVisible(False)
1896
+ self.canvas.draw_idle()
1897
+ except Exception:
1898
+ pass
1899
+
1900
+
1457
1901
  def _on_sasp_closed(self, _=None):
1458
1902
  # Called when the SaspViewer window is destroyed
1459
1903
  self.sasp_viewer_window = None
1904
+ self._cleanup()
1460
1905
 
1461
1906
  def closeEvent(self, event):
1907
+ self._cleanup()
1462
1908
  super().closeEvent(event)
1463
1909
 
1464
1910