setiastrosuitepro 1.6.0__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (174) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/saspro/__init__.py +20 -0
  3. setiastro/saspro/__main__.py +784 -0
  4. setiastro/saspro/_generated/__init__.py +7 -0
  5. setiastro/saspro/_generated/build_info.py +2 -0
  6. setiastro/saspro/abe.py +1295 -0
  7. setiastro/saspro/abe_preset.py +196 -0
  8. setiastro/saspro/aberration_ai.py +694 -0
  9. setiastro/saspro/aberration_ai_preset.py +224 -0
  10. setiastro/saspro/accel_installer.py +218 -0
  11. setiastro/saspro/accel_workers.py +30 -0
  12. setiastro/saspro/add_stars.py +621 -0
  13. setiastro/saspro/astrobin_exporter.py +1007 -0
  14. setiastro/saspro/astrospike.py +153 -0
  15. setiastro/saspro/astrospike_python.py +1839 -0
  16. setiastro/saspro/autostretch.py +196 -0
  17. setiastro/saspro/backgroundneutral.py +560 -0
  18. setiastro/saspro/batch_convert.py +325 -0
  19. setiastro/saspro/batch_renamer.py +519 -0
  20. setiastro/saspro/blemish_blaster.py +488 -0
  21. setiastro/saspro/blink_comparator_pro.py +2923 -0
  22. setiastro/saspro/bundles.py +61 -0
  23. setiastro/saspro/bundles_dock.py +114 -0
  24. setiastro/saspro/cheat_sheet.py +168 -0
  25. setiastro/saspro/clahe.py +342 -0
  26. setiastro/saspro/comet_stacking.py +1377 -0
  27. setiastro/saspro/config.py +38 -0
  28. setiastro/saspro/config_bootstrap.py +40 -0
  29. setiastro/saspro/config_manager.py +316 -0
  30. setiastro/saspro/continuum_subtract.py +1617 -0
  31. setiastro/saspro/convo.py +1397 -0
  32. setiastro/saspro/convo_preset.py +414 -0
  33. setiastro/saspro/copyastro.py +187 -0
  34. setiastro/saspro/cosmicclarity.py +1564 -0
  35. setiastro/saspro/cosmicclarity_preset.py +407 -0
  36. setiastro/saspro/crop_dialog_pro.py +948 -0
  37. setiastro/saspro/crop_preset.py +189 -0
  38. setiastro/saspro/curve_editor_pro.py +2544 -0
  39. setiastro/saspro/curves_preset.py +375 -0
  40. setiastro/saspro/debayer.py +670 -0
  41. setiastro/saspro/debug_utils.py +29 -0
  42. setiastro/saspro/dnd_mime.py +35 -0
  43. setiastro/saspro/doc_manager.py +2634 -0
  44. setiastro/saspro/exoplanet_detector.py +2166 -0
  45. setiastro/saspro/file_utils.py +284 -0
  46. setiastro/saspro/fitsmodifier.py +744 -0
  47. setiastro/saspro/free_torch_memory.py +48 -0
  48. setiastro/saspro/frequency_separation.py +1343 -0
  49. setiastro/saspro/function_bundle.py +1594 -0
  50. setiastro/saspro/ghs_dialog_pro.py +660 -0
  51. setiastro/saspro/ghs_preset.py +284 -0
  52. setiastro/saspro/graxpert.py +634 -0
  53. setiastro/saspro/graxpert_preset.py +287 -0
  54. setiastro/saspro/gui/__init__.py +0 -0
  55. setiastro/saspro/gui/main_window.py +8494 -0
  56. setiastro/saspro/gui/mixins/__init__.py +33 -0
  57. setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
  58. setiastro/saspro/gui/mixins/file_mixin.py +445 -0
  59. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  60. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  61. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  62. setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
  63. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  64. setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
  65. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  66. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  67. setiastro/saspro/halobgon.py +462 -0
  68. setiastro/saspro/header_viewer.py +445 -0
  69. setiastro/saspro/headless_utils.py +88 -0
  70. setiastro/saspro/histogram.py +753 -0
  71. setiastro/saspro/history_explorer.py +939 -0
  72. setiastro/saspro/image_combine.py +414 -0
  73. setiastro/saspro/image_peeker_pro.py +1596 -0
  74. setiastro/saspro/imageops/__init__.py +37 -0
  75. setiastro/saspro/imageops/mdi_snap.py +292 -0
  76. setiastro/saspro/imageops/scnr.py +36 -0
  77. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  78. setiastro/saspro/imageops/stretch.py +244 -0
  79. setiastro/saspro/isophote.py +1179 -0
  80. setiastro/saspro/layers.py +208 -0
  81. setiastro/saspro/layers_dock.py +714 -0
  82. setiastro/saspro/lazy_imports.py +193 -0
  83. setiastro/saspro/legacy/__init__.py +2 -0
  84. setiastro/saspro/legacy/image_manager.py +2226 -0
  85. setiastro/saspro/legacy/numba_utils.py +3659 -0
  86. setiastro/saspro/legacy/xisf.py +1071 -0
  87. setiastro/saspro/linear_fit.py +534 -0
  88. setiastro/saspro/live_stacking.py +1830 -0
  89. setiastro/saspro/log_bus.py +5 -0
  90. setiastro/saspro/logging_config.py +460 -0
  91. setiastro/saspro/luminancerecombine.py +309 -0
  92. setiastro/saspro/main_helpers.py +201 -0
  93. setiastro/saspro/mask_creation.py +928 -0
  94. setiastro/saspro/masks_core.py +56 -0
  95. setiastro/saspro/mdi_widgets.py +353 -0
  96. setiastro/saspro/memory_utils.py +666 -0
  97. setiastro/saspro/metadata_patcher.py +75 -0
  98. setiastro/saspro/mfdeconv.py +3826 -0
  99. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  100. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  101. setiastro/saspro/mfdeconvsport.py +2382 -0
  102. setiastro/saspro/minorbodycatalog.py +567 -0
  103. setiastro/saspro/morphology.py +382 -0
  104. setiastro/saspro/multiscale_decomp.py +1290 -0
  105. setiastro/saspro/nbtorgb_stars.py +531 -0
  106. setiastro/saspro/numba_utils.py +3044 -0
  107. setiastro/saspro/numba_warmup.py +141 -0
  108. setiastro/saspro/ops/__init__.py +9 -0
  109. setiastro/saspro/ops/command_help_dialog.py +623 -0
  110. setiastro/saspro/ops/command_runner.py +217 -0
  111. setiastro/saspro/ops/commands.py +1594 -0
  112. setiastro/saspro/ops/script_editor.py +1102 -0
  113. setiastro/saspro/ops/scripts.py +1413 -0
  114. setiastro/saspro/ops/settings.py +560 -0
  115. setiastro/saspro/parallel_utils.py +554 -0
  116. setiastro/saspro/pedestal.py +121 -0
  117. setiastro/saspro/perfect_palette_picker.py +1053 -0
  118. setiastro/saspro/pipeline.py +110 -0
  119. setiastro/saspro/pixelmath.py +1600 -0
  120. setiastro/saspro/plate_solver.py +2435 -0
  121. setiastro/saspro/project_io.py +797 -0
  122. setiastro/saspro/psf_utils.py +136 -0
  123. setiastro/saspro/psf_viewer.py +549 -0
  124. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  125. setiastro/saspro/remove_green.py +314 -0
  126. setiastro/saspro/remove_stars.py +1625 -0
  127. setiastro/saspro/remove_stars_preset.py +404 -0
  128. setiastro/saspro/resources.py +472 -0
  129. setiastro/saspro/rgb_combination.py +207 -0
  130. setiastro/saspro/rgb_extract.py +19 -0
  131. setiastro/saspro/rgbalign.py +723 -0
  132. setiastro/saspro/runtime_imports.py +7 -0
  133. setiastro/saspro/runtime_torch.py +754 -0
  134. setiastro/saspro/save_options.py +72 -0
  135. setiastro/saspro/selective_color.py +1552 -0
  136. setiastro/saspro/sfcc.py +1425 -0
  137. setiastro/saspro/shortcuts.py +2807 -0
  138. setiastro/saspro/signature_insert.py +1099 -0
  139. setiastro/saspro/stacking_suite.py +17712 -0
  140. setiastro/saspro/star_alignment.py +7420 -0
  141. setiastro/saspro/star_alignment_preset.py +329 -0
  142. setiastro/saspro/star_metrics.py +49 -0
  143. setiastro/saspro/star_spikes.py +681 -0
  144. setiastro/saspro/star_stretch.py +470 -0
  145. setiastro/saspro/stat_stretch.py +502 -0
  146. setiastro/saspro/status_log_dock.py +78 -0
  147. setiastro/saspro/subwindow.py +3267 -0
  148. setiastro/saspro/supernovaasteroidhunter.py +1712 -0
  149. setiastro/saspro/swap_manager.py +99 -0
  150. setiastro/saspro/torch_backend.py +89 -0
  151. setiastro/saspro/torch_rejection.py +434 -0
  152. setiastro/saspro/view_bundle.py +1555 -0
  153. setiastro/saspro/wavescale_hdr.py +624 -0
  154. setiastro/saspro/wavescale_hdr_preset.py +100 -0
  155. setiastro/saspro/wavescalede.py +657 -0
  156. setiastro/saspro/wavescalede_preset.py +228 -0
  157. setiastro/saspro/wcs_update.py +374 -0
  158. setiastro/saspro/whitebalance.py +456 -0
  159. setiastro/saspro/widgets/__init__.py +48 -0
  160. setiastro/saspro/widgets/common_utilities.py +305 -0
  161. setiastro/saspro/widgets/graphics_views.py +122 -0
  162. setiastro/saspro/widgets/image_utils.py +518 -0
  163. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  164. setiastro/saspro/widgets/spinboxes.py +275 -0
  165. setiastro/saspro/widgets/themed_buttons.py +13 -0
  166. setiastro/saspro/widgets/wavelet_utils.py +299 -0
  167. setiastro/saspro/window_shelf.py +185 -0
  168. setiastro/saspro/xisf.py +1123 -0
  169. setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
  170. setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
  171. setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
  172. setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
  173. setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
  174. setiastrosuitepro-1.6.0.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,1425 @@
1
+ # sfcc.py
2
+ # SASpro Spectral Flux Color Calibration (SFCC) — "current view" integration
3
+ # - Expects a "view adapter" you provide that exposes:
4
+ # get_rgb_image() -> np.ndarray (H,W,3), uint8 or float32 in [0,1]
5
+ # get_metadata() -> dict (optional; may return {})
6
+ # get_header() -> astropy.io.fits.Header or dict (optional but needed for WCS features)
7
+ # set_rgb_image(img: np.ndarray, metadata: dict | None = None, step_name: str | None = None) -> None
8
+ # If your adapter names differ, tweak _get_img_meta/_get_header/_push_image below (they already try a few fallbacks).
9
+ #
10
+ # - Call open_sfcc(view_adapter, sasp_data_path) to show the dialog.
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import re
16
+ import cv2
17
+ import math
18
+ import time
19
+ from datetime import datetime
20
+ from typing import List, Tuple, Optional
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+
25
+ # ── SciPy bits
26
+ from scipy.interpolate import RBFInterpolator, interp1d
27
+ from scipy.signal import medfilt
28
+
29
+ # ── Astropy / Astroquery
30
+ from astropy.io import fits
31
+ from astropy.wcs import WCS
32
+ import astropy.units as u
33
+ from astropy.coordinates import SkyCoord
34
+ from astroquery.simbad import Simbad
35
+
36
+ # ── SEP (Source Extractor)
37
+ import sep
38
+
39
+ # ── Matplotlib backend for Qt
40
+ from matplotlib.figure import Figure
41
+ from matplotlib import pyplot as plt
42
+ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
43
+
44
+ from PyQt6.QtCore import (Qt, QPoint, QRect, QMimeData, QSettings, QByteArray,
45
+ QDataStream, QIODevice, QEvent, QStandardPaths)
46
+ from PyQt6.QtGui import (QAction, QDrag, QIcon, QMouseEvent, QPixmap, QKeyEvent)
47
+ from PyQt6.QtWidgets import (QToolBar, QWidget, QToolButton, QMenu, QApplication, QVBoxLayout, QHBoxLayout, QComboBox, QGroupBox, QGridLayout, QDoubleSpinBox, QSpinBox,
48
+ QInputDialog, QMessageBox, QDialog, QFileDialog,
49
+ QFormLayout, QDialogButtonBox, QDoubleSpinBox, QCheckBox, QLabel, QRubberBand, QRadioButton, QMainWindow, QPushButton)
50
+
51
+
52
+ # ──────────────────────────────────────────────────────────────────────────────
53
+ # Utilities
54
+ # ──────────────────────────────────────────────────────────────────────────────
55
+
56
+ # --- Debug/guards -----------------------------------------------------
57
+ def _debug_probe_channels(img: np.ndarray, label="input"):
58
+ assert img.ndim == 3 and img.shape[2] == 3, f"[SFCC] {label}: not RGB"
59
+ f = img.astype(np.float32) / (255.0 if img.dtype == np.uint8 else 1.0)
60
+ means = [float(f[...,i].mean()) for i in range(3)]
61
+ stds = [float(f[...,i].std()) for i in range(3)]
62
+ rg = float(np.corrcoef(f[...,0].ravel(), f[...,1].ravel())[0,1])
63
+ rb = float(np.corrcoef(f[...,0].ravel(), f[...,2].ravel())[0,1])
64
+ gb = float(np.corrcoef(f[...,1].ravel(), f[...,2].ravel())[0,1])
65
+ print(f"[SFCC] {label}: mean={means}, std={stds}, corr(R,G)={rg:.5f}, corr(R,B)={rb:.5f}, corr(G,B)={gb:.5f}")
66
+ return rg, rb, gb
67
+
68
+ def _maybe_bgr_to_rgb(img: np.ndarray) -> np.ndarray:
69
+ # Heuristic: if channel-2 is consistently brightest in highlights and ch-0 the dimmest → likely BGR.
70
+ f = img.astype(np.float32) / (255.0 if img.dtype == np.uint8 else 1.0)
71
+ lum = np.mean(f, axis=2)
72
+ thr = np.quantile(lum, 0.95)
73
+ m0 = f[...,0][lum >= thr].mean() if np.any(lum >= thr) else f[...,0].mean()
74
+ m1 = f[...,1][lum >= thr].mean() if np.any(lum >= thr) else f[...,1].mean()
75
+ m2 = f[...,2][lum >= thr].mean() if np.any(lum >= thr) else f[...,2].mean()
76
+ if (m2 > m1 >= m0) and (m2 - m0 > 0.02):
77
+ print("[SFCC] Heuristic suggests BGR input → converting to RGB")
78
+ return img[..., ::-1]
79
+ return img
80
+
81
+ def _ensure_angstrom(wl: np.ndarray) -> np.ndarray:
82
+ """If wavelengths look like nm (≈300–1100), convert to Å."""
83
+ med = float(np.median(wl))
84
+ return wl * 10.0 if 250.0 <= med <= 2000.0 else wl
85
+
86
+
87
+ def pickles_match_for_simbad(simbad_sp: str, available_extnames: List[str]) -> List[str]:
88
+ sp = simbad_sp.strip().upper()
89
+ if not sp:
90
+ return []
91
+ m = re.match(r"^([OBAFGKMLT])(\d?)(I{1,3}|IV|V)?", sp)
92
+ if not m:
93
+ return []
94
+ letter_class = m.group(1)
95
+ digit_part = m.group(2)
96
+ lum_part = m.group(3)
97
+ subclass = int(digit_part) if digit_part != "" else None
98
+
99
+ def parse_pickles_extname(ext: str):
100
+ ext = ext.strip().upper()
101
+ m2 = re.match(r"^([OBAFGKMLT])(\d+)(I{1,3}|IV|V)$", ext)
102
+ if not m2:
103
+ return None, None, None
104
+ return m2.group(1), int(m2.group(2)), m2.group(3)
105
+
106
+ parsed_templates = []
107
+ for ext in available_extnames:
108
+ l2, d2, L2 = parse_pickles_extname(ext)
109
+ if l2 is not None:
110
+ parsed_templates.append((ext, l2, d2, L2))
111
+
112
+ # Exact
113
+ if subclass is not None and lum_part is not None:
114
+ target = f"{letter_class}{subclass}{lum_part}"
115
+ if target in available_extnames:
116
+ return [target]
117
+
118
+ # Same letter (+same lum if we have it)
119
+ same_letter_and_lum = []
120
+ same_letter_any_lum = []
121
+ for (ext, l2, d2, L2) in parsed_templates:
122
+ if l2 != letter_class:
123
+ continue
124
+ if lum_part is not None and L2 == lum_part:
125
+ same_letter_and_lum.append((ext, d2))
126
+ else:
127
+ same_letter_any_lum.append((ext, d2))
128
+
129
+ def pick_nearest(candidates: List[Tuple[str, int]], target: int) -> List[str]:
130
+ if not candidates or target is None:
131
+ return []
132
+ arr = np.abs(np.array([d for _, d in candidates]) - target)
133
+ mind = np.min(arr)
134
+ return [candidates[i][0] for i in np.where(arr == mind)[0]]
135
+
136
+ if subclass is not None and lum_part is not None:
137
+ if same_letter_and_lum:
138
+ return pick_nearest(same_letter_and_lum, subclass)
139
+ if same_letter_any_lum:
140
+ return pick_nearest(same_letter_any_lum, subclass)
141
+
142
+ if subclass is not None and lum_part is None:
143
+ if same_letter_any_lum:
144
+ return pick_nearest(same_letter_any_lum, subclass)
145
+
146
+ if subclass is None and lum_part is None:
147
+ return sorted([ext for (ext, l2, _, _) in parsed_templates if l2 == letter_class])
148
+
149
+ if subclass is None and lum_part is not None:
150
+ cands = [ (ext, d2) for (ext, l2, d2, L2) in parsed_templates if l2 == letter_class and L2 == lum_part ]
151
+ if cands:
152
+ return sorted([ext for (ext, _) in cands])
153
+ return sorted([ext for (ext, l2, _, _) in parsed_templates if l2 == letter_class])
154
+
155
+ return []
156
+
157
+
158
+ def compute_gradient_map(sources, delta_flux, shape, method="poly2"):
159
+ H, W = shape
160
+ xs, ys = sources[:, 0], sources[:, 1]
161
+
162
+ if method == "poly2":
163
+ A = np.vstack([np.ones_like(xs), xs, ys, xs**2, xs*ys, ys**2]).T
164
+ coeffs, *_ = np.linalg.lstsq(A, delta_flux, rcond=None)
165
+ YY, XX = np.mgrid[0:H, 0:W]
166
+ return (coeffs[0] + coeffs[1]*XX + coeffs[2]*YY
167
+ + coeffs[3]*XX**2 + coeffs[4]*XX*YY + coeffs[5]*YY**2)
168
+
169
+ elif method == "poly3":
170
+ A = np.vstack([
171
+ np.ones_like(xs), xs, ys,
172
+ xs**2, xs*ys, ys**2,
173
+ xs**3, xs**2*ys, xs*ys**2, ys**3
174
+ ]).T
175
+ coeffs, *_ = np.linalg.lstsq(A, delta_flux, rcond=None)
176
+ YY, XX = np.mgrid[0:H, 0:W]
177
+ return (coeffs[0] + coeffs[1]*XX + coeffs[2]*YY
178
+ + coeffs[3]*XX**2 + coeffs[4]*XX*YY + coeffs[5]*YY**2
179
+ + coeffs[6]*XX**3 + coeffs[7]*XX**2*YY + coeffs[8]*XX*YY**2 + coeffs[9]*YY**3)
180
+
181
+ elif method == "rbf":
182
+ pts = np.vstack([xs, ys]).T
183
+ rbfi = RBFInterpolator(pts, delta_flux, kernel="thin_plate_spline", smoothing=1.0)
184
+ YY, XX = np.mgrid[0:H, 0:W]
185
+ grid_pts = np.vstack([XX.ravel(), YY.ravel()]).T
186
+ return rbfi(grid_pts).reshape(H, W)
187
+
188
+ else:
189
+ raise ValueError("method must be one of 'poly2','poly3','rbf'")
190
+
191
+
192
+ # ──────────────────────────────────────────────────────────────────────────────
193
+ # Simple responses viewer (unchanged core logic; useful for diagnostics)
194
+ # ──────────────────────────────────────────────────────────────────────────────
195
+ class SaspViewer(QMainWindow):
196
+ def __init__(self, sasp_data_path: str, user_custom_path: str):
197
+ super().__init__()
198
+ self.setWindowTitle("SASP Viewer (Pickles + RGB Responses)")
199
+
200
+ self.base_hdul = fits.open(sasp_data_path, mode="readonly", memmap=False)
201
+ self.custom_hdul = fits.open(user_custom_path, mode="readonly", memmap=False)
202
+
203
+ self.pickles_templates = []
204
+ self.filter_list = []
205
+ self.sensor_list = []
206
+ for hdul in (self.custom_hdul, self.base_hdul):
207
+ for hdu in hdul:
208
+ if not isinstance(hdu, fits.BinTableHDU): continue
209
+ c = hdu.header.get("CTYPE","").upper()
210
+ e = hdu.header.get("EXTNAME","")
211
+ if c == "SED": self.pickles_templates.append(e)
212
+ elif c == "FILTER": self.filter_list.append(e)
213
+ elif c == "SENSOR": self.sensor_list.append(e)
214
+
215
+ for lst in (self.pickles_templates, self.filter_list, self.sensor_list):
216
+ lst.sort()
217
+ self.rgb_filter_choices = ["(None)"] + self.filter_list
218
+
219
+ central = QWidget(); self.setCentralWidget(central)
220
+ vbox = QVBoxLayout(); central.setLayout(vbox)
221
+
222
+ row = QHBoxLayout(); vbox.addLayout(row)
223
+ row.addWidget(QLabel("Star Template:"))
224
+ self.star_combo = QComboBox(); self.star_combo.addItems(self.pickles_templates); row.addWidget(self.star_combo)
225
+ row.addWidget(QLabel("R-Filter:"))
226
+ self.r_filter_combo = QComboBox(); self.r_filter_combo.addItems(self.rgb_filter_choices); row.addWidget(self.r_filter_combo)
227
+ row.addWidget(QLabel("G-Filter:"))
228
+ self.g_filter_combo = QComboBox(); self.g_filter_combo.addItems(self.rgb_filter_choices); row.addWidget(self.g_filter_combo)
229
+ row.addWidget(QLabel("B-Filter:"))
230
+ self.b_filter_combo = QComboBox(); self.b_filter_combo.addItems(self.rgb_filter_choices); row.addWidget(self.b_filter_combo)
231
+
232
+ row2 = QHBoxLayout(); vbox.addLayout(row2)
233
+ row2.addWidget(QLabel("LP/Cut Filter1:"))
234
+ self.lp_filter_combo = QComboBox(); self.lp_filter_combo.addItems(self.rgb_filter_choices); row2.addWidget(self.lp_filter_combo)
235
+ row2.addWidget(QLabel("LP/Cut Filter2:"))
236
+ self.lp_filter_combo2 = QComboBox(); self.lp_filter_combo2.addItems(self.rgb_filter_choices); row2.addWidget(self.lp_filter_combo2)
237
+ row2.addSpacing(20); row2.addWidget(QLabel("Sensor (QE):"))
238
+ self.sens_combo = QComboBox(); self.sens_combo.addItems(self.sensor_list); row2.addWidget(self.sens_combo)
239
+
240
+ self.plot_btn = QPushButton("Plot"); self.plot_btn.clicked.connect(self.update_plot); row.addWidget(self.plot_btn)
241
+
242
+ self.figure = Figure(figsize=(9, 6)); self.canvas = FigureCanvas(self.figure); vbox.addWidget(self.canvas)
243
+ self.update_plot()
244
+
245
+ def closeEvent(self, event):
246
+ self.base_hdul.close(); self.custom_hdul.close()
247
+ super().closeEvent(event)
248
+
249
+ def load_any(self, extname, field):
250
+ for hdul in (self.custom_hdul, self.base_hdul):
251
+ if extname in hdul:
252
+ return hdul[extname].data[field].astype(float)
253
+ raise KeyError(f"Extension '{extname}' not found")
254
+
255
+ def update_plot(self):
256
+ star_ext = self.star_combo.currentText()
257
+ r_filt = self.r_filter_combo.currentText()
258
+ g_filt = self.g_filter_combo.currentText()
259
+ b_filt = self.b_filter_combo.currentText()
260
+ sens_ext = self.sens_combo.currentText()
261
+ lp_ext1 = self.lp_filter_combo.currentText()
262
+ lp_ext2 = self.lp_filter_combo2.currentText()
263
+
264
+ wl_star = self.load_any(star_ext, "WAVELENGTH")
265
+ fl_star = self.load_any(star_ext, "FLUX")
266
+ wl_sens = self.load_any(sens_ext, "WAVELENGTH")
267
+ qe_sens = self.load_any(sens_ext, "THROUGHPUT")
268
+
269
+ wl_min, wl_max = 1150.0, 10620.0
270
+ common_wl = np.arange(wl_min, wl_max + 1.0, 1.0)
271
+
272
+ sed_interp = interp1d(wl_star, fl_star, kind="linear", bounds_error=False, fill_value=0.0)
273
+ sens_interp = interp1d(wl_sens, qe_sens, kind="linear", bounds_error=False, fill_value=0.0)
274
+ fl_common = sed_interp(common_wl)
275
+ sens_common = sens_interp(common_wl)
276
+
277
+ rgb_data = {}
278
+ for color, filt_name in (("red", r_filt), ("green", g_filt), ("blue", b_filt)):
279
+ if filt_name == "(None)":
280
+ rgb_data[color] = None; continue
281
+
282
+ wl_filt = self.load_any(filt_name, "WAVELENGTH")
283
+ tr_filt = self.load_any(filt_name, "THROUGHPUT")
284
+ filt_common = interp1d(wl_filt, tr_filt, bounds_error=False, fill_value=0.0)(common_wl)
285
+
286
+ def lp_curve(ext):
287
+ if ext == "(None)": return np.ones_like(common_wl)
288
+ wl_lp = self.load_any(ext, "WAVELENGTH"); tr_lp = self.load_any(ext, "THROUGHPUT")
289
+ return interp1d(wl_lp, tr_lp, bounds_error=False, fill_value=0.0)(common_wl)
290
+
291
+ T_LP = lp_curve(lp_ext1) * lp_curve(lp_ext2)
292
+ T_sys = filt_common * sens_common * T_LP
293
+ resp = fl_common * T_sys
294
+
295
+ rgb_data[color] = {"filter_name": filt_name, "T_sys": T_sys, "response": resp}
296
+
297
+ mag_texts = []
298
+ if "A0V" in self.pickles_templates:
299
+ wl_veg = self.load_any("A0V", "WAVELENGTH")
300
+ fl_veg = self.load_any("A0V", "FLUX")
301
+ fl_veg_c = interp1d(wl_veg, fl_veg, kind="linear", bounds_error=False, fill_value=0.0)(common_wl)
302
+ for color in ("red","green","blue"):
303
+ data = rgb_data[color]
304
+ if data is not None:
305
+ S_star = np.trapezoid(data["response"], x=common_wl)
306
+ S_veg = np.trapezoid(fl_veg_c * data["T_sys"], x=common_wl)
307
+ if S_veg>0 and S_star>0:
308
+ mag = -2.5 * np.log10(S_star / S_veg)
309
+ mag_texts.append(f"{color[0].upper()}→{data['filter_name']}: {mag:.2f}")
310
+ else:
311
+ mag_texts.append(f"{color[0].upper()}→{data['filter_name']}: N/A")
312
+ title_text = " | ".join(mag_texts) if mag_texts else "No channels selected"
313
+
314
+ self.figure.clf()
315
+ ax1 = self.figure.add_subplot(111)
316
+ ax1.plot(common_wl, fl_common, color="black", linewidth=1, label=f"{star_ext} SED")
317
+ for color, data in rgb_data.items():
318
+ if data is not None:
319
+ ax1.plot(common_wl, data["response"], color="gold", linewidth=1.5, label=f"{color.upper()} Response")
320
+ ax1.set_xlim(wl_min, wl_max); ax1.set_xlabel("Wavelength (Å)")
321
+ ax1.set_ylabel("Flux (erg s⁻¹ cm⁻² Å⁻¹)", color="black"); ax1.tick_params(axis="y", labelcolor="black")
322
+
323
+ ax2 = ax1.twinx()
324
+ ax2.set_ylabel("Relative Throughput", color="red"); ax2.tick_params(axis="y", labelcolor="red"); ax2.set_ylim(0.0, 1.0)
325
+ if rgb_data["red"] is not None: ax2.plot(common_wl, rgb_data["red"]["T_sys"], color="red", linestyle="--", linewidth=1, label="R filter×QE")
326
+ if rgb_data["green"] is not None: ax2.plot(common_wl, rgb_data["green"]["T_sys"], color="green", linestyle="--", linewidth=1, label="G filter×QE")
327
+ if rgb_data["blue"] is not None: ax2.plot(common_wl, rgb_data["blue"]["T_sys"], color="blue", linestyle="--", linewidth=1, label="B filter×QE")
328
+
329
+ ax1.grid(True, which="both", linestyle="--", alpha=0.3); self.figure.suptitle(title_text, fontsize=10)
330
+ lines1, labels1 = ax1.get_legend_handles_labels(); lines2, labels2 = ax2.get_legend_handles_labels()
331
+ ax1.legend(lines1 + lines2, labels1 + labels2, loc="upper right")
332
+ self.canvas.draw()
333
+
334
+
335
+ # ──────────────────────────────────────────────────────────────────────────────
336
+ # SFCC Dialog (rewired for "current view")
337
+ # ──────────────────────────────────────────────────────────────────────────────
338
+ class SFCCDialog(QDialog):
339
+ """
340
+ Spectral Flux Color Calibration dialog, adapted for SASpro's current view.
341
+ Pass a 'view' adapter providing:
342
+ - get_rgb_image(), set_rgb_image(...)
343
+ - get_metadata() [optional]
344
+ - get_header() [preferred for WCS; else we look in metadata]
345
+ """
346
+ def __init__(self, doc_manager, sasp_data_path, parent=None):
347
+ super().__init__(parent)
348
+ self.setWindowTitle("Spectral Flux Color Calibration")
349
+ self.setMinimumSize(800, 600)
350
+
351
+ self.doc_manager = doc_manager
352
+ self.sasp_data_path = sasp_data_path
353
+ self.user_custom_path = self._ensure_user_custom_fits()
354
+ self.current_image = None
355
+ self.current_header = None
356
+ self.orientation_label = QLabel("Orientation: N/A")
357
+ self.sasp_viewer_window = None
358
+ self.main_win = parent
359
+
360
+ # user custom file init … (unchanged)
361
+ # ...
362
+ self._reload_hdu_lists()
363
+ self.star_list = []
364
+ self._build_ui()
365
+ self.load_settings()
366
+
367
+ # persist combobox choices
368
+ self.r_filter_combo.currentIndexChanged.connect(self.save_r_filter_setting)
369
+ self.g_filter_combo.currentIndexChanged.connect(self.save_g_filter_setting)
370
+ self.b_filter_combo.currentIndexChanged.connect(self.save_b_filter_setting)
371
+ self.lp_filter_combo.currentIndexChanged.connect(self.save_lp_setting)
372
+ self.lp_filter_combo2.currentIndexChanged.connect(self.save_lp2_setting)
373
+ self.sens_combo.currentIndexChanged.connect(self.save_sensor_setting)
374
+ self.star_combo.currentIndexChanged.connect(self.save_star_setting)
375
+
376
+ self.grad_method = "poly3"
377
+ self.grad_method_combo.currentTextChanged.connect(lambda m: setattr(self, "grad_method", m))
378
+
379
+ # ── View plumbing ───────────────────────────────────────────────────
380
+ def _get_active_image_and_header(self):
381
+ doc = self.doc_manager.get_active_document()
382
+ if doc is None:
383
+ return None, None, None
384
+
385
+ img = doc.image
386
+ meta = doc.metadata or {}
387
+
388
+ # Prefer the normalized WCS header if present, then fall back
389
+ hdr = (
390
+ meta.get("wcs_header") or
391
+ meta.get("original_header") or
392
+ meta.get("header")
393
+ )
394
+
395
+ return img, hdr, meta
396
+
397
+
398
+ def _get_img_meta(self) -> Tuple[Optional[np.ndarray], dict]:
399
+ """Try a few common shapes to obtain image + metadata from the view."""
400
+ meta = {}
401
+ img = None
402
+ if hasattr(self.view, "get_image_and_metadata"):
403
+ try:
404
+ img, meta = self.view.get_image_and_metadata()
405
+ except Exception:
406
+ pass
407
+ if img is None and hasattr(self.view, "get_rgb_image"):
408
+ img = self.view.get_rgb_image()
409
+ if not meta and hasattr(self.view, "get_metadata"):
410
+ try:
411
+ meta = self.view.get_metadata() or {}
412
+ except Exception:
413
+ meta = {}
414
+ return img, (meta or {})
415
+
416
+ def _get_header(self):
417
+ header = None
418
+ if hasattr(self.view, "get_header"):
419
+ try:
420
+ header = self.view.get_header()
421
+ except Exception:
422
+ header = None
423
+ if header is None:
424
+ # fall back to metadata
425
+ _, meta = self._get_img_meta()
426
+ header = meta.get("original_header") or meta.get("header")
427
+ return header
428
+
429
+ def _push_image(self, img: np.ndarray, meta: Optional[dict], step_name: str):
430
+ """Send image back to the same current view."""
431
+ if hasattr(self.view, "set_rgb_image"):
432
+ self.view.set_rgb_image(img, meta or {}, step_name)
433
+ elif hasattr(self.view, "set_image"):
434
+ self.view.set_image(img, meta or {}, step_name=step_name)
435
+ elif hasattr(self.view, "update_image"):
436
+ self.view.update_image(img, meta or {}, step_name=step_name)
437
+ else:
438
+ # As a last resort, try attribute assignment (for custom apps)
439
+ if hasattr(self.view, "image"):
440
+ self.view.image = img
441
+ if hasattr(self.view, "metadata"):
442
+ self.view.metadata = meta or {}
443
+
444
+ # ── File prep ───────────────────────────────────────────────────────
445
+
446
+ def _ensure_user_custom_fits(self) -> str:
447
+ app_data = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
448
+ os.makedirs(app_data, exist_ok=True)
449
+ path = os.path.join(app_data, "usercustomcurves.fits")
450
+ if not os.path.exists(path):
451
+ fits.HDUList([fits.PrimaryHDU()]).writeto(path)
452
+ return path
453
+
454
+ # ── UI ──────────────────────────────────────────────────────────────
455
+
456
+ def _build_ui(self):
457
+ layout = QVBoxLayout(self)
458
+
459
+ row1 = QHBoxLayout(); layout.addLayout(row1)
460
+ self.fetch_stars_btn = QPushButton("Step 1: Fetch Stars from Current View")
461
+ f = self.fetch_stars_btn.font(); f.setBold(True); self.fetch_stars_btn.setFont(f)
462
+ self.fetch_stars_btn.clicked.connect(self.fetch_stars)
463
+ row1.addWidget(self.fetch_stars_btn)
464
+
465
+ self.open_sasp_btn = QPushButton("Open SASP Viewer")
466
+ self.open_sasp_btn.clicked.connect(self.open_sasp_viewer)
467
+ row1.addWidget(self.open_sasp_btn)
468
+
469
+ row1.addSpacing(20)
470
+ row1.addWidget(QLabel("Select White Reference:"))
471
+ self.star_combo = QComboBox()
472
+ self.star_combo.addItem("Vega (A0V)", userData="A0V")
473
+ for sed in getattr(self, "sed_list", []):
474
+ if sed.upper() == "A0V": continue
475
+ self.star_combo.addItem(sed, userData=sed)
476
+ row1.addWidget(self.star_combo)
477
+ idx_g2v = self.star_combo.findData("G2V")
478
+ if idx_g2v >= 0: self.star_combo.setCurrentIndex(idx_g2v)
479
+
480
+ row2 = QHBoxLayout(); layout.addLayout(row2)
481
+ row2.addWidget(QLabel("R Filter:"))
482
+ self.r_filter_combo = QComboBox(); self.r_filter_combo.addItem("(None)"); self.r_filter_combo.addItems(self.filter_list); row2.addWidget(self.r_filter_combo)
483
+ row2.addSpacing(20); row2.addWidget(QLabel("G Filter:"))
484
+ self.g_filter_combo = QComboBox(); self.g_filter_combo.addItem("(None)"); self.g_filter_combo.addItems(self.filter_list); row2.addWidget(self.g_filter_combo)
485
+ row2.addSpacing(20); row2.addWidget(QLabel("B Filter:"))
486
+ self.b_filter_combo = QComboBox(); self.b_filter_combo.addItem("(None)"); self.b_filter_combo.addItems(self.filter_list); row2.addWidget(self.b_filter_combo)
487
+
488
+ row3 = QHBoxLayout(); layout.addLayout(row3)
489
+ row3.addStretch()
490
+ row3.addWidget(QLabel("Sensor (QE):"))
491
+ self.sens_combo = QComboBox(); self.sens_combo.addItem("(None)"); self.sens_combo.addItems(self.sensor_list); row3.addWidget(self.sens_combo)
492
+ row3.addSpacing(20); row3.addWidget(QLabel("LP/Cut Filter1:"))
493
+ self.lp_filter_combo = QComboBox(); self.lp_filter_combo.addItem("(None)"); self.lp_filter_combo.addItems(self.filter_list); row3.addWidget(self.lp_filter_combo)
494
+ row3.addSpacing(20); row3.addWidget(QLabel("LP/Cut Filter2:"))
495
+ self.lp_filter_combo2 = QComboBox(); self.lp_filter_combo2.addItem("(None)"); self.lp_filter_combo2.addItems(self.filter_list); row3.addWidget(self.lp_filter_combo2)
496
+ row3.addStretch()
497
+
498
+ row4 = QHBoxLayout(); layout.addLayout(row4)
499
+ self.run_spcc_btn = QPushButton("Step 2: Run Color Calibration")
500
+ f2 = self.run_spcc_btn.font(); f2.setBold(True); self.run_spcc_btn.setFont(f2)
501
+ self.run_spcc_btn.clicked.connect(self.run_spcc)
502
+ row4.addWidget(self.run_spcc_btn)
503
+
504
+ self.neutralize_chk = QCheckBox("Background Neutralization"); self.neutralize_chk.setChecked(True); row4.addWidget(self.neutralize_chk)
505
+
506
+ self.run_grad_btn = QPushButton("Run Gradient Extraction (Beta)")
507
+ f3 = self.run_grad_btn.font(); f3.setBold(True); self.run_grad_btn.setFont(f3)
508
+ self.run_grad_btn.clicked.connect(self.run_gradient_extraction)
509
+ row4.addWidget(self.run_grad_btn)
510
+
511
+ self.grad_method_combo = QComboBox(); self.grad_method_combo.addItems(["poly2","poly3","rbf"]); self.grad_method_combo.setCurrentText("poly3")
512
+ row4.addWidget(self.grad_method_combo)
513
+
514
+ row4.addSpacing(15)
515
+ row4.addWidget(QLabel("Star detect σ:"))
516
+ self.sep_thr_spin = QSpinBox()
517
+ self.sep_thr_spin.setRange(2, 50) # should be enough
518
+ self.sep_thr_spin.setValue(5) # our current hardcoded value
519
+ self.sep_thr_spin.valueChanged.connect(self.save_sep_threshold_setting)
520
+ row4.addWidget(self.sep_thr_spin)
521
+
522
+ row4.addStretch()
523
+ self.add_curve_btn = QPushButton("Add Custom Filter/Sensor Curve…")
524
+ self.add_curve_btn.clicked.connect(self.add_custom_curve); row4.addWidget(self.add_curve_btn)
525
+ self.remove_curve_btn = QPushButton("Remove Filter/Sensor Curve…")
526
+ self.remove_curve_btn.clicked.connect(self.remove_custom_curve); row4.addWidget(self.remove_curve_btn)
527
+ row4.addStretch()
528
+ self.close_btn = QPushButton("Close"); self.close_btn.clicked.connect(self.close); row4.addWidget(self.close_btn)
529
+
530
+ self.count_label = QLabel(""); layout.addWidget(self.count_label)
531
+
532
+ self.figure = Figure(figsize=(6, 4)); self.canvas = FigureCanvas(self.figure); self.canvas.setVisible(False); layout.addWidget(self.canvas, stretch=1)
533
+ self.reset_btn = QPushButton("Reset View/Close"); self.reset_btn.clicked.connect(self.close); layout.addWidget(self.reset_btn)
534
+
535
+ # hide gradient controls by default (enable if you like)
536
+ self.run_grad_btn.hide(); self.grad_method_combo.hide()
537
+ layout.addWidget(self.orientation_label)
538
+
539
+ # ── Settings helpers ────────────────────────────────────────────────
540
+
541
+ def _reload_hdu_lists(self):
542
+ self.sed_list = []
543
+ with fits.open(self.sasp_data_path, mode="readonly", memmap=False) as base:
544
+ for hdu in base:
545
+ if isinstance(hdu, fits.BinTableHDU) and hdu.header.get("CTYPE","").upper()=="SED":
546
+ self.sed_list.append(hdu.header["EXTNAME"])
547
+
548
+ self.filter_list = []; self.sensor_list = []
549
+ for path in (self.sasp_data_path, self.user_custom_path):
550
+ with fits.open(path, mode="readonly", memmap=False) as hdul:
551
+ for hdu in hdul:
552
+ if not isinstance(hdu, fits.BinTableHDU): continue
553
+ c = hdu.header.get("CTYPE","").upper(); e = hdu.header.get("EXTNAME","")
554
+ if c=="FILTER": self.filter_list.append(e)
555
+ elif c=="SENSOR": self.sensor_list.append(e)
556
+ self.sed_list.sort(); self.filter_list.sort(); self.sensor_list.sort()
557
+
558
+ def load_settings(self):
559
+ s = QSettings()
560
+ def apply(cb, key):
561
+ val = s.value(key, "")
562
+ if val:
563
+ idx = cb.findText(val)
564
+ if idx != -1:
565
+ cb.setCurrentIndex(idx)
566
+
567
+ # existing stuff...
568
+ saved_star = QSettings().value("SFCC/WhiteReference", "")
569
+ if saved_star:
570
+ idx = self.star_combo.findText(saved_star)
571
+ if idx != -1:
572
+ self.star_combo.setCurrentIndex(idx)
573
+
574
+ apply(self.r_filter_combo, "SFCC/RFilter")
575
+ apply(self.g_filter_combo, "SFCC/GFilter")
576
+ apply(self.b_filter_combo, "SFCC/BFilter")
577
+ apply(self.sens_combo, "SFCC/Sensor")
578
+ apply(self.lp_filter_combo, "SFCC/LPFilter")
579
+ apply(self.lp_filter_combo2, "SFCC/LPFilter2")
580
+
581
+ # 👇 NEW: load SEP/star-detect threshold
582
+ sep_thr = int(s.value("SFCC/SEPThreshold", 5))
583
+ if hasattr(self, "sep_thr_spin"):
584
+ self.sep_thr_spin.setValue(sep_thr)
585
+ def save_sep_threshold_setting(self, v: int):
586
+ QSettings().setValue("SFCC/SEPThreshold", int(v))
587
+
588
+ def save_lp_setting(self, _): QSettings().setValue("SFCC/LPFilter", self.lp_filter_combo.currentText())
589
+ def save_lp2_setting(self, _): QSettings().setValue("SFCC/LPFilter2", self.lp_filter_combo2.currentText())
590
+ def save_star_setting(self, _): QSettings().setValue("SFCC/WhiteReference", self.star_combo.currentText())
591
+ def save_r_filter_setting(self, _): QSettings().setValue("SFCC/RFilter", self.r_filter_combo.currentText())
592
+ def save_g_filter_setting(self, _): QSettings().setValue("SFCC/GFilter", self.g_filter_combo.currentText())
593
+ def save_b_filter_setting(self, _): QSettings().setValue("SFCC/BFilter", self.b_filter_combo.currentText())
594
+ def save_sensor_setting(self, _): QSettings().setValue("SFCC/Sensor", self.sens_combo.currentText())
595
+
596
+ # ── Curve utilities ─────────────────────────────────────────────────
597
+
598
+ def interpolate_bad_points(self, wl, tr):
599
+ tr = tr.copy()
600
+ bad = (tr < 0.0) | (tr > 1.0)
601
+ good = ~bad
602
+ if not np.any(bad): return tr, np.array([], dtype=int)
603
+ if np.sum(good) < 2: raise RuntimeError("Not enough valid points to interpolate anomalies.")
604
+ tr_corr = tr.copy()
605
+ tr_corr[bad] = np.interp(wl[bad], wl[good], tr[good])
606
+ return tr_corr, np.where(bad)[0]
607
+
608
+ def smooth_curve(self, tr, window_size=5):
609
+ return medfilt(tr, kernel_size=window_size)
610
+
611
+ def get_calibration_points(self, rgb_img: np.ndarray):
612
+ print("\nClick three calibration points: BL (λmin,0), BR (λmax,0), TL (λmin,1)")
613
+ fig, ax = plt.subplots(figsize=(8, 5)); ax.imshow(rgb_img); ax.set_title("Click 3 points, then close")
614
+ pts = plt.ginput(3, timeout=-1); plt.close(fig)
615
+ if len(pts) != 3: raise RuntimeError("Need exactly three clicks for calibration.")
616
+ return pts[0], pts[1], pts[2]
617
+
618
+ def build_transforms(self, px_bl, py_bl, px_br, py_br, px_tl, py_tl, λ_min, λ_max, resp_min, resp_max):
619
+ nm_per_px = (λ_max - λ_min) / (px_br - px_bl)
620
+ resp_per_px = (resp_max - resp_min) / (py_bl - py_tl)
621
+ def px_to_λ(px): return λ_min + (px - px_bl) * nm_per_px
622
+ def py_to_resp(py): return resp_max - (py - py_tl) * resp_per_px
623
+ return px_to_λ, py_to_resp
624
+
625
+ def extract_curve(self, gray_img, λ_mapper, resp_mapper, λ_min, λ_max, threshold=50):
626
+ H, W = gray_img.shape
627
+ data = []
628
+ for px in range(W):
629
+ col = gray_img[:, px]
630
+ py_min = int(np.argmin(col)); val_min = int(col[py_min])
631
+ if val_min < threshold:
632
+ lam = λ_mapper(px)
633
+ if λ_min <= lam <= λ_max:
634
+ data.append((lam, resp_mapper(py_min)))
635
+ if not data:
636
+ raise RuntimeError("No dark pixels found; raise threshold or adjust clicks.")
637
+ df = (pd.DataFrame(data, columns=["wavelength_nm", "response"])
638
+ .sort_values("wavelength_nm").reset_index(drop=True))
639
+ df = df[(df["wavelength_nm"] >= λ_min) & (df["wavelength_nm"] <= λ_max)].copy()
640
+ if df["wavelength_nm"].iloc[0] > λ_min:
641
+ df = pd.concat([pd.DataFrame([[λ_min, 0.0]], columns=["wavelength_nm", "response"]), df], ignore_index=True)
642
+ if df["wavelength_nm"].iloc[-1] < λ_max:
643
+ df = pd.concat([df, pd.DataFrame([[λ_max, 0.0]], columns=["wavelength_nm", "response"])], ignore_index=True)
644
+ return df.sort_values("wavelength_nm").reset_index(drop=True)
645
+
646
+ def _query_name_channel(self):
647
+ name_str, ok1 = QInputDialog.getText(self, "Curve Name", "Enter curve name (EXTNAME):")
648
+ if not (ok1 and name_str.strip()): return False, None, None
649
+ extname = name_str.strip().upper().replace(" ", "_")
650
+ ch_str, ok2 = QInputDialog.getText(self, "Channel", "Enter channel (R,G,B or Q for sensor):")
651
+ if not (ok2 and ch_str.strip()): return False, None, None
652
+ return True, extname, ch_str.strip().upper()
653
+
654
+ def _append_curve_hdu(self, wl_ang, tr_final, extname, ctype, origin):
655
+ col_wl = fits.Column(name="WAVELENGTH", format="E", unit="Angstrom", array=wl_ang.astype(np.float32))
656
+ col_tr = fits.Column(name="THROUGHPUT", format="E", unit="REL", array=tr_final.astype(np.float32))
657
+ new_hdu = fits.BinTableHDU.from_columns([col_wl, col_tr])
658
+ new_hdu.header["EXTNAME"] = extname
659
+ new_hdu.header["CTYPE"] = ctype
660
+ new_hdu.header["ORIGIN"] = origin
661
+ with fits.open(self.user_custom_path, mode="update", memmap=False) as hdul:
662
+ hdul.append(new_hdu); hdul.flush()
663
+
664
+ def add_custom_curve(self):
665
+ msg = QMessageBox(self); msg.setWindowTitle("Add Custom Curve"); msg.setText("Choose how to add the curve:")
666
+ csv_btn = msg.addButton("Import CSV", QMessageBox.ButtonRole.AcceptRole)
667
+ img_btn = msg.addButton("Digitize Image", QMessageBox.ButtonRole.AcceptRole)
668
+ cancel_btn = msg.addButton(QMessageBox.StandardButton.Cancel)
669
+ msg.exec()
670
+ if msg.clickedButton() == csv_btn: self._import_curve_from_csv()
671
+ elif msg.clickedButton() == img_btn: self._digitize_curve_from_image()
672
+
673
+ def _import_curve_from_csv(self):
674
+ csv_path, _ = QFileDialog.getOpenFileName(self, "Select 2-column CSV (λ_nm, response)", "", "CSV Files (*.csv);;All Files (*)")
675
+ if not csv_path: return
676
+ try:
677
+ df = (pd.read_csv(csv_path, comment="#", header=None).iloc[:, :2].dropna())
678
+ df.columns = ["wavelength_nm","response"]
679
+ wl_nm = df["wavelength_nm"].astype(float).to_numpy(); tp = df["response"].astype(float).to_numpy()
680
+ except ValueError:
681
+ try:
682
+ df = (pd.read_csv(csv_path, comment="#", header=0).iloc[:, :2].dropna())
683
+ df.columns = ["wavelength_nm","response"]
684
+ wl_nm = df["wavelength_nm"].astype(float).to_numpy(); tp = df["response"].astype(float).to_numpy()
685
+ except Exception as e2:
686
+ QMessageBox.critical(self, "CSV Error", f"Could not read CSV:\n{e2}"); return
687
+ except Exception as e:
688
+ QMessageBox.critical(self, "CSV Error", f"Could not read CSV:\n{e}"); return
689
+
690
+ ok, extname_base, channel_val = self._query_name_channel()
691
+ if not ok: return
692
+ wl_ang = (wl_nm * 10.0).astype(np.float32); tr_final = tp.astype(np.float32)
693
+ self._append_curve_hdu(wl_ang, tr_final, extname_base, "SENSOR" if channel_val=="Q" else "FILTER", f"CSV:{os.path.basename(csv_path)}")
694
+ self._reload_hdu_lists(); self.refresh_filter_sensor_lists()
695
+ QMessageBox.information(self, "Done", f"CSV curve '{extname_base}' added.")
696
+
697
+ def _digitize_curve_from_image(self):
698
+ img_path_str, _ = QFileDialog.getOpenFileName(self, "Select Curve Image to Digitize", "", "Images (*.png *.jpg *.jpeg *.bmp);;All Files (*)")
699
+ if not img_path_str: return
700
+ img_filename = os.path.basename(img_path_str)
701
+ try:
702
+ bgr = cv2.imread(img_path_str)
703
+ if bgr is None: raise RuntimeError(f"cv2.imread returned None for '{img_path_str}'")
704
+ rgb_img = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB); gray_img = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
705
+ except Exception as e:
706
+ QMessageBox.critical(self, "Error", f"Could not load image:\n{e}"); return
707
+
708
+ try:
709
+ (px_bl, py_bl), (px_br, py_br), (px_tl, py_tl) = self.get_calibration_points(rgb_img)
710
+ except Exception as e:
711
+ QMessageBox.critical(self, "Digitization Error", str(e)); return
712
+
713
+ λ_min_str, ok1 = QInputDialog.getText(self, "λ_min", "Enter λ_min (in nm):")
714
+ λ_max_str, ok2 = QInputDialog.getText(self, "λ_max", "Enter λ_max (in nm):")
715
+ if not (ok1 and ok2 and λ_min_str.strip() and λ_max_str.strip()): return
716
+ try:
717
+ λ_min = float(λ_min_str); λ_max = float(λ_max_str)
718
+ except ValueError:
719
+ QMessageBox.critical(self, "Input Error", "λ_min and λ_max must be numbers."); return
720
+
721
+ ok, extname_base, channel_val = self._query_name_channel()
722
+ if not ok: return
723
+
724
+ px_to_λ, py_to_resp = self.build_transforms(px_bl, py_bl, px_br, py_br, px_tl, py_tl, λ_min, λ_max, 0.0, 1.0)
725
+ try:
726
+ df_curve = self.extract_curve(gray_img, px_to_λ, py_to_resp, λ_min, λ_max, threshold=50)
727
+ except Exception as e:
728
+ QMessageBox.critical(self, "Extraction Error", str(e)); return
729
+
730
+ df_curve["wl_int"] = df_curve["wavelength_nm"].round().astype(int)
731
+ grp = (df_curve.groupby("wl_int")["response"].median().reset_index().sort_values("wl_int"))
732
+ wl = grp["wl_int"].to_numpy(dtype=int); tr = grp["response"].to_numpy(dtype=float)
733
+
734
+ try:
735
+ tr_corr, _ = self.interpolate_bad_points(wl, tr)
736
+ except Exception as e:
737
+ QMessageBox.critical(self, "Interpolation Error", str(e)); return
738
+
739
+ tr_smoothed = self.smooth_curve(tr_corr, window_size=5)
740
+ wl_ang = (wl.astype(float) * 10.0).astype(np.float32); tr_final = tr_smoothed.astype(np.float32)
741
+ self._append_curve_hdu(wl_ang, tr_final, extname_base, "SENSOR" if channel_val=="Q" else "FILTER", f"UserDefined:{img_filename}")
742
+ self._reload_hdu_lists(); self.refresh_filter_sensor_lists()
743
+ QMessageBox.information(self, "Done", f"Added curve '{extname_base}'.")
744
+
745
+ def remove_custom_curve(self):
746
+ all_curves = self.filter_list + self.sensor_list
747
+ if not all_curves:
748
+ QMessageBox.information(self, "Remove Curve", "No custom curves to remove."); return
749
+ curve, ok = QInputDialog.getItem(self, "Remove Curve", "Select a FILTER or SENSOR curve to delete:", all_curves, 0, False)
750
+ if not ok or not curve: return
751
+ reply = QMessageBox.question(self, "Confirm Deletion", f"Delete '{curve}'?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
752
+ if reply != QMessageBox.StandardButton.Yes: return
753
+
754
+ temp_path = self.user_custom_path + ".tmp"
755
+ try:
756
+ with fits.open(self.user_custom_path, memmap=False) as old_hdul:
757
+ new_hdus = []
758
+ for hdu in old_hdul:
759
+ if hdu is old_hdul[0]:
760
+ new_hdus.append(hdu.copy())
761
+ else:
762
+ if hdu.header.get("EXTNAME") != curve:
763
+ new_hdus.append(hdu.copy())
764
+ fits.HDUList(new_hdus).writeto(temp_path, overwrite=True)
765
+ os.replace(temp_path, self.user_custom_path)
766
+ except Exception as e:
767
+ if os.path.exists(temp_path): os.remove(temp_path)
768
+ QMessageBox.critical(self, "Write Error", f"Could not remove curve:\n{e}"); return
769
+
770
+ self._reload_hdu_lists(); self.refresh_filter_sensor_lists()
771
+ QMessageBox.information(self, "Removed", f"Deleted curve '{curve}'.")
772
+
773
+ def refresh_filter_sensor_lists(self):
774
+ self._reload_hdu_lists()
775
+ current_r = self.r_filter_combo.currentText()
776
+ current_g = self.g_filter_combo.currentText()
777
+ current_b = self.b_filter_combo.currentText()
778
+ current_s = self.sens_combo.currentText()
779
+ current_lp = self.lp_filter_combo.currentText()
780
+ current_lp2 = self.lp_filter_combo2.currentText()
781
+
782
+ for cb, lst, prev in [
783
+ (self.r_filter_combo, self.filter_list, current_r),
784
+ (self.g_filter_combo, self.filter_list, current_g),
785
+ (self.b_filter_combo, self.filter_list, current_b),
786
+ ]:
787
+ cb.clear(); cb.addItem("(None)"); cb.addItems(lst)
788
+ idx = cb.findText(prev); cb.setCurrentIndex(idx if idx != -1 else 0)
789
+
790
+ for cb, prev in [(self.lp_filter_combo, current_lp), (self.lp_filter_combo2, current_lp2)]:
791
+ cb.clear(); cb.addItem("(None)"); cb.addItems(self.filter_list)
792
+ idx = cb.findText(prev); cb.setCurrentIndex(idx if idx != -1 else 0)
793
+
794
+ self.sens_combo.clear(); self.sens_combo.addItem("(None)"); self.sens_combo.addItems(self.sensor_list)
795
+ idx = self.sens_combo.findText(current_s); self.sens_combo.setCurrentIndex(idx if idx != -1 else 0)
796
+
797
+ # ── WCS utilities ──────────────────────────────────────────────────
798
+ def initialize_wcs_from_header(self, header):
799
+ if header is None:
800
+ print("No FITS header available; cannot build WCS.")
801
+ return
802
+ try:
803
+ hdr = header.copy()
804
+
805
+ # --- normalize deprecated keywords ---
806
+ if "RADECSYS" in hdr and "RADESYS" not in hdr:
807
+ radesys_val = str(hdr["RADECSYS"]).strip()
808
+ hdr["RADESYS"] = radesys_val
809
+ try:
810
+ del hdr["RADECSYS"]
811
+ except Exception:
812
+ pass
813
+
814
+ alt_letters = {
815
+ k[-1]
816
+ for k in hdr.keys()
817
+ if re.match(r"^CTYPE[12][A-Z]$", k)
818
+ }
819
+ for a in alt_letters:
820
+ key = f"RADESYS{a}"
821
+ if key not in hdr:
822
+ hdr[key] = radesys_val
823
+
824
+ if "EPOCH" in hdr and "EQUINOX" not in hdr:
825
+ hdr["EQUINOX"] = hdr["EPOCH"]
826
+ try:
827
+ del hdr["EPOCH"]
828
+ except Exception:
829
+ pass
830
+
831
+ # IMPORTANT: use the normalized hdr, not the original header
832
+ self.wcs = WCS(hdr, naxis=2, relax=True)
833
+
834
+ psm = self.wcs.pixel_scale_matrix
835
+ self.pixscale = np.hypot(psm[0, 0], psm[1, 0]) * 3600.0
836
+ self.center_ra, self.center_dec = self.wcs.wcs.crval
837
+ self.wcs_header = self.wcs.to_header(relax=True)
838
+
839
+ # Orientation from normalized header
840
+ if "CROTA2" in hdr:
841
+ try:
842
+ self.orientation = float(hdr["CROTA2"])
843
+ except Exception:
844
+ self.orientation = None
845
+ else:
846
+ self.orientation = self.calculate_orientation(hdr)
847
+
848
+ if self.orientation is not None:
849
+ self.orientation_label.setText(f"Orientation: {self.orientation:.2f}°")
850
+ else:
851
+ self.orientation_label.setText("Orientation: N/A")
852
+
853
+ except Exception as e:
854
+ print("WCS initialization error:\n", e)
855
+
856
+
857
+ def calculate_orientation(self, header):
858
+ try:
859
+ cd1_1 = float(header.get("CD1_1", 0.0))
860
+ cd1_2 = float(header.get("CD1_2", 0.0))
861
+ return math.degrees(math.atan2(cd1_2, cd1_1))
862
+ except Exception:
863
+ return None
864
+
865
+ def calculate_ra_dec_from_pixel(self, x, y):
866
+ if not hasattr(self, "wcs"): return None, None
867
+ return self.wcs.all_pix2world(x, y, 0)
868
+
869
+ # ── Background neutralization ───────────────────────────────────────
870
+
871
+ def _neutralize_background(self, rgb_img: np.ndarray, patch_size: int = 50) -> np.ndarray:
872
+ img = rgb_img.copy()
873
+ h, w = img.shape[:2]
874
+ ph, pw = h // patch_size, w // patch_size
875
+ min_sum, best_med = np.inf, None
876
+ for i in range(patch_size):
877
+ for j in range(patch_size):
878
+ y0, x0 = i * ph, j * pw
879
+ patch = img[y0:min(y0+ph, h), x0:min(x0+pw, w), :]
880
+ med = np.median(patch, axis=(0, 1))
881
+ s = med.sum()
882
+ if s < min_sum:
883
+ min_sum, best_med = s, med
884
+ if best_med is None:
885
+ return img
886
+ target = float(best_med.mean()); eps = 1e-8
887
+ for c in range(3):
888
+ diff = float(best_med[c] - target)
889
+ if abs(diff) < eps: continue
890
+ img[..., c] = np.clip((img[..., c] - diff) / (1.0 - diff), 0.0, 1.0)
891
+ return img
892
+
893
+ # ── SIMBAD/Star fetch ──────────────────────────────────────────────
894
+
895
+ def fetch_stars(self):
896
+ # 0) Grab current image + header from the active document
897
+ img, hdr, _meta = self._get_active_image_and_header()
898
+ self.current_image = img
899
+ self.current_header = hdr
900
+
901
+ if self.current_header is None or self.current_image is None:
902
+ QMessageBox.warning(self, "No Plate Solution",
903
+ "Please plate-solve the active document first.")
904
+ return
905
+
906
+ # Pickles templates list (once)
907
+ if not hasattr(self, "pickles_templates"):
908
+ self.pickles_templates = []
909
+ for p in (self.user_custom_path, self.sasp_data_path):
910
+ try:
911
+ with fits.open(p) as hd:
912
+ for hdu in hd:
913
+ if (isinstance(hdu, fits.BinTableHDU)
914
+ and hdu.header.get("CTYPE", "").upper() == "SED"):
915
+ extname = hdu.header.get("EXTNAME", None)
916
+ if extname and extname not in self.pickles_templates:
917
+ self.pickles_templates.append(extname)
918
+ except Exception as e:
919
+ print(f"[fetch_stars] Could not load Pickles templates from {p}: {e}")
920
+
921
+ # Build WCS
922
+ try:
923
+ self.initialize_wcs_from_header(self.current_header)
924
+ except Exception:
925
+ QMessageBox.critical(self, "WCS Error", "Could not build a 2D WCS from header."); return
926
+
927
+ H, W = self.current_image.shape[:2]
928
+ pix = np.array([[W/2, H/2], [0,0], [W,0], [0,H], [W,H]])
929
+ try:
930
+ sky = self.wcs.all_pix2world(pix, 0)
931
+ except Exception as e:
932
+ QMessageBox.critical(self, "WCS Conversion Error", str(e)); return
933
+ center_sky = SkyCoord(ra=sky[0,0]*u.deg, dec=sky[0,1]*u.deg, frame="icrs")
934
+ corners_sky = SkyCoord(ra=sky[1:,0]*u.deg, dec=sky[1:,1]*u.deg, frame="icrs")
935
+ radius_deg = center_sky.separation(corners_sky).max().deg
936
+
937
+ # Simbad fields
938
+ Simbad.reset_votable_fields()
939
+ for attempt in range(1, 6):
940
+ try:
941
+ Simbad.add_votable_fields('sp', 'flux(B)', 'flux(V)', 'flux(R)')
942
+ break
943
+ except Exception:
944
+ QApplication.processEvents()
945
+ time.sleep(1.2)
946
+ Simbad.ROW_LIMIT = 10000
947
+
948
+ for attempt in range(1, 6):
949
+ try:
950
+ result = Simbad.query_region(center_sky, radius=radius_deg * u.deg)
951
+ break
952
+ except Exception as e:
953
+ self.count_label.setText(f"Attempt {attempt}/5 to query SIMBAD…")
954
+ QApplication.processEvents(); time.sleep(1.2)
955
+ result = None
956
+ if result is None or len(result) == 0:
957
+ QMessageBox.information(self, "No Stars", "SIMBAD returned zero objects in that region.")
958
+ self.star_list = []; self.star_combo.clear(); self.star_combo.addItem("Vega (A0V)", userData="A0V"); return
959
+
960
+ def infer_letter(bv):
961
+ if bv is None or (isinstance(bv, float) and np.isnan(bv)): return None
962
+ if bv < 0.00: return "B"
963
+ elif bv < 0.30: return "A"
964
+ elif bv < 0.58: return "F"
965
+ elif bv < 0.81: return "G"
966
+ elif bv < 1.40: return "K"
967
+ elif bv > 1.40: return "M"
968
+ else: return "U"
969
+
970
+ self.star_list = []; templates_for_hist = []
971
+ for row in result:
972
+ raw_sp = row['sp_type']
973
+ bmag, vmag, rmag = row['B'], row['V'], row['R']
974
+ ra_deg, dec_deg = float(row['ra']), float(row['dec'])
975
+ try:
976
+ sc = SkyCoord(ra=ra_deg*u.deg, dec=dec_deg*u.deg, frame="icrs")
977
+ except Exception:
978
+ continue
979
+
980
+ def _unmask_num(x):
981
+ try:
982
+ if x is None or np.ma.isMaskedArray(x) and np.ma.is_masked(x):
983
+ return None
984
+ return float(x)
985
+ except Exception:
986
+ return None
987
+
988
+ # inside your SIMBAD row loop:
989
+ bmag = _unmask_num(row['B'])
990
+ vmag = _unmask_num(row['V'])
991
+
992
+ sp_clean = None
993
+ if raw_sp and str(raw_sp).strip():
994
+ sp = str(raw_sp).strip().upper()
995
+ if not (sp.startswith("SN") or sp.startswith("KA")):
996
+ sp_clean = sp
997
+ elif bmag is not None and vmag is not None:
998
+ bv = bmag - vmag
999
+ sp_clean = infer_letter(bv)
1000
+ if not sp_clean: continue
1001
+
1002
+ match_list = pickles_match_for_simbad(sp_clean, self.pickles_templates)
1003
+ best_template = match_list[0] if match_list else None
1004
+ xpix, ypix = self.wcs.all_world2pix(sc.ra.deg, sc.dec.deg, 0)
1005
+ if 0 <= xpix < W and 0 <= ypix < H:
1006
+ self.star_list.append({
1007
+ "ra": sc.ra.deg, "dec": sc.dec.deg, "sp_clean": sp_clean,
1008
+ "pickles_match": best_template, "x": xpix, "y": ypix,
1009
+ "Bmag": float(bmag) if bmag else None,
1010
+ "Vmag": float(vmag) if vmag else None,
1011
+ "Rmag": float(rmag) if rmag else None,
1012
+ })
1013
+ if best_template is not None: templates_for_hist.append(best_template)
1014
+
1015
+ self.figure.clf()
1016
+ if templates_for_hist:
1017
+ uniq, cnt = np.unique(templates_for_hist, return_counts=True)
1018
+ types_str = ", ".join(uniq)
1019
+ self.count_label.setText(f"Found {len(templates_for_hist)} stars; templates: {types_str}")
1020
+ ax = self.figure.add_subplot(111)
1021
+ ax.bar(uniq, cnt, edgecolor="black")
1022
+ ax.set_xlabel("Spectral Type"); ax.set_ylabel("Count"); ax.set_title("Spectral Distribution")
1023
+ ax.tick_params(axis='x', rotation=90); ax.grid(axis="y", linestyle="--", alpha=0.3)
1024
+ self.canvas.setVisible(True); self.canvas.draw()
1025
+ else:
1026
+ self.count_label.setText("Found 0 stars with Pickles matches.")
1027
+ self.canvas.setVisible(False); self.canvas.draw()
1028
+
1029
+ # ── Core SFCC ───────────────────────────────────────────────────────
1030
+
1031
+ def run_spcc(self):
1032
+ ref_sed_name = self.star_combo.currentData()
1033
+ r_filt = self.r_filter_combo.currentText()
1034
+ g_filt = self.g_filter_combo.currentText()
1035
+ b_filt = self.b_filter_combo.currentText()
1036
+ sens_name = self.sens_combo.currentText()
1037
+ lp_filt = self.lp_filter_combo.currentText()
1038
+ lp_filt2 = self.lp_filter_combo2.currentText()
1039
+
1040
+ if not ref_sed_name:
1041
+ QMessageBox.warning(self, "Error", "Select a reference spectral type (e.g. A0V)."); return
1042
+ if r_filt == "(None)" and g_filt == "(None)" and b_filt == "(None)":
1043
+ QMessageBox.warning(self, "Error", "Pick at least one of R, G or B filters."); return
1044
+ if sens_name == "(None)":
1045
+ QMessageBox.warning(self, "Error", "Select a sensor QE curve."); return
1046
+
1047
+ # -- Step 1A: get active image as float32 in [0..1]
1048
+ doc = self.doc_manager.get_active_document()
1049
+ if doc is None or doc.image is None:
1050
+ QMessageBox.critical(self, "Error", "No active document.")
1051
+ return
1052
+
1053
+ img = doc.image
1054
+ H, W = img.shape[:2]
1055
+ if img.ndim != 3 or img.shape[2] != 3:
1056
+ QMessageBox.critical(self, "Error", "Active document must be RGB (3 channels).")
1057
+ return
1058
+
1059
+ if img.dtype == np.uint8:
1060
+ base = img.astype(np.float32) / 255.0
1061
+ else:
1062
+ base = img.astype(np.float32, copy=True)
1063
+
1064
+ # pedestal removal
1065
+ base = np.clip(base - np.min(base, axis=(0,1)), 0.0, None)
1066
+ # light neutralization
1067
+ base = self._neutralize_background(base, patch_size=10)
1068
+
1069
+ # SEP on grayscale
1070
+ gray = np.mean(base, axis=2)
1071
+
1072
+ bkg = sep.Background(gray)
1073
+ data_sub = gray - bkg.back()
1074
+ err = bkg.globalrms
1075
+
1076
+ # 👇 get user threshold (default 5.0)
1077
+ if hasattr(self, "sep_thr_spin"):
1078
+ sep_sigma = float(self.sep_thr_spin.value())
1079
+ else:
1080
+ sep_sigma = 5.0
1081
+ self.count_label.setText(f"Detecting stars (SEP σ={sep_sigma:.1f})…"); QApplication.processEvents()
1082
+ sources = sep.extract(data_sub, sep_sigma, err=err)
1083
+
1084
+ MAX_SOURCES = 300_000
1085
+ if sources.size > MAX_SOURCES:
1086
+ QMessageBox.warning(
1087
+ self,
1088
+ "Too many detections",
1089
+ f"SEP found {sources.size:,} sources with σ={sep_sigma:.1f}.\n"
1090
+ f"Increase the threshold and rerun SFCC."
1091
+ )
1092
+ return
1093
+
1094
+ if sources.size == 0:
1095
+ QMessageBox.critical(self, "SEP Error", "SEP found no sources."); return
1096
+ r_fluxrad, _ = sep.flux_radius(gray, sources["x"], sources["y"], 2.0*sources["a"], 0.5, normflux=sources["flux"], subpix=5)
1097
+ mask = (r_fluxrad > .2) & (r_fluxrad <= 10); sources = sources[mask]
1098
+ if sources.size == 0:
1099
+ QMessageBox.critical(self, "SEP Error", "All SEP detections rejected by radius filter."); return
1100
+
1101
+ if not getattr(self, "star_list", None):
1102
+ QMessageBox.warning(self, "Error", "Fetch Stars (with WCS) before running SFCC."); return
1103
+
1104
+ raw_matches = []
1105
+ for i, star in enumerate(self.star_list):
1106
+ dx = sources["x"] - star["x"]; dy = sources["y"] - star["y"]
1107
+ j = np.argmin(dx*dx + dy*dy)
1108
+ if (dx[j]**2 + dy[j]**2) < 3.0**2:
1109
+ xi, yi = int(round(sources["x"][j])), int(round(sources["y"][j]))
1110
+ if 0 <= xi < W and 0 <= yi < H:
1111
+ raw_matches.append({"sim_index": i, "template": star.get("pickles_match") or star["sp_clean"], "x_pix": xi, "y_pix": yi})
1112
+ if not raw_matches:
1113
+ QMessageBox.warning(self, "No Matches", "No SIMBAD star matched to SEP detections."); return
1114
+
1115
+ wl_min, wl_max = 3000, 11000
1116
+ wl_grid = np.arange(wl_min, wl_max+1)
1117
+
1118
+ def load_curve(ext):
1119
+ for p in (self.user_custom_path, self.sasp_data_path):
1120
+ with fits.open(p) as hd:
1121
+ if ext in hd:
1122
+ d = hd[ext].data
1123
+ wl = _ensure_angstrom(d["WAVELENGTH"].astype(float))
1124
+ tp = d["THROUGHPUT"].astype(float)
1125
+ return wl, tp
1126
+ raise KeyError(f"Curve '{ext}' not found")
1127
+
1128
+ def load_sed(ext):
1129
+ for p in (self.user_custom_path, self.sasp_data_path):
1130
+ with fits.open(p) as hd:
1131
+ if ext in hd:
1132
+ d = hd[ext].data
1133
+ wl = _ensure_angstrom(d["WAVELENGTH"].astype(float))
1134
+ fl = d["FLUX"].astype(float)
1135
+ return wl, fl
1136
+ raise KeyError(f"SED '{ext}' not found")
1137
+
1138
+ interp = lambda wl_o, tp_o: np.interp(wl_grid, wl_o, tp_o, left=0., right=0.)
1139
+ T_R = interp(*load_curve(r_filt)) if r_filt!="(None)" else np.ones_like(wl_grid)
1140
+ T_G = interp(*load_curve(g_filt)) if g_filt!="(None)" else np.ones_like(wl_grid)
1141
+ T_B = interp(*load_curve(b_filt)) if b_filt!="(None)" else np.ones_like(wl_grid)
1142
+ QE = interp(*load_curve(sens_name)) if sens_name!="(None)" else np.ones_like(wl_grid)
1143
+ LP1 = interp(*load_curve(lp_filt)) if lp_filt != "(None)" else np.ones_like(wl_grid)
1144
+ LP2 = interp(*load_curve(lp_filt2)) if lp_filt2!= "(None)" else np.ones_like(wl_grid)
1145
+ LP = LP1 * LP2
1146
+ T_sys_R, T_sys_G, T_sys_B = T_R*QE*LP, T_G*QE*LP, T_B*QE*LP
1147
+
1148
+ wl_ref, fl_ref = load_sed(ref_sed_name)
1149
+ fr_i = np.interp(wl_grid, wl_ref, fl_ref, left=0., right=0.)
1150
+ S_ref_R = np.trapezoid(fr_i * T_sys_R, x=wl_grid)
1151
+ S_ref_G = np.trapezoid(fr_i * T_sys_G, x=wl_grid)
1152
+ S_ref_B = np.trapezoid(fr_i * T_sys_B, x=wl_grid)
1153
+
1154
+ diag_meas_RG, diag_exp_RG = [], []
1155
+ diag_meas_BG, diag_exp_BG = [], []
1156
+ enriched = []
1157
+
1158
+ for m in raw_matches:
1159
+ xi, yi, sp = m["x_pix"], m["y_pix"], m["template"]
1160
+ Rm = float(base[yi, xi, 0]); Gm = float(base[yi, xi, 1]); Bm = float(base[yi, xi, 2])
1161
+ if Gm <= 0: continue
1162
+
1163
+ cands = pickles_match_for_simbad(sp, getattr(self, "pickles_templates", []))
1164
+ if not cands: continue
1165
+ wl_s, fl_s = load_sed(cands[0])
1166
+ fs_i = np.interp(wl_grid, wl_s, fl_s, left=0., right=0.)
1167
+ S_sr = np.trapezoid(fs_i * T_sys_R, x=wl_grid)
1168
+ S_sg = np.trapezoid(fs_i * T_sys_G, x=wl_grid)
1169
+ S_sb = np.trapezoid(fs_i * T_sys_B, x=wl_grid)
1170
+ if S_sg <= 0: continue
1171
+
1172
+ exp_RG = S_sr / S_sg; exp_BG = S_sb / S_sg
1173
+ meas_RG = Rm / Gm; meas_BG = Bm / Gm
1174
+
1175
+ diag_meas_RG.append(meas_RG); diag_exp_RG.append(exp_RG)
1176
+ diag_meas_BG.append(meas_BG); diag_exp_BG.append(exp_BG)
1177
+
1178
+ enriched.append({
1179
+ **m, "R_meas": Rm, "G_meas": Gm, "B_meas": Bm,
1180
+ "S_star_R": S_sr, "S_star_G": S_sg, "S_star_B": S_sb,
1181
+ "exp_RG": exp_RG, "exp_BG": exp_BG
1182
+ })
1183
+ self._last_matched = enriched # <-- missing in SASpro
1184
+ diag_meas_RG = np.array(diag_meas_RG); diag_exp_RG = np.array(diag_exp_RG)
1185
+ diag_meas_BG = np.array(diag_meas_BG); diag_exp_BG = np.array(diag_exp_BG)
1186
+ if diag_meas_RG.size == 0 or diag_meas_BG.size == 0:
1187
+ QMessageBox.information(self, "No Valid Stars", "No stars with valid measured vs expected ratios."); return
1188
+ n_stars = diag_meas_RG.size
1189
+
1190
+ def rms_frac(pred, exp): return np.sqrt(np.mean(((pred/exp) - 1.0) ** 2))
1191
+ slope_only = lambda x, m: m*x
1192
+ affine = lambda x, m, b: m*x + b
1193
+ quad = lambda x, a, b, c: a*x**2 + b*x + c
1194
+
1195
+ denR = np.sum(diag_meas_RG**2); denB = np.sum(diag_meas_BG**2)
1196
+ mR_s = (np.sum(diag_meas_RG * diag_exp_RG) / denR) if denR > 0 else 1.0
1197
+ mB_s = (np.sum(diag_meas_BG * diag_exp_BG) / denB) if denB > 0 else 1.0
1198
+ 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)
1199
+
1200
+ mR_a, bR_a = np.linalg.lstsq(np.vstack([diag_meas_RG, np.ones_like(diag_meas_RG)]).T, diag_exp_RG, rcond=None)[0]
1201
+ mB_a, bB_a = np.linalg.lstsq(np.vstack([diag_meas_BG, np.ones_like(diag_meas_BG)]).T, diag_exp_BG, rcond=None)[0]
1202
+ 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)
1203
+
1204
+ aR_q, bR_q, cR_q = np.polyfit(diag_meas_RG, diag_exp_RG, 2)
1205
+ aB_q, bB_q, cB_q = np.polyfit(diag_meas_BG, diag_exp_BG, 2)
1206
+ 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)
1207
+
1208
+ idx = np.argmin([rms_s, rms_a, rms_q])
1209
+ if idx == 0: coeff_R, coeff_B, model_choice = (0, mR_s, 0), (0, mB_s, 0), "slope-only"
1210
+ elif idx == 1: coeff_R, coeff_B, model_choice = (0, mR_a, bR_a), (0, mB_a, bB_a), "affine"
1211
+ else: coeff_R, coeff_B, model_choice = (aR_q, bR_q, cR_q), (aB_q, bB_q, cB_q), "quadratic"
1212
+
1213
+ poly = lambda c, x: c[0]*x**2 + c[1]*x + c[2]
1214
+ self.figure.clf()
1215
+ #ax1 = self.figure.add_subplot(1, 3, 1); bins=20
1216
+ #ax1.hist(diag_meas_RG, bins=bins, alpha=.65, label="meas R/G", color="firebrick", edgecolor="black")
1217
+ #ax1.hist(diag_exp_RG, bins=bins, alpha=.55, label="exp R/G", color="salmon", edgecolor="black")
1218
+ #ax1.hist(diag_meas_BG, bins=bins, alpha=.65, label="meas B/G", color="royalblue", edgecolor="black")
1219
+ #ax1.hist(diag_exp_BG, bins=bins, alpha=.55, label="exp B/G", color="lightskyblue", edgecolor="black")
1220
+ #ax1.set_xlabel("Ratio (band / G)"); ax1.set_ylabel("Count"); ax1.set_title("Measured vs expected"); ax1.legend(fontsize=7, frameon=False)
1221
+
1222
+ res0_RG = (diag_meas_RG / diag_exp_RG) - 1.0
1223
+ res0_BG = (diag_meas_BG / diag_exp_BG) - 1.0
1224
+ res1_RG = (poly(coeff_R, diag_meas_RG) / diag_exp_RG) - 1.0
1225
+ res1_BG = (poly(coeff_B, diag_meas_BG) / diag_exp_BG) - 1.0
1226
+
1227
+ ymin = np.min(np.concatenate([res0_RG, res0_BG])); ymax = np.max(np.concatenate([res0_RG, res0_BG]))
1228
+ pad = 0.05 * (ymax - ymin) if ymax > ymin else 0.02; y_lim = (ymin - pad, ymax + pad)
1229
+ def shade(ax, yvals, color):
1230
+ q1, q3 = np.percentile(yvals, [25,75]); ax.axhspan(q1, q3, color=color, alpha=.10, zorder=0)
1231
+
1232
+ ax2 = self.figure.add_subplot(1, 2, 1)
1233
+ ax2.axhline(0, color="0.65", ls="--", lw=1); shade(ax2, res0_RG, "firebrick"); shade(ax2, res0_BG, "royalblue")
1234
+ ax2.scatter(diag_exp_RG, res0_RG, c="firebrick", marker="o", alpha=.7, label="R/G residual")
1235
+ ax2.scatter(diag_exp_BG, res0_BG, c="royalblue", marker="s", alpha=.7, label="B/G residual")
1236
+ ax2.set_ylim(*y_lim); ax2.set_xlabel("Expected (band/G)"); ax2.set_ylabel("Frac residual (meas/exp − 1)")
1237
+ ax2.set_title("Residuals • BEFORE"); ax2.legend(frameon=False, fontsize=7, loc="lower right")
1238
+
1239
+ ax3 = self.figure.add_subplot(1, 2, 2)
1240
+ ax3.axhline(0, color="0.65", ls="--", lw=1); shade(ax3, res1_RG, "firebrick"); shade(ax3, res1_BG, "royalblue")
1241
+ ax3.scatter(diag_exp_RG, res1_RG, c="firebrick", marker="o", alpha=.7)
1242
+ ax3.scatter(diag_exp_BG, res1_BG, c="royalblue", marker="s", alpha=.7)
1243
+ ax3.set_ylim(*y_lim); ax3.set_xlabel("Expected (band/G)"); ax3.set_ylabel("Frac residual (corrected/exp − 1)")
1244
+ ax3.set_title("Residuals • AFTER")
1245
+ self.canvas.setVisible(True); self.figure.tight_layout(w_pad=2.); self.canvas.draw()
1246
+
1247
+ self.count_label.setText("Applying SFCC color scales to image…"); QApplication.processEvents()
1248
+ if img.dtype == np.uint8: img_float = img.astype(np.float32) / 255.0
1249
+ else: img_float = img.astype(np.float32)
1250
+
1251
+ RG = img_float[..., 0] / np.maximum(img_float[..., 1], 1e-8)
1252
+ BG = img_float[..., 2] / np.maximum(img_float[..., 1], 1e-8)
1253
+ aR, bR, cR = coeff_R; aB, bB, cB = coeff_B
1254
+ RG_corr = aR*RG**2 + bR*RG + cR
1255
+ BG_corr = aB*BG**2 + bB*BG + cB
1256
+ calibrated = img_float.copy()
1257
+ calibrated[..., 0] = RG_corr * img_float[..., 1]
1258
+ calibrated[..., 2] = BG_corr * img_float[..., 1]
1259
+ calibrated = np.clip(calibrated, 0, 1)
1260
+
1261
+ if self.neutralize_chk.isChecked():
1262
+ calibrated = self._neutralize_background(calibrated, patch_size=10)
1263
+
1264
+ if img.dtype == np.uint8:
1265
+ calibrated = (np.clip(calibrated, 0, 1) * 255.0).astype(np.uint8)
1266
+ else:
1267
+ calibrated = np.clip(calibrated, 0, 1).astype(np.float32)
1268
+
1269
+ new_meta = dict(doc.metadata or {})
1270
+ new_meta.update({
1271
+ "SFCC_applied": True,
1272
+ "SFCC_timestamp": datetime.now().isoformat(),
1273
+ "SFCC_model": model_choice,
1274
+ "SFCC_coeff_R": [float(v) for v in coeff_R],
1275
+ "SFCC_coeff_B": [float(v) for v in coeff_B],
1276
+ })
1277
+
1278
+ self.doc_manager.update_active_document(
1279
+ calibrated, metadata=new_meta, step_name="SFCC Calibrated"
1280
+ )
1281
+
1282
+ self.count_label.setText(f"Applied SFCC color calibration using {n_stars} stars")
1283
+ QApplication.processEvents()
1284
+
1285
+ def pretty(coeff): return coeff[0] + coeff[1] + coeff[2]
1286
+ ratio_R, ratio_B = pretty(coeff_R), pretty(coeff_B)
1287
+ QMessageBox.information(self, "SFCC Complete",
1288
+ f"Applied SFCC using {n_stars} stars\n"
1289
+ f"Model: {model_choice}\n"
1290
+ f"R ratio @ x=1: {ratio_R:.4f}\n"
1291
+ f"B ratio @ x=1: {ratio_B:.4f}\n"
1292
+ f"Background neutralisation: {'ON' if self.neutralize_chk.isChecked() else 'OFF'}")
1293
+
1294
+ self.current_image = calibrated # keep for gradient step
1295
+
1296
+ # ── Chromatic gradient (optional) ──────────────────────────────────
1297
+
1298
+ def run_gradient_extraction(self):
1299
+ if not getattr(self, "_last_matched", None):
1300
+ QMessageBox.warning(self, "No Star Matches", "Run colour calibration first.")
1301
+ return
1302
+
1303
+ doc = self.doc_manager.get_active_document()
1304
+ if doc is None or doc.image is None:
1305
+ QMessageBox.critical(self, "Error", "No active document.")
1306
+ return
1307
+
1308
+ img = doc.image
1309
+ if img.ndim != 3 or img.shape[2] != 3:
1310
+ QMessageBox.critical(self, "Error", "Active document must be RGB.")
1311
+ return
1312
+
1313
+ is_u8 = (img.dtype == np.uint8)
1314
+ img_f = img.astype(np.float32) / (255.0 if is_u8 else 1.0)
1315
+ H, W = img_f.shape[:2]
1316
+
1317
+ # Need star diagnostics from SPCC
1318
+ if not hasattr(self, "_last_matched") or not self._last_matched:
1319
+ QMessageBox.warning(self, "No Star Matches", "Run color calibration first."); return
1320
+
1321
+ down_fact = 4
1322
+ Hs, Ws = H // down_fact, W // down_fact
1323
+ small = cv2.resize(img_f, (Ws, Hs), interpolation=cv2.INTER_AREA)
1324
+
1325
+ pts, dRG, dBG = [], [], []
1326
+ eps, box = 1e-8, 3
1327
+ for st in self._last_matched:
1328
+ xs_full, ys_full = st["x_pix"], st["y_pix"]
1329
+ xs, ys = xs_full / down_fact, ys_full / down_fact
1330
+ xs_c, ys_c = int(round(xs)), int(round(ys))
1331
+ if not (0 <= xs_c < Ws and 0 <= ys_c < Hs): continue
1332
+ xsl = slice(max(0, xs_c-box), min(Ws, xs_c+box+1))
1333
+ ysl = slice(max(0, ys_c-box), min(Hs, ys_c+box+1))
1334
+ Rm = np.median(small[ysl, xsl, 0]); Gm = np.median(small[ysl, xsl, 1]); Bm = np.median(small[ysl, xsl, 2])
1335
+ if Gm <= 0: continue
1336
+ meas_RG = Rm / Gm; meas_BG = Bm / Gm
1337
+ exp_RG, exp_BG = st["exp_RG"], st["exp_BG"]
1338
+ if exp_RG is None or exp_BG is None: continue
1339
+ dm_RG = -2.5 * np.log10((meas_RG+eps)/(exp_RG+eps))
1340
+ dm_BG = -2.5 * np.log10((meas_BG+eps)/(exp_BG+eps))
1341
+ pts.append([xs, ys]); dRG.append(dm_RG); dBG.append(dm_BG)
1342
+
1343
+ pts = np.asarray(pts); dRG = np.asarray(dRG); dBG = np.asarray(dBG)
1344
+ if pts.shape[0] < 5:
1345
+ QMessageBox.warning(self, "Too Few Stars", "Need ≥5 stars after clipping."); return
1346
+
1347
+ def sclip(arr, p, s=2.5):
1348
+ m, sd = np.median(arr), np.std(arr); keep = np.abs(arr-m) < s*sd
1349
+ return p[keep], arr[keep]
1350
+
1351
+ ptsRG, dRG = sclip(dRG, pts); ptsBG, dBG = sclip(dBG, pts)
1352
+
1353
+ mode = getattr(self, "grad_method", "poly2")
1354
+ bgRG_s = compute_gradient_map(ptsRG, dRG, (Hs, Ws), method=mode)
1355
+ bgBG_s = compute_gradient_map(ptsBG, dBG, (Hs, Ws), method=mode)
1356
+
1357
+ for bg in (bgRG_s, bgBG_s):
1358
+ bg -= np.median(bg)
1359
+ peak = np.max(np.abs(bg))
1360
+ if peak > 0.2: bg *= 0.2/peak
1361
+
1362
+ bgRG = cv2.resize(bgRG_s, (W, H), interpolation=cv2.INTER_CUBIC)
1363
+ bgBG = cv2.resize(bgBG_s, (W, H), interpolation=cv2.INTER_CUBIC)
1364
+
1365
+ scale_R = 10**(-0.4*bgRG); scale_B = 10**(-0.4*bgBG)
1366
+
1367
+ self.figure.clf()
1368
+ for i,(surf,lbl) in enumerate(((bgRG,"Δm R/G"),(bgBG,"Δm B/G"))):
1369
+ ax = self.figure.add_subplot(1,2,i+1)
1370
+ im = ax.imshow(surf, origin="lower", cmap="RdBu")
1371
+ ax.set_title(lbl); self.figure.colorbar(im, ax=ax)
1372
+ self.canvas.setVisible(True); self.figure.tight_layout(); self.canvas.draw()
1373
+
1374
+ corrected = img_f.copy()
1375
+ corrected[...,0] = np.clip(corrected[...,0] / scale_R, 0, 1.0)
1376
+ corrected[...,2] = np.clip(corrected[...,2] / scale_B, 0, 1.0)
1377
+ corrected = np.clip(corrected, 0, 1)
1378
+ if is_u8:
1379
+ corrected = (corrected * 255.0).astype(np.uint8)
1380
+ else:
1381
+ corrected = corrected.astype(np.float32)
1382
+
1383
+ new_meta = dict(doc.metadata or {})
1384
+ new_meta["ColourGradRemoved"] = True
1385
+
1386
+ self.doc_manager.update_active_document(
1387
+ corrected, metadata=new_meta,
1388
+ step_name="Colour-Gradient (star spectra, ¼-res fit)"
1389
+ )
1390
+ self.count_label.setText("Chromatic gradient removed ✓")
1391
+ QApplication.processEvents()
1392
+
1393
+ # ── Viewer, close ──────────────────────────────────────────────────
1394
+
1395
+ def open_sasp_viewer(self):
1396
+ if self.sasp_viewer_window is not None:
1397
+ if self.sasp_viewer_window.isVisible():
1398
+ self.sasp_viewer_window.raise_()
1399
+ else:
1400
+ self.sasp_viewer_window.show()
1401
+ return
1402
+
1403
+ self.sasp_viewer_window = SaspViewer(
1404
+ sasp_data_path=self.sasp_data_path,
1405
+ user_custom_path=self.user_custom_path
1406
+ )
1407
+ self.sasp_viewer_window.show()
1408
+ self.sasp_viewer_window.destroyed.connect(self._on_sasp_closed)
1409
+
1410
+ def _on_sasp_closed(self, _=None):
1411
+ # Called when the SaspViewer window is destroyed
1412
+ self.sasp_viewer_window = None
1413
+
1414
+ def closeEvent(self, event):
1415
+ super().closeEvent(event)
1416
+
1417
+
1418
+ # ──────────────────────────────────────────────────────────────────────────────
1419
+ # Helper to open the dialog from your app
1420
+ # ──────────────────────────────────────────────────────────────────────────────
1421
+
1422
+ def open_sfcc(doc_manager, sasp_data_path: str, parent=None) -> SFCCDialog:
1423
+ dlg = SFCCDialog(doc_manager=doc_manager, sasp_data_path=sasp_data_path, parent=parent)
1424
+ dlg.show()
1425
+ return dlg