setiastrosuitepro 1.6.10__py3-none-any.whl → 1.7.0.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.
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/narrowbandnormalization.png +0 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/saspro/__main__.py +1 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/aberration_ai.py +49 -11
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/backgroundneutral.py +73 -33
- setiastro/saspro/blink_comparator_pro.py +116 -71
- setiastro/saspro/convo.py +9 -6
- setiastro/saspro/curve_editor_pro.py +72 -22
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +178 -11
- setiastro/saspro/gui/main_window.py +305 -66
- setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
- setiastro/saspro/gui/mixins/file_mixin.py +35 -16
- setiastro/saspro/gui/mixins/menu_mixin.py +32 -1
- setiastro/saspro/gui/mixins/toolbar_mixin.py +135 -11
- setiastro/saspro/histogram.py +179 -7
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +972 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +66 -15
- setiastro/saspro/legacy/numba_utils.py +25 -48
- setiastro/saspro/live_stacking.py +24 -4
- setiastro/saspro/multiscale_decomp.py +30 -17
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/numba_utils.py +0 -55
- setiastro/saspro/ops/script_editor.py +5 -0
- setiastro/saspro/ops/scripts.py +119 -0
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +4 -0
- setiastro/saspro/ser_stack_config.py +74 -0
- setiastro/saspro/ser_stacker.py +2310 -0
- setiastro/saspro/ser_stacker_dialog.py +1500 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1258 -0
- setiastro/saspro/sfcc.py +602 -214
- setiastro/saspro/shortcuts.py +35 -16
- setiastro/saspro/stacking_suite.py +332 -87
- setiastro/saspro/star_alignment.py +243 -122
- setiastro/saspro/stat_stretch.py +220 -31
- setiastro/saspro/subwindow.py +2 -4
- setiastro/saspro/whitebalance.py +24 -0
- setiastro/saspro/widgets/resource_monitor.py +122 -74
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/METADATA +2 -2
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/RECORD +51 -40
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.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)
|
|
@@ -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
|
-
|
|
880
|
-
|
|
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
|
-
|
|
889
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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.")
|
|
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
|
-
|
|
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 =
|
|
1118
|
+
sky = wcs2.all_pix2world(pix, 0)
|
|
939
1119
|
except Exception as e:
|
|
940
|
-
QMessageBox.critical(self, "WCS Conversion Error", str(e))
|
|
941
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1141
|
+
_try_new_fields()
|
|
1142
|
+
ok = True
|
|
950
1143
|
break
|
|
951
1144
|
except Exception:
|
|
952
1145
|
QApplication.processEvents()
|
|
953
|
-
time.sleep(
|
|
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
|
-
|
|
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
|
|
961
|
-
|
|
962
|
-
|
|
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 = []
|
|
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
|
-
|
|
969
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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
|
-
|
|
1228
|
+
pass
|
|
1229
|
+
return None
|
|
1230
|
+
except Exception:
|
|
1231
|
+
return None
|
|
995
1232
|
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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
|
-
|
|
1007
|
-
|
|
1008
|
-
if not sp_clean:
|
|
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
|
-
|
|
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,
|
|
1016
|
-
"
|
|
1017
|
-
"
|
|
1018
|
-
"
|
|
1019
|
-
|
|
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:
|
|
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
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
|
1035
|
-
|
|
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).")
|
|
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.")
|
|
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.")
|
|
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
|
-
|
|
1374
|
+
img_float = img.astype(np.float32) / 255.0
|
|
1069
1375
|
else:
|
|
1070
|
-
|
|
1376
|
+
img_float = img.astype(np.float32, copy=False)
|
|
1071
1377
|
|
|
1072
|
-
# pedestal
|
|
1073
|
-
base =
|
|
1074
|
-
|
|
1075
|
-
|
|
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.")
|
|
1104
|
-
|
|
1105
|
-
|
|
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.")
|
|
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.")
|
|
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"]
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
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({
|
|
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.")
|
|
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
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
1177
|
-
simbad_to_pickles[sp] =
|
|
1178
|
-
pickles_templates_needed.add(
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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:
|
|
1209
|
-
|
|
1210
|
-
|
|
1532
|
+
if not pname:
|
|
1533
|
+
continue
|
|
1534
|
+
|
|
1211
1535
|
integrals = template_integrals.get(pname)
|
|
1212
|
-
if not integrals:
|
|
1213
|
-
|
|
1536
|
+
if not integrals:
|
|
1537
|
+
continue
|
|
1538
|
+
|
|
1214
1539
|
S_sr, S_sg, S_sb = integrals
|
|
1215
|
-
|
|
1216
|
-
|
|
1540
|
+
if S_sg <= 0:
|
|
1541
|
+
continue
|
|
1217
1542
|
|
|
1218
|
-
exp_RG = S_sr / S_sg
|
|
1219
|
-
|
|
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,
|
|
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.
|
|
1232
|
-
|
|
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.")
|
|
1235
|
-
|
|
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):
|
|
1238
|
-
|
|
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
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
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(
|
|
1248
|
-
|
|
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:
|
|
1257
|
-
|
|
1258
|
-
|
|
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
|
-
|
|
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]))
|
|
1275
|
-
|
|
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])
|
|
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)
|
|
1281
|
-
ax2
|
|
1282
|
-
ax2.scatter(
|
|
1283
|
-
ax2.
|
|
1284
|
-
ax2.
|
|
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)
|
|
1288
|
-
ax3
|
|
1289
|
-
ax3.scatter(
|
|
1290
|
-
ax3.
|
|
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
|
-
|
|
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
|
-
|
|
1694
|
+
out_img = (np.clip(calibrated, 0.0, 1.0) * 255.0).astype(np.uint8)
|
|
1313
1695
|
else:
|
|
1314
|
-
|
|
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
|
-
|
|
1708
|
+
out_img,
|
|
1327
1709
|
metadata=new_meta,
|
|
1328
1710
|
step_name="SFCC Calibrated",
|
|
1329
|
-
doc=doc,
|
|
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):
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
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
|
|