setiastrosuitepro 1.6.10__py3-none-any.whl → 1.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. setiastro/images/colorwheel.svg +97 -0
  2. setiastro/images/narrowbandnormalization.png +0 -0
  3. setiastro/images/planetarystacker.png +0 -0
  4. setiastro/saspro/__main__.py +1 -1
  5. setiastro/saspro/_generated/build_info.py +2 -2
  6. setiastro/saspro/aberration_ai.py +49 -11
  7. setiastro/saspro/aberration_ai_preset.py +29 -3
  8. setiastro/saspro/backgroundneutral.py +73 -33
  9. setiastro/saspro/blink_comparator_pro.py +116 -71
  10. setiastro/saspro/convo.py +9 -6
  11. setiastro/saspro/curve_editor_pro.py +72 -22
  12. setiastro/saspro/curves_preset.py +249 -47
  13. setiastro/saspro/doc_manager.py +178 -11
  14. setiastro/saspro/gui/main_window.py +218 -66
  15. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  16. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  17. setiastro/saspro/gui/mixins/menu_mixin.py +31 -1
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +132 -10
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +769 -0
  22. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  23. setiastro/saspro/imageops/stretch.py +66 -15
  24. setiastro/saspro/legacy/numba_utils.py +25 -48
  25. setiastro/saspro/live_stacking.py +24 -4
  26. setiastro/saspro/multiscale_decomp.py +30 -17
  27. setiastro/saspro/narrowband_normalization.py +1618 -0
  28. setiastro/saspro/numba_utils.py +0 -55
  29. setiastro/saspro/ops/script_editor.py +5 -0
  30. setiastro/saspro/ops/scripts.py +119 -0
  31. setiastro/saspro/remove_green.py +1 -1
  32. setiastro/saspro/resources.py +4 -0
  33. setiastro/saspro/ser_stack_config.py +68 -0
  34. setiastro/saspro/ser_stacker.py +2245 -0
  35. setiastro/saspro/ser_stacker_dialog.py +1481 -0
  36. setiastro/saspro/ser_tracking.py +206 -0
  37. setiastro/saspro/serviewer.py +1242 -0
  38. setiastro/saspro/sfcc.py +602 -214
  39. setiastro/saspro/shortcuts.py +35 -16
  40. setiastro/saspro/stacking_suite.py +332 -87
  41. setiastro/saspro/star_alignment.py +243 -122
  42. setiastro/saspro/stat_stretch.py +220 -31
  43. setiastro/saspro/subwindow.py +2 -4
  44. setiastro/saspro/whitebalance.py +24 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/METADATA +2 -2
  47. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +51 -40
  48. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
  51. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.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)
@@ -875,32 +886,190 @@ class SFCCDialog(QDialog):
875
886
  return self.wcs.all_pix2world(x, y, 0)
876
887
 
877
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)
878
891
 
879
- def _neutralize_background(self, rgb_img: np.ndarray, patch_size: int = 50) -> np.ndarray:
880
- img = rgb_img.copy()
892
+ if img.ndim != 3 or img.shape[2] != 3:
893
+ raise ValueError("Expected RGB image (H,W,3)")
894
+
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()
881
918
  h, w = img.shape[:2]
882
- ph, pw = h // patch_size, w // patch_size
919
+ ph, pw = max(1, h // patch_size), max(1, w // patch_size)
920
+
883
921
  min_sum, best_med = np.inf, None
884
922
  for i in range(patch_size):
885
923
  for j in range(patch_size):
886
924
  y0, x0 = i * ph, j * pw
887
- patch = img[y0:min(y0+ph, h), x0:min(x0+pw, w), :]
888
- med = np.median(patch, axis=(0, 1))
889
- 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())
890
930
  if s < min_sum:
891
931
  min_sum, best_med = s, med
932
+
892
933
  if best_med is None:
893
- return img
894
- 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
895
938
  for c in range(3):
896
939
  diff = float(best_med[c] - target)
897
- if abs(diff) < eps: continue
940
+ if abs(diff) < eps:
941
+ continue
942
+ # Preserve [0,1] scale; keep the same form you were using.
898
943
  img[..., c] = np.clip((img[..., c] - diff) / (1.0 - diff), 0.0, 1.0)
899
- 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
+
900
972
 
901
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
+
902
1063
 
903
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
+
904
1073
  # 0) Grab current image + header from the active document
905
1074
  img, hdr, _meta = self._get_active_image_and_header()
906
1075
  self.current_image = img
@@ -916,7 +1085,7 @@ class SFCCDialog(QDialog):
916
1085
  self.pickles_templates = []
917
1086
  for p in (self.user_custom_path, self.sasp_data_path):
918
1087
  try:
919
- with fits.open(p) as hd:
1088
+ with fits.open(p, memmap=False) as hd:
920
1089
  for hdu in hd:
921
1090
  if (isinstance(hdu, fits.BinTableHDU)
922
1091
  and hdu.header.get("CTYPE", "").upper() == "SED"):
@@ -924,118 +1093,252 @@ class SFCCDialog(QDialog):
924
1093
  if extname and extname not in self.pickles_templates:
925
1094
  self.pickles_templates.append(extname)
926
1095
  except Exception as e:
927
- 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()
928
1098
 
929
1099
  # Build WCS
930
1100
  try:
931
1101
  self.initialize_wcs_from_header(self.current_header)
932
1102
  except Exception:
933
- 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
934
1112
 
935
1113
  H, W = self.current_image.shape[:2]
936
- 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)
937
1117
  try:
938
- sky = self.wcs.all_pix2world(pix, 0)
1118
+ sky = wcs2.all_pix2world(pix, 0)
939
1119
  except Exception as e:
940
- QMessageBox.critical(self, "WCS Conversion Error", str(e)); return
941
- center_sky = SkyCoord(ra=sky[0,0]*u.deg, dec=sky[0,1]*u.deg, frame="icrs")
942
- corners_sky = SkyCoord(ra=sky[1:,0]*u.deg, dec=sky[1:,1]*u.deg, frame="icrs")
943
- radius_deg = center_sky.separation(corners_sky).max().deg
1120
+ QMessageBox.critical(self, "WCS Conversion Error", str(e))
1121
+ return
944
1122
 
945
- # Simbad fields
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
1126
+
1127
+ # --- SIMBAD fields (NEW first, fallback to legacy) ---
946
1128
  Simbad.reset_votable_fields()
947
- 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):
948
1140
  try:
949
- Simbad.add_votable_fields('sp', 'flux(B)', 'flux(V)', 'flux(R)')
1141
+ _try_new_fields()
1142
+ ok = True
950
1143
  break
951
1144
  except Exception:
952
1145
  QApplication.processEvents()
953
- 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
+
954
1162
  Simbad.ROW_LIMIT = 10000
955
1163
 
1164
+ # --- Query SIMBAD ---
1165
+ result = None
956
1166
  for attempt in range(1, 6):
957
1167
  try:
958
- 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)
959
1172
  break
960
- except Exception as e:
961
- self.count_label.setText(f"Attempt {attempt}/5 to query SIMBAD…")
962
- QApplication.processEvents(); time.sleep(1.2)
1173
+ except Exception:
1174
+ QApplication.processEvents()
1175
+ time.sleep(1.2)
963
1176
  result = None
1177
+
964
1178
  if result is None or len(result) == 0:
965
1179
  QMessageBox.information(self, "No Stars", "SIMBAD returned zero objects in that region.")
966
- 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
967
1185
 
968
- def infer_letter(bv):
969
- if bv is None or (isinstance(bv, float) and np.isnan(bv)): return None
970
- if bv < 0.00: return "B"
971
- elif bv < 0.30: return "A"
972
- elif bv < 0.58: return "F"
973
- elif bv < 0.81: return "G"
974
- elif bv < 1.40: return "K"
975
- elif bv > 1.40: return "M"
976
- else: return "U"
977
-
978
- self.star_list = []; templates_for_hist = []
979
- for row in result:
980
- raw_sp = row['sp_type']
981
- bmag, vmag, rmag = row['B'], row['V'], row['R']
982
- ra_deg, dec_deg = float(row['ra']), float(row['dec'])
1186
+ # --- helpers ---
1187
+ def _unmask_num(x):
983
1188
  try:
984
- 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)
985
1194
  except Exception:
986
- continue
1195
+ return None
1196
+
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
987
1213
 
988
- def _unmask_num(x):
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:
989
1222
  try:
990
- if x is None or np.ma.isMaskedArray(x) and np.ma.is_masked(x):
991
- return None
992
- 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
993
1227
  except Exception:
994
- return None
1228
+ pass
1229
+ return None
1230
+ except Exception:
1231
+ return None
995
1232
 
996
- # inside your SIMBAD row loop:
997
- bmag = _unmask_num(row['B'])
998
- vmag = _unmask_num(row['V'])
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
1253
+
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
999
1280
 
1000
1281
  sp_clean = None
1001
1282
  if raw_sp and str(raw_sp).strip():
1002
1283
  sp = str(raw_sp).strip().upper()
1003
1284
  if not (sp.startswith("SN") or sp.startswith("KA")):
1004
1285
  sp_clean = sp
1005
- elif bmag is not None and vmag is not None:
1006
- bv = bmag - vmag
1007
- sp_clean = infer_letter(bv)
1008
- 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
1009
1291
 
1010
1292
  match_list = pickles_match_for_simbad(sp_clean, self.pickles_templates)
1011
1293
  best_template = match_list[0] if match_list else None
1012
- 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
1013
1300
  if 0 <= xpix < W and 0 <= ypix < H:
1014
1301
  self.star_list.append({
1015
- "ra": sc.ra.deg, "dec": sc.dec.deg, "sp_clean": sp_clean,
1016
- "pickles_match": best_template, "x": xpix, "y": ypix,
1017
- "Bmag": float(bmag) if bmag else None,
1018
- "Vmag": float(vmag) if vmag else None,
1019
- "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,
1020
1310
  })
1021
- 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()
1022
1317
 
1023
- self.figure.clf()
1024
1318
  if templates_for_hist:
1025
1319
  uniq, cnt = np.unique(templates_for_hist, return_counts=True)
1026
- types_str = ", ".join(uniq)
1027
- self.count_label.setText(f"Found {len(templates_for_hist)} stars; templates: {types_str}")
1028
- ax = self.figure.add_subplot(111)
1029
- ax.bar(uniq, cnt, edgecolor="black")
1030
- ax.set_xlabel("Spectral Type"); ax.set_ylabel("Count"); ax.set_title("Spectral Distribution")
1031
- ax.tick_params(axis='x', rotation=90); ax.grid(axis="y", linestyle="--", alpha=0.3)
1032
- 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()
1033
1334
  else:
1034
- self.count_label.setText("Found 0 stars with Pickles matches.")
1035
- 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()
1036
1340
 
1037
1341
  # ── Core SFCC ───────────────────────────────────────────────────────
1038
-
1039
1342
  def run_spcc(self):
1040
1343
  ref_sed_name = self.star_combo.currentData()
1041
1344
  r_filt = self.r_filter_combo.currentText()
@@ -1046,13 +1349,15 @@ class SFCCDialog(QDialog):
1046
1349
  lp_filt2 = self.lp_filter_combo2.currentText()
1047
1350
 
1048
1351
  if not ref_sed_name:
1049
- 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
1050
1354
  if r_filt == "(None)" and g_filt == "(None)" and b_filt == "(None)":
1051
- 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
1052
1357
  if sens_name == "(None)":
1053
- QMessageBox.warning(self, "Error", "Select a sensor QE curve."); return
1358
+ QMessageBox.warning(self, "Error", "Select a sensor QE curve.")
1359
+ return
1054
1360
 
1055
- # -- Step 1A: get active image as float32 in [0..1]
1056
1361
  doc = self.doc_manager.get_active_document()
1057
1362
  if doc is None or doc.image is None:
1058
1363
  QMessageBox.critical(self, "Error", "No active document.")
@@ -1064,29 +1369,32 @@ class SFCCDialog(QDialog):
1064
1369
  QMessageBox.critical(self, "Error", "Active document must be RGB (3 channels).")
1065
1370
  return
1066
1371
 
1372
+ # ---- Convert to float working space ----
1067
1373
  if img.dtype == np.uint8:
1068
- base = img.astype(np.float32) / 255.0
1374
+ img_float = img.astype(np.float32) / 255.0
1069
1375
  else:
1070
- base = img.astype(np.float32, copy=True)
1376
+ img_float = img.astype(np.float32, copy=False)
1071
1377
 
1072
- # pedestal removal
1073
- base = np.clip(base - np.min(base, axis=(0,1)), 0.0, None)
1074
- # light neutralization
1075
- base = self._neutralize_background(base, patch_size=10)
1378
+ # ---- Build SEP working copy (ONE pedestal handling only) ----
1379
+ base = self._make_working_base_for_sep(img_float)
1380
+
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)
1076
1385
 
1077
1386
  # SEP on grayscale
1078
- gray = np.mean(base, axis=2)
1079
-
1387
+ gray = np.mean(base, axis=2).astype(np.float32)
1388
+
1080
1389
  bkg = sep.Background(gray)
1081
1390
  data_sub = gray - bkg.back()
1082
- 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()
1083
1397
 
1084
- # 👇 get user threshold (default 5.0)
1085
- if hasattr(self, "sep_thr_spin"):
1086
- sep_sigma = float(self.sep_thr_spin.value())
1087
- else:
1088
- sep_sigma = 5.0
1089
- self.count_label.setText(f"Detecting stars (SEP σ={sep_sigma:.1f})…"); QApplication.processEvents()
1090
1398
  sources = sep.extract(data_sub, sep_sigma, err=err)
1091
1399
 
1092
1400
  MAX_SOURCES = 300_000
@@ -1100,32 +1408,51 @@ class SFCCDialog(QDialog):
1100
1408
  return
1101
1409
 
1102
1410
  if sources.size == 0:
1103
- QMessageBox.critical(self, "SEP Error", "SEP found no sources."); return
1104
- r_fluxrad, _ = sep.flux_radius(gray, sources["x"], sources["y"], 2.0*sources["a"], 0.5, normflux=sources["flux"], subpix=5)
1105
- 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]
1106
1422
  if sources.size == 0:
1107
- 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
1108
1425
 
1109
1426
  if not getattr(self, "star_list", None):
1110
- 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
1111
1429
 
1430
+ # ---- Match SIMBAD stars to SEP detections ----
1112
1431
  raw_matches = []
1113
1432
  for i, star in enumerate(self.star_list):
1114
- dx = sources["x"] - star["x"]; dy = sources["y"] - star["y"]
1115
- j = np.argmin(dx*dx + dy*dy)
1116
- if (dx[j]**2 + dy[j]**2) < 3.0**2:
1117
- 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])))
1118
1438
  if 0 <= xi < W and 0 <= yi < H:
1119
- 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
+
1120
1446
  if not raw_matches:
1121
- 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
1122
1449
 
1123
1450
  wl_min, wl_max = 3000, 11000
1124
- wl_grid = np.arange(wl_min, wl_max+1)
1451
+ wl_grid = np.arange(wl_min, wl_max + 1)
1125
1452
 
1126
1453
  def load_curve(ext):
1127
1454
  for p in (self.user_custom_path, self.sasp_data_path):
1128
- with fits.open(p) as hd:
1455
+ with fits.open(p, memmap=False) as hd:
1129
1456
  if ext in hd:
1130
1457
  d = hd[ext].data
1131
1458
  wl = _ensure_angstrom(d["WAVELENGTH"].astype(float))
@@ -1135,7 +1462,7 @@ class SFCCDialog(QDialog):
1135
1462
 
1136
1463
  def load_sed(ext):
1137
1464
  for p in (self.user_custom_path, self.sasp_data_path):
1138
- with fits.open(p) as hd:
1465
+ with fits.open(p, memmap=False) as hd:
1139
1466
  if ext in hd:
1140
1467
  d = hd[ext].data
1141
1468
  wl = _ensure_angstrom(d["WAVELENGTH"].astype(float))
@@ -1143,18 +1470,21 @@ class SFCCDialog(QDialog):
1143
1470
  return wl, fl
1144
1471
  raise KeyError(f"SED '{ext}' not found")
1145
1472
 
1146
- interp = lambda wl_o, tp_o: np.interp(wl_grid, wl_o, tp_o, left=0., right=0.)
1147
- T_R = interp(*load_curve(r_filt)) if r_filt!="(None)" else np.ones_like(wl_grid)
1148
- T_G = interp(*load_curve(g_filt)) if g_filt!="(None)" else np.ones_like(wl_grid)
1149
- T_B = interp(*load_curve(b_filt)) if b_filt!="(None)" else np.ones_like(wl_grid)
1150
- QE = interp(*load_curve(sens_name)) if sens_name!="(None)" else np.ones_like(wl_grid)
1151
- LP1 = interp(*load_curve(lp_filt)) if lp_filt != "(None)" else np.ones_like(wl_grid)
1152
- 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)
1153
1481
  LP = LP1 * LP2
1154
- 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
1155
1484
 
1156
1485
  wl_ref, fl_ref = load_sed(ref_sed_name)
1157
- 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
+
1158
1488
  S_ref_R = np.trapezoid(fr_i * T_sys_R, x=wl_grid)
1159
1489
  S_ref_G = np.trapezoid(fr_i * T_sys_G, x=wl_grid)
1160
1490
  S_ref_B = np.trapezoid(fr_i * T_sys_B, x=wl_grid)
@@ -1163,155 +1493,207 @@ class SFCCDialog(QDialog):
1163
1493
  diag_meas_BG, diag_exp_BG = [], []
1164
1494
  enriched = []
1165
1495
 
1166
- # --- Optimization: Pre-calculate integrals for unique templates ---
1496
+ # ---- Pre-calc integrals for unique templates ----
1167
1497
  unique_simbad_types = set(m["template"] for m in raw_matches)
1168
-
1169
- # Map simbad_type -> pickles_template_name
1498
+
1170
1499
  simbad_to_pickles = {}
1171
1500
  pickles_templates_needed = set()
1172
-
1173
1501
  for sp in unique_simbad_types:
1174
1502
  cands = pickles_match_for_simbad(sp, getattr(self, "pickles_templates", []))
1175
1503
  if cands:
1176
- pickles_name = cands[0]
1177
- simbad_to_pickles[sp] = pickles_name
1178
- pickles_templates_needed.add(pickles_name)
1504
+ pname = cands[0]
1505
+ simbad_to_pickles[sp] = pname
1506
+ pickles_templates_needed.add(pname)
1179
1507
 
1180
- # Pre-calc integrals for each unique Pickles template
1181
- # Cache structure: template_name -> (S_sr, S_sg, S_sb)
1182
1508
  template_integrals = {}
1183
-
1184
- # Cache for load_sed to avoid re-reading even across different calls if desired,
1185
- # but here we just optimize the loop.
1186
-
1187
1509
  for pname in pickles_templates_needed:
1188
1510
  try:
1189
1511
  wl_s, fl_s = load_sed(pname)
1190
- fs_i = np.interp(wl_grid, wl_s, fl_s, left=0., right=0.)
1191
-
1512
+ fs_i = np.interp(wl_grid, wl_s, fl_s, left=0.0, right=0.0)
1192
1513
  S_sr = np.trapezoid(fs_i * T_sys_R, x=wl_grid)
1193
1514
  S_sg = np.trapezoid(fs_i * T_sys_G, x=wl_grid)
1194
1515
  S_sb = np.trapezoid(fs_i * T_sys_B, x=wl_grid)
1195
-
1196
1516
  template_integrals[pname] = (S_sr, S_sg, S_sb)
1197
1517
  except Exception as e:
1198
1518
  print(f"[SFCC] Warning: failed to load/integrate template {pname}: {e}")
1199
1519
 
1200
- # --- Main Match Loop ---
1520
+ # ---- Main match loop (measure from 'base' only) ----
1201
1521
  for m in raw_matches:
1202
1522
  xi, yi, sp = m["x_pix"], m["y_pix"], m["template"]
1203
- Rm = float(base[yi, xi, 0]); Gm = float(base[yi, xi, 1]); Bm = float(base[yi, xi, 2])
1204
- if Gm <= 0: continue
1205
1523
 
1206
- # 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
+
1207
1531
  pname = simbad_to_pickles.get(sp)
1208
- if not pname: continue
1209
-
1210
- # 2. Retrieve pre-calced integrals
1532
+ if not pname:
1533
+ continue
1534
+
1211
1535
  integrals = template_integrals.get(pname)
1212
- if not integrals: continue
1213
-
1536
+ if not integrals:
1537
+ continue
1538
+
1214
1539
  S_sr, S_sg, S_sb = integrals
1215
-
1216
- if S_sg <= 0: continue
1540
+ if S_sg <= 0:
1541
+ continue
1217
1542
 
1218
- exp_RG = S_sr / S_sg; exp_BG = S_sb / S_sg
1219
- 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
1220
1547
 
1221
1548
  diag_meas_RG.append(meas_RG); diag_exp_RG.append(exp_RG)
1222
1549
  diag_meas_BG.append(meas_BG); diag_exp_BG.append(exp_BG)
1223
1550
 
1224
1551
  enriched.append({
1225
- **m, "R_meas": Rm, "G_meas": Gm, "B_meas": Bm,
1552
+ **m,
1553
+ "R_meas": Rm, "G_meas": Gm, "B_meas": Bm,
1226
1554
  "S_star_R": S_sr, "S_star_G": S_sg, "S_star_B": S_sb,
1227
1555
  "exp_RG": exp_RG, "exp_BG": exp_BG
1228
1556
  })
1229
-
1557
+
1230
1558
  self._last_matched = enriched
1231
- diag_meas_RG = np.array(diag_meas_RG); diag_exp_RG = np.array(diag_exp_RG)
1232
- 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
+
1233
1564
  if diag_meas_RG.size == 0 or diag_meas_BG.size == 0:
1234
- QMessageBox.information(self, "No Valid Stars", "No stars with valid measured vs expected ratios."); return
1235
- 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)
1236
1569
 
1237
- def rms_frac(pred, exp): return np.sqrt(np.mean(((pred/exp) - 1.0) ** 2))
1238
- slope_only = lambda x, m: m*x
1239
- affine = lambda x, m, b: m*x + b
1240
- 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)))
1241
1572
 
1242
- denR = np.sum(diag_meas_RG**2); denB = np.sum(diag_meas_BG**2)
1243
- mR_s = (np.sum(diag_meas_RG * diag_exp_RG) / denR) if denR > 0 else 1.0
1244
- 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
1245
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)
1246
1582
 
1247
- 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]
1248
- 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]
1249
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)
1250
1590
 
1251
1591
  aR_q, bR_q, cR_q = np.polyfit(diag_meas_RG, diag_exp_RG, 2)
1252
1592
  aB_q, bB_q, cB_q = np.polyfit(diag_meas_BG, diag_exp_BG, 2)
1253
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)
1254
1594
 
1255
- idx = np.argmin([rms_s, rms_a, rms_q])
1256
- if idx == 0: coeff_R, coeff_B, model_choice = (0, mR_s, 0), (0, mB_s, 0), "slope-only"
1257
- elif idx == 1: coeff_R, coeff_B, model_choice = (0, mR_a, bR_a), (0, mB_a, bB_a), "affine"
1258
- 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"
1602
+
1603
+ poly = lambda c, x: c[0] * x**2 + c[1] * x + c[2]
1259
1604
 
1260
- poly = lambda c, x: c[0]*x**2 + c[1]*x + c[2]
1605
+ # ---- Diagnostics plot (unchanged) ----
1261
1606
  self.figure.clf()
1262
- #ax1 = self.figure.add_subplot(1, 3, 1); bins=20
1263
- #ax1.hist(diag_meas_RG, bins=bins, alpha=.65, label="meas R/G", color="firebrick", edgecolor="black")
1264
- #ax1.hist(diag_exp_RG, bins=bins, alpha=.55, label="exp R/G", color="salmon", edgecolor="black")
1265
- #ax1.hist(diag_meas_BG, bins=bins, alpha=.65, label="meas B/G", color="royalblue", edgecolor="black")
1266
- #ax1.hist(diag_exp_BG, bins=bins, alpha=.55, label="exp B/G", color="lightskyblue", edgecolor="black")
1267
- #ax1.set_xlabel("Ratio (band / G)"); ax1.set_ylabel("Count"); ax1.set_title("Measured vs expected"); ax1.legend(fontsize=7, frameon=False)
1268
-
1269
1607
  res0_RG = (diag_meas_RG / diag_exp_RG) - 1.0
1270
1608
  res0_BG = (diag_meas_BG / diag_exp_BG) - 1.0
1271
1609
  res1_RG = (poly(coeff_R, diag_meas_RG) / diag_exp_RG) - 1.0
1272
1610
  res1_BG = (poly(coeff_B, diag_meas_BG) / diag_exp_BG) - 1.0
1273
1611
 
1274
- ymin = np.min(np.concatenate([res0_RG, res0_BG])); ymax = np.max(np.concatenate([res0_RG, res0_BG]))
1275
- 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
+
1276
1617
  def shade(ax, yvals, color):
1277
- 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)
1278
1620
 
1279
1621
  ax2 = self.figure.add_subplot(1, 2, 1)
1280
- ax2.axhline(0, color="0.65", ls="--", lw=1); shade(ax2, res0_RG, "firebrick"); shade(ax2, res0_BG, "royalblue")
1281
- ax2.scatter(diag_exp_RG, res0_RG, c="firebrick", marker="o", alpha=.7, label="R/G residual")
1282
- ax2.scatter(diag_exp_BG, res0_BG, c="royalblue", marker="s", alpha=.7, label="B/G residual")
1283
- ax2.set_ylim(*y_lim); ax2.set_xlabel("Expected (band/G)"); ax2.set_ylabel("Frac residual (meas/exp − 1)")
1284
- 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")
1285
1631
 
1286
1632
  ax3 = self.figure.add_subplot(1, 2, 2)
1287
- ax3.axhline(0, color="0.65", ls="--", lw=1); shade(ax3, res1_RG, "firebrick"); shade(ax3, res1_BG, "royalblue")
1288
- ax3.scatter(diag_exp_RG, res1_RG, c="firebrick", marker="o", alpha=.7)
1289
- ax3.scatter(diag_exp_BG, res1_BG, c="royalblue", marker="s", alpha=.7)
1290
- 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)")
1291
1640
  ax3.set_title("Residuals • AFTER")
1292
- self.canvas.setVisible(True); self.figure.tight_layout(w_pad=2.); self.canvas.draw()
1293
-
1294
- self.count_label.setText("Applying SFCC color scales to image…"); QApplication.processEvents()
1295
- if img.dtype == np.uint8: img_float = img.astype(np.float32) / 255.0
1296
- else: img_float = img.astype(np.float32)
1297
-
1298
- RG = img_float[..., 0] / np.maximum(img_float[..., 1], 1e-8)
1299
- BG = img_float[..., 2] / np.maximum(img_float[..., 1], 1e-8)
1300
- aR, bR, cR = coeff_R; aB, bB, cB = coeff_B
1301
- RG_corr = aR*RG**2 + bR*RG + cR
1302
- BG_corr = aB*BG**2 + bB*BG + cB
1303
- calibrated = img_float.copy()
1304
- calibrated[..., 0] = RG_corr * img_float[..., 1]
1305
- calibrated[..., 2] = BG_corr * img_float[..., 1]
1306
- calibrated = np.clip(calibrated, 0, 1)
1307
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 ---
1308
1678
  if self.neutralize_chk.isChecked():
1309
- 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")
1310
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
1311
1693
  if img.dtype == np.uint8:
1312
- 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)
1313
1695
  else:
1314
- calibrated = np.clip(calibrated, 0, 1).astype(np.float32)
1696
+ out_img = np.clip(calibrated, 0.0, 1.0).astype(np.float32)
1315
1697
 
1316
1698
  new_meta = dict(doc.metadata or {})
1317
1699
  new_meta.update({
@@ -1323,25 +1705,31 @@ class SFCCDialog(QDialog):
1323
1705
  })
1324
1706
 
1325
1707
  self.doc_manager.update_active_document(
1326
- calibrated,
1708
+ out_img,
1327
1709
  metadata=new_meta,
1328
1710
  step_name="SFCC Calibrated",
1329
- doc=doc, # 👈 pin to the document we started from
1711
+ doc=doc,
1330
1712
  )
1331
1713
 
1332
1714
  self.count_label.setText(f"Applied SFCC color calibration using {n_stars} stars")
1333
1715
  QApplication.processEvents()
1334
1716
 
1335
- def pretty(coeff): return coeff[0] + coeff[1] + coeff[2]
1336
- ratio_R, ratio_B = pretty(coeff_R), pretty(coeff_B)
1337
- QMessageBox.information(self, "SFCC Complete",
1338
- f"Applied SFCC using {n_stars} stars\n"
1339
- f"Model: {model_choice}\n"
1340
- f"R ratio @ x=1: {ratio_R:.4f}\n"
1341
- f"B ratio @ x=1: {ratio_B:.4f}\n"
1342
- 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
1343
1732
 
1344
- self.current_image = calibrated # keep for gradient step
1345
1733
 
1346
1734
  # ── Chromatic gradient (optional) ──────────────────────────────────
1347
1735