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