setiastrosuitepro 1.8.2__py3-none-any.whl → 1.8.3__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/magnitude.png +0 -0
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/backgroundneutral.py +54 -16
- setiastro/saspro/finder_chart.py +20 -9
- setiastro/saspro/gui/main_window.py +96 -4
- setiastro/saspro/gui/mixins/header_mixin.py +40 -15
- setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +8 -1
- setiastro/saspro/imageops/stretch.py +1 -1
- setiastro/saspro/magnitude_tool.py +1724 -0
- setiastro/saspro/remove_stars.py +13 -30
- setiastro/saspro/resources.py +2 -0
- setiastro/saspro/runtime_torch.py +20 -0
- setiastro/saspro/sfcc.py +81 -74
- setiastro/saspro/torch_rejection.py +59 -28
- {setiastrosuitepro-1.8.2.dist-info → setiastrosuitepro-1.8.3.dist-info}/METADATA +1 -1
- {setiastrosuitepro-1.8.2.dist-info → setiastrosuitepro-1.8.3.dist-info}/RECORD +21 -19
- {setiastrosuitepro-1.8.2.dist-info → setiastrosuitepro-1.8.3.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.8.2.dist-info → setiastrosuitepro-1.8.3.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.8.2.dist-info → setiastrosuitepro-1.8.3.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.8.2.dist-info → setiastrosuitepro-1.8.3.dist-info}/licenses/license.txt +0 -0
|
@@ -0,0 +1,1724 @@
|
|
|
1
|
+
# src/setiastro/saspro/magnitude_tool.py
|
|
2
|
+
# SASpro Magnitude / Surface Brightness (mag/arcsec^2) — v0
|
|
3
|
+
#
|
|
4
|
+
# Assumptions:
|
|
5
|
+
# - Image is LINEAR (like SFCC).
|
|
6
|
+
# - Image is RGB float in [0,1] OR uint8 (we normalize). If you have other dtypes,
|
|
7
|
+
# you can extend _to_float_rgb() the same way you do elsewhere.
|
|
8
|
+
# - WCS header is available in doc.metadata["original_header"] (or similar) like SFCC.
|
|
9
|
+
#
|
|
10
|
+
# Strategy:
|
|
11
|
+
# 1) Reuse SFCC’s SIMBAD star_list pipeline (Fetch Stars) for catalog B/V/R mags + pixel coords.
|
|
12
|
+
# 2) Use SEP to detect stars (for centroid-ish matching and optional radius sanity).
|
|
13
|
+
# 3) Do aperture photometry (per channel) on the ORIGINAL linear image (not clamped/pedestal-removed).
|
|
14
|
+
# 4) Compute per-channel photometric zero point:
|
|
15
|
+
# ZP = m_cat + 2.5 log10( flux / exptime )
|
|
16
|
+
# (no gain required; ZP is in “mag per ADU/sec (or per normalized unit/sec)”)
|
|
17
|
+
# 5) For a user region (object rect + background rect), compute:
|
|
18
|
+
# - integrated magnitude per channel
|
|
19
|
+
# - surface brightness mag/arcsec^2 per channel (if pixscale known)
|
|
20
|
+
#
|
|
21
|
+
# Notes:
|
|
22
|
+
# - This is intentionally “initial version”: no color-term modeling. You can add a color term later.
|
|
23
|
+
# - Robustness: sigma-clip the ZP list per channel.
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import math
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Optional, Dict, Any, List, Tuple
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
import numpy.ma as ma
|
|
33
|
+
|
|
34
|
+
import sep
|
|
35
|
+
|
|
36
|
+
from astropy.io import fits
|
|
37
|
+
from astropy.wcs import WCS
|
|
38
|
+
|
|
39
|
+
from PyQt6.QtCore import Qt, QRect, QEvent, QPointF, QRectF, QTimer
|
|
40
|
+
from PyQt6.QtWidgets import (
|
|
41
|
+
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
|
42
|
+
QSpinBox, QDoubleSpinBox, QGroupBox, QFormLayout,QComboBox, QGraphicsPathItem, QGraphicsEllipseItem,
|
|
43
|
+
QMessageBox, QApplication, QGraphicsView, QGraphicsScene, QGraphicsRectItem, QGraphicsItem
|
|
44
|
+
)
|
|
45
|
+
from PyQt6.QtGui import QImage, QPixmap, QPen, QColor, QPainter, QPainterPath
|
|
46
|
+
from astropy.coordinates import SkyCoord
|
|
47
|
+
import astropy.units as u
|
|
48
|
+
from astroquery.simbad import Simbad
|
|
49
|
+
from astropy.wcs.wcs import NoConvergence
|
|
50
|
+
|
|
51
|
+
# IMPORTANT: use the centralized one (adjust import path to where you moved it)
|
|
52
|
+
from setiastro.saspro.sfcc import pickles_match_for_simbad
|
|
53
|
+
|
|
54
|
+
# Reuse useful SFCC pieces
|
|
55
|
+
from setiastro.saspro.sfcc import non_blocking_sleep # already used in SFCC; optional
|
|
56
|
+
from setiastro.saspro.backgroundneutral import auto_rect_box, auto_rect_50x50
|
|
57
|
+
from setiastro.saspro.imageops.stretch import stretch_color_image
|
|
58
|
+
# We *intentionally* do NOT reuse SFCC pedestal-removal/clamp for photometry.
|
|
59
|
+
|
|
60
|
+
# ---------------------------- helpers ----------------------------
|
|
61
|
+
|
|
62
|
+
def _unmask_num(x):
|
|
63
|
+
try:
|
|
64
|
+
if x is None:
|
|
65
|
+
return None
|
|
66
|
+
if ma.isMaskedArray(x) and ma.is_masked(x):
|
|
67
|
+
return None
|
|
68
|
+
return float(x)
|
|
69
|
+
except Exception:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _to_float_image(img: np.ndarray) -> np.ndarray:
|
|
74
|
+
a = np.asarray(img)
|
|
75
|
+
if a.ndim == 2:
|
|
76
|
+
# mono
|
|
77
|
+
if a.dtype == np.uint8:
|
|
78
|
+
return (a.astype(np.float32) / 255.0).astype(np.float32, copy=False)
|
|
79
|
+
return a.astype(np.float32, copy=False)
|
|
80
|
+
|
|
81
|
+
if a.ndim == 3:
|
|
82
|
+
if a.shape[2] == 1:
|
|
83
|
+
return _to_float_image(a[..., 0])
|
|
84
|
+
if a.shape[2] >= 3:
|
|
85
|
+
a = a[..., :3]
|
|
86
|
+
if a.dtype == np.uint8:
|
|
87
|
+
return (a.astype(np.float32) / 255.0).astype(np.float32, copy=False)
|
|
88
|
+
return a.astype(np.float32, copy=False)
|
|
89
|
+
|
|
90
|
+
raise ValueError("Unsupported image shape for magnitude tool.")
|
|
91
|
+
|
|
92
|
+
def _mask_sum(img_f: np.ndarray, mask: np.ndarray):
|
|
93
|
+
m = mask.astype(bool)
|
|
94
|
+
if img_f.ndim == 2:
|
|
95
|
+
return float(np.sum(img_f[m], dtype=np.float64))
|
|
96
|
+
else:
|
|
97
|
+
v = img_f[..., :3][m]
|
|
98
|
+
return v.reshape(-1, 3).sum(axis=0).astype(np.float64)
|
|
99
|
+
|
|
100
|
+
def _mask_area(mask: np.ndarray) -> int:
|
|
101
|
+
return int(np.count_nonzero(mask))
|
|
102
|
+
|
|
103
|
+
def _build_wcs_and_pixscale(header) -> Tuple[Optional[WCS], Optional[float]]:
|
|
104
|
+
if header is None:
|
|
105
|
+
return None, None
|
|
106
|
+
try:
|
|
107
|
+
hdr = header.copy()
|
|
108
|
+
except Exception:
|
|
109
|
+
try:
|
|
110
|
+
hdr = fits.Header(header)
|
|
111
|
+
except Exception:
|
|
112
|
+
return None, None
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
wcs = WCS(hdr, naxis=2, relax=True)
|
|
116
|
+
except Exception:
|
|
117
|
+
return None, None
|
|
118
|
+
|
|
119
|
+
pixscale = None
|
|
120
|
+
try:
|
|
121
|
+
wcs2 = wcs.celestial if hasattr(wcs, "celestial") else wcs
|
|
122
|
+
psm = wcs2.pixel_scale_matrix
|
|
123
|
+
pixscale = float(np.hypot(psm[0, 0], psm[1, 0]) * 3600.0) # arcsec/px
|
|
124
|
+
except Exception:
|
|
125
|
+
pixscale = None
|
|
126
|
+
|
|
127
|
+
return wcs, pixscale
|
|
128
|
+
|
|
129
|
+
def _rect_pixels(img_f: np.ndarray, rect: QRect) -> np.ndarray:
|
|
130
|
+
H, W = img_f.shape[:2]
|
|
131
|
+
x0 = max(0, rect.left()); y0 = max(0, rect.top())
|
|
132
|
+
x1 = min(W, rect.right() + 1); y1 = min(H, rect.bottom() + 1)
|
|
133
|
+
if x1 <= x0 or y1 <= y0:
|
|
134
|
+
return np.array([], dtype=np.float32)
|
|
135
|
+
patch = img_f[y0:y1, x0:x1]
|
|
136
|
+
return patch.reshape(-1, 3) if (img_f.ndim == 3) else patch.reshape(-1)
|
|
137
|
+
|
|
138
|
+
def _bg_stats(img_f: np.ndarray, bg_rect: QRect):
|
|
139
|
+
p = _rect_pixels(img_f, bg_rect)
|
|
140
|
+
if p.size == 0:
|
|
141
|
+
return None
|
|
142
|
+
# robust: median + MAD->sigma
|
|
143
|
+
if img_f.ndim == 2:
|
|
144
|
+
med = float(np.median(p))
|
|
145
|
+
mad = float(np.median(np.abs(p - med)))
|
|
146
|
+
sigma = 1.4826 * mad
|
|
147
|
+
mean = float(np.mean(p))
|
|
148
|
+
return {"mean": mean, "median": med, "sigma": sigma}
|
|
149
|
+
else:
|
|
150
|
+
med = np.median(p, axis=0)
|
|
151
|
+
mad = np.median(np.abs(p - med), axis=0)
|
|
152
|
+
sigma = 1.4826 * mad
|
|
153
|
+
mean = np.mean(p, axis=0)
|
|
154
|
+
return {"mean": mean.astype(float), "median": med.astype(float), "sigma": sigma.astype(float)}
|
|
155
|
+
|
|
156
|
+
_LOGC = 2.5 / math.log(10.0) # 1.085736...
|
|
157
|
+
|
|
158
|
+
def _mag_err_from_flux(flux: float, flux_err: float, zp_err: float) -> Optional[float]:
|
|
159
|
+
if not (flux > 0) or flux_err is None or zp_err is None:
|
|
160
|
+
return None
|
|
161
|
+
# clamp
|
|
162
|
+
flux_err = max(0.0, float(flux_err))
|
|
163
|
+
return float(math.sqrt(zp_err*zp_err + (_LOGC * (flux_err / float(flux)))**2))
|
|
164
|
+
|
|
165
|
+
def _mu_err_from_flux(flux: float, flux_err: float, zp_err: float) -> Optional[float]:
|
|
166
|
+
# same functional form as magnitude; dividing by area doesn't change relative error in flux
|
|
167
|
+
return _mag_err_from_flux(flux, flux_err, zp_err)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _sigma_clip(vals: np.ndarray, sigma: float = 2.5, iters: int = 3) -> np.ndarray:
|
|
171
|
+
v = np.asarray(vals, dtype=np.float64)
|
|
172
|
+
v = v[np.isfinite(v)]
|
|
173
|
+
if v.size < 3:
|
|
174
|
+
return v
|
|
175
|
+
for _ in range(max(1, int(iters))):
|
|
176
|
+
med = np.median(v)
|
|
177
|
+
sd = np.std(v)
|
|
178
|
+
if not np.isfinite(sd) or sd <= 0:
|
|
179
|
+
break
|
|
180
|
+
keep = np.abs(v - med) <= sigma * sd
|
|
181
|
+
if keep.sum() == v.size:
|
|
182
|
+
break
|
|
183
|
+
v = v[keep]
|
|
184
|
+
if v.size < 3:
|
|
185
|
+
break
|
|
186
|
+
return v
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _detect_sources(gray: np.ndarray, sigma: float = 5.0) -> np.ndarray:
|
|
190
|
+
gray = np.asarray(gray, dtype=np.float32)
|
|
191
|
+
bkg = sep.Background(gray)
|
|
192
|
+
data_sub = gray - bkg.back()
|
|
193
|
+
err = float(bkg.globalrms)
|
|
194
|
+
sources = sep.extract(data_sub, float(sigma), err=err)
|
|
195
|
+
return sources
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _match_starlist_to_sources(star_list: List[dict], sources: np.ndarray, max_dist_px: float = 3.0) -> List[dict]:
|
|
199
|
+
if sources is None or sources.size == 0:
|
|
200
|
+
return []
|
|
201
|
+
sx = sources["x"].astype(np.float64)
|
|
202
|
+
sy = sources["y"].astype(np.float64)
|
|
203
|
+
|
|
204
|
+
out = []
|
|
205
|
+
r2max = float(max_dist_px) ** 2
|
|
206
|
+
|
|
207
|
+
for st in star_list:
|
|
208
|
+
x0 = float(st.get("x", np.nan))
|
|
209
|
+
y0 = float(st.get("y", np.nan))
|
|
210
|
+
if not (np.isfinite(x0) and np.isfinite(y0)):
|
|
211
|
+
continue
|
|
212
|
+
dx = sx - x0
|
|
213
|
+
dy = sy - y0
|
|
214
|
+
j = int(np.argmin(dx * dx + dy * dy))
|
|
215
|
+
if float(dx[j] * dx[j] + dy[j] * dy[j]) <= r2max:
|
|
216
|
+
out.append({
|
|
217
|
+
"star": st,
|
|
218
|
+
"src": sources[j],
|
|
219
|
+
"x": float(sx[j]),
|
|
220
|
+
"y": float(sy[j]),
|
|
221
|
+
})
|
|
222
|
+
return out
|
|
223
|
+
|
|
224
|
+
def _aperture_photometry_mono(img_f, xs, ys, r_ap, r_in, r_out):
|
|
225
|
+
x = np.ascontiguousarray(np.asarray(xs, dtype=np.float64))
|
|
226
|
+
y = np.ascontiguousarray(np.asarray(ys, dtype=np.float64))
|
|
227
|
+
plane = np.ascontiguousarray(np.asarray(img_f, dtype=np.float32))
|
|
228
|
+
|
|
229
|
+
f_ap, _, _ = sep.sum_circle(plane, x, y, r=float(r_ap), subpix=5)
|
|
230
|
+
bkg, _, _ = sep.sum_circann(plane, x, y, float(r_in), float(r_out), subpix=5)
|
|
231
|
+
|
|
232
|
+
ap_area = np.pi * float(r_ap) * float(r_ap)
|
|
233
|
+
ann_area = np.pi * (float(r_out) * float(r_out) - float(r_in) * float(r_in))
|
|
234
|
+
bkg_per_pix = bkg / max(ann_area, 1e-8)
|
|
235
|
+
|
|
236
|
+
flux_net = (f_ap - bkg_per_pix * ap_area).astype(np.float32)
|
|
237
|
+
return flux_net
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _aperture_photometry_rgb(img_f, xs, ys, r_ap, r_in, r_out):
|
|
241
|
+
import numpy as np
|
|
242
|
+
import sep
|
|
243
|
+
|
|
244
|
+
# SEP wants float arrays; also make sure x/y are contiguous float64
|
|
245
|
+
x = np.ascontiguousarray(np.asarray(xs, dtype=np.float64))
|
|
246
|
+
y = np.ascontiguousarray(np.asarray(ys, dtype=np.float64))
|
|
247
|
+
|
|
248
|
+
# ensure the image itself is contiguous float32
|
|
249
|
+
img_f = np.ascontiguousarray(np.asarray(img_f, dtype=np.float32))
|
|
250
|
+
|
|
251
|
+
# split planes and make each plane contiguous too
|
|
252
|
+
planes = [
|
|
253
|
+
np.ascontiguousarray(img_f[..., 0]),
|
|
254
|
+
np.ascontiguousarray(img_f[..., 1]),
|
|
255
|
+
np.ascontiguousarray(img_f[..., 2]),
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
flux_net = np.zeros((len(x), 3), dtype=np.float32)
|
|
259
|
+
|
|
260
|
+
for c, plane in enumerate(planes):
|
|
261
|
+
# aperture sum
|
|
262
|
+
f_ap, f_aperr, _ = sep.sum_circle(plane, x, y, r=float(r_ap), subpix=5)
|
|
263
|
+
|
|
264
|
+
# annulus background (per-star)
|
|
265
|
+
bkg, bkgerr, _ = sep.sum_circann(plane, x, y, float(r_in), float(r_out), subpix=5)
|
|
266
|
+
|
|
267
|
+
# convert annulus sum to per-pixel background then to aperture background
|
|
268
|
+
ap_area = np.pi * float(r_ap) * float(r_ap)
|
|
269
|
+
ann_area = np.pi * (float(r_out) * float(r_out) - float(r_in) * float(r_in))
|
|
270
|
+
bkg_per_pix = bkg / max(ann_area, 1e-8)
|
|
271
|
+
|
|
272
|
+
flux_net[:, c] = (f_ap - bkg_per_pix * ap_area).astype(np.float32)
|
|
273
|
+
|
|
274
|
+
return flux_net, None
|
|
275
|
+
|
|
276
|
+
def _compute_zero_points_mono(matches, img_f, r_ap, r_in, r_out, band="L", clip_sigma=2.5):
|
|
277
|
+
xs = np.array([m["x"] for m in matches], dtype=np.float64)
|
|
278
|
+
ys = np.array([m["y"] for m in matches], dtype=np.float64)
|
|
279
|
+
|
|
280
|
+
flux_net = _aperture_photometry_mono(img_f, xs, ys, r_ap, r_in, r_out)
|
|
281
|
+
|
|
282
|
+
magkey = _magkey_for_band(band)
|
|
283
|
+
|
|
284
|
+
zps = []
|
|
285
|
+
used = 0
|
|
286
|
+
for i, m in enumerate(matches):
|
|
287
|
+
st = m["star"]
|
|
288
|
+
f = float(flux_net[i])
|
|
289
|
+
if not (f > 0):
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
mag = _unmask_num(st.get(magkey))
|
|
293
|
+
if mag is None:
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
zps.append(float(mag) + 2.5 * math.log10(f))
|
|
297
|
+
used += 1
|
|
298
|
+
|
|
299
|
+
zps = _sigma_clip(np.asarray(zps, dtype=np.float64), sigma=clip_sigma)
|
|
300
|
+
if zps.size == 0:
|
|
301
|
+
sd = float(np.std(zps))
|
|
302
|
+
sem = float(sd / math.sqrt(zps.size)) if zps.size > 1 else None
|
|
303
|
+
return {"ZP": float(np.median(zps)), "n": int(zps.size), "std": sd, "sem": sem, "band": band, "magkey": magkey, "used_matches": used}
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
"ZP": float(np.median(zps)),
|
|
307
|
+
"n": int(zps.size),
|
|
308
|
+
"std": float(np.std(zps)),
|
|
309
|
+
"band": band,
|
|
310
|
+
"magkey": magkey,
|
|
311
|
+
"used_matches": used,
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
def _compute_zero_points(
|
|
315
|
+
matches: List[dict],
|
|
316
|
+
img_f: np.ndarray,
|
|
317
|
+
r_ap: float,
|
|
318
|
+
r_in: float,
|
|
319
|
+
r_out: float,
|
|
320
|
+
clip_sigma: float = 2.5,
|
|
321
|
+
) -> Dict[str, Any]:
|
|
322
|
+
"""
|
|
323
|
+
Build per-channel ZP from catalog mags:
|
|
324
|
+
Blue -> Bmag
|
|
325
|
+
Green -> Vmag
|
|
326
|
+
Red -> Rmag
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
xs = np.array([m["x"] for m in matches], dtype=np.float64)
|
|
330
|
+
ys = np.array([m["y"] for m in matches], dtype=np.float64)
|
|
331
|
+
|
|
332
|
+
flux_net, _flux_ap = _aperture_photometry_rgb(img_f, xs, ys, r_ap, r_in, r_out)
|
|
333
|
+
|
|
334
|
+
# Collect ZP candidates
|
|
335
|
+
zp_R, zp_G, zp_B = [], [], []
|
|
336
|
+
used = 0
|
|
337
|
+
|
|
338
|
+
for i, m in enumerate(matches):
|
|
339
|
+
st = m["star"]
|
|
340
|
+
fR, fG, fB = float(flux_net[i, 0]), float(flux_net[i, 1]), float(flux_net[i, 2])
|
|
341
|
+
# reject non-positive net flux
|
|
342
|
+
if not (fR > 0 or fG > 0 or fB > 0):
|
|
343
|
+
continue
|
|
344
|
+
|
|
345
|
+
# catalog mags
|
|
346
|
+
Rmag = _unmask_num(st.get("Rmag"))
|
|
347
|
+
Vmag = _unmask_num(st.get("Vmag"))
|
|
348
|
+
Bmag = _unmask_num(st.get("Bmag"))
|
|
349
|
+
|
|
350
|
+
# NO flux/sec — use flux directly
|
|
351
|
+
if fR > 0 and Rmag is not None:
|
|
352
|
+
zp_R.append(float(Rmag) + 2.5 * math.log10(fR))
|
|
353
|
+
if fG > 0 and Vmag is not None:
|
|
354
|
+
zp_G.append(float(Vmag) + 2.5 * math.log10(fG))
|
|
355
|
+
if fB > 0 and Bmag is not None:
|
|
356
|
+
zp_B.append(float(Bmag) + 2.5 * math.log10(fB))
|
|
357
|
+
|
|
358
|
+
used += 1
|
|
359
|
+
|
|
360
|
+
zp_R = _sigma_clip(np.asarray(zp_R, dtype=np.float64), sigma=clip_sigma)
|
|
361
|
+
zp_G = _sigma_clip(np.asarray(zp_G, dtype=np.float64), sigma=clip_sigma)
|
|
362
|
+
zp_B = _sigma_clip(np.asarray(zp_B, dtype=np.float64), sigma=clip_sigma)
|
|
363
|
+
|
|
364
|
+
def summarize(arr):
|
|
365
|
+
if arr.size == 0:
|
|
366
|
+
return None, 0, None, None
|
|
367
|
+
med = float(np.median(arr))
|
|
368
|
+
sd = float(np.std(arr))
|
|
369
|
+
n = int(arr.size)
|
|
370
|
+
sem = float(sd / math.sqrt(n)) if (n > 1 and np.isfinite(sd)) else None
|
|
371
|
+
return med, n, sd, sem
|
|
372
|
+
|
|
373
|
+
ZP_R, nR, sR, semR = summarize(zp_R)
|
|
374
|
+
ZP_G, nG, sG, semG = summarize(zp_G)
|
|
375
|
+
ZP_B, nB, sB, semB = summarize(zp_B)
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
"ZP_R": ZP_R, "ZP_G": ZP_G, "ZP_B": ZP_B,
|
|
379
|
+
"n_R": nR, "n_G": nG, "n_B": nB,
|
|
380
|
+
"std_R": sR, "std_G": sG, "std_B": sB,
|
|
381
|
+
"sem_R": semR, "sem_G": semG, "sem_B": semB,
|
|
382
|
+
"used_matches": used,
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
BAND_TO_MAGKEY = {
|
|
386
|
+
"L": "Vmag",
|
|
387
|
+
"R": "Rmag",
|
|
388
|
+
"G": "Vmag",
|
|
389
|
+
"B": "Bmag",
|
|
390
|
+
}
|
|
391
|
+
def _magkey_for_band(band: str) -> str:
|
|
392
|
+
b = (band or "L").strip().upper()
|
|
393
|
+
return BAND_TO_MAGKEY.get(b, "Vmag")
|
|
394
|
+
|
|
395
|
+
def _rect_sum(img_f: np.ndarray, rect: QRect) -> np.ndarray:
|
|
396
|
+
H, W = img_f.shape[:2]
|
|
397
|
+
x0 = max(0, rect.left()); y0 = max(0, rect.top())
|
|
398
|
+
x1 = min(W, rect.right() + 1); y1 = min(H, rect.bottom() + 1)
|
|
399
|
+
if x1 <= x0 or y1 <= y0:
|
|
400
|
+
return 0.0 if img_f.ndim == 2 else np.zeros((3,), dtype=np.float64)
|
|
401
|
+
|
|
402
|
+
patch = img_f[y0:y1, x0:x1]
|
|
403
|
+
if img_f.ndim == 2:
|
|
404
|
+
return float(np.sum(patch, dtype=np.float64))
|
|
405
|
+
else:
|
|
406
|
+
patch = patch[..., :3]
|
|
407
|
+
return patch.reshape(-1, 3).sum(axis=0).astype(np.float64)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _rect_area_px(img_f: np.ndarray, rect: QRect) -> int:
|
|
411
|
+
H, W = img_f.shape[:2]
|
|
412
|
+
x0 = max(0, rect.left())
|
|
413
|
+
y0 = max(0, rect.top())
|
|
414
|
+
x1 = min(W, rect.right() + 1)
|
|
415
|
+
y1 = min(H, rect.bottom() + 1)
|
|
416
|
+
return max(0, x1 - x0) * max(0, y1 - y0)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _mag_from_flux(flux: float, zp: Optional[float]) -> Optional[float]:
|
|
420
|
+
if zp is None or not (flux > 0):
|
|
421
|
+
return None
|
|
422
|
+
return float(-2.5 * math.log10(flux) + float(zp))
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _mu_from_flux(flux: float, area_asec2: float, zp: Optional[float]) -> Optional[float]:
|
|
426
|
+
if zp is None or not (flux > 0) or not (area_asec2 > 0):
|
|
427
|
+
return None
|
|
428
|
+
return float(-2.5 * math.log10(flux / area_asec2) + float(zp))
|
|
429
|
+
|
|
430
|
+
# ---------------------------- UI dialog (initial) ----------------------------
|
|
431
|
+
class MagnitudeRegionDialog(QDialog):
|
|
432
|
+
"""
|
|
433
|
+
Pick ONE target rectangle (object). Background is auto-selected via auto_rect_50x50().
|
|
434
|
+
Preview can be toggled to ABE hard_autostretch to see dim regions on linear data.
|
|
435
|
+
"""
|
|
436
|
+
def __init__(self, parent, doc_manager, icon=None):
|
|
437
|
+
super().__init__(parent)
|
|
438
|
+
self._main = parent
|
|
439
|
+
self.doc_manager = doc_manager
|
|
440
|
+
self.doc = self.doc_manager.get_active_document()
|
|
441
|
+
self._path = QPainterPath()
|
|
442
|
+
self._path_item: QGraphicsPathItem | None = None
|
|
443
|
+
self._ellipse_item: QGraphicsEllipseItem | None = None
|
|
444
|
+
self._pen_live = QPen(QColor(0, 255, 0), 3, Qt.PenStyle.DashLine)
|
|
445
|
+
self._pen_live.setCosmetic(True)
|
|
446
|
+
self._pen_final = QPen(QColor(255, 0, 0), 3)
|
|
447
|
+
self._pen_final.setCosmetic(True)
|
|
448
|
+
if icon:
|
|
449
|
+
try: self.setWindowIcon(icon)
|
|
450
|
+
except Exception: pass
|
|
451
|
+
|
|
452
|
+
self.setWindowTitle("Magnitude Tool — Select Target Region")
|
|
453
|
+
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
454
|
+
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
455
|
+
self.setModal(False)
|
|
456
|
+
self.resize(900, 600)
|
|
457
|
+
|
|
458
|
+
self.auto_stretch = True
|
|
459
|
+
self.zoom_factor = 1.0
|
|
460
|
+
self._user_zoomed = False
|
|
461
|
+
|
|
462
|
+
self.target_rect_scene = QRectF()
|
|
463
|
+
self.target_item: QGraphicsRectItem | None = None
|
|
464
|
+
self.bg_item: QGraphicsRectItem | None = None
|
|
465
|
+
self._drawing = False
|
|
466
|
+
self._origin_scene = QPointF()
|
|
467
|
+
|
|
468
|
+
# --- scene/view ---
|
|
469
|
+
self.scene = QGraphicsScene(self)
|
|
470
|
+
self.view = QGraphicsView(self)
|
|
471
|
+
self.view.setScene(self.scene)
|
|
472
|
+
self.view.setRenderHints(QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform)
|
|
473
|
+
|
|
474
|
+
self.view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
|
475
|
+
self.view.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
|
|
476
|
+
self._zoom_debounce_ms = 70
|
|
477
|
+
self._interactive_timer = QTimer(self)
|
|
478
|
+
self._interactive_timer.setSingleShot(True)
|
|
479
|
+
self._interactive_timer.timeout.connect(self._end_interactive_present)
|
|
480
|
+
|
|
481
|
+
self._interactive_active = False
|
|
482
|
+
|
|
483
|
+
# Make interactive updates cheaper (optional but helps a lot)
|
|
484
|
+
self.view.setViewportUpdateMode(QGraphicsView.ViewportUpdateMode.MinimalViewportUpdate)
|
|
485
|
+
self.view.setOptimizationFlag(QGraphicsView.OptimizationFlag.DontSavePainterState, True)
|
|
486
|
+
self.view.setOptimizationFlag(QGraphicsView.OptimizationFlag.DontAdjustForAntialiasing, True)
|
|
487
|
+
# --- layout ---
|
|
488
|
+
v = QVBoxLayout(self)
|
|
489
|
+
self.lbl = QLabel("Draw a Target region (Box/Ellipse/Freehand). Background will be auto-selected (gold).")
|
|
490
|
+
|
|
491
|
+
self.lbl.setWordWrap(True)
|
|
492
|
+
v.addWidget(self.lbl)
|
|
493
|
+
|
|
494
|
+
self.mode_combo = QComboBox()
|
|
495
|
+
self.mode_combo.addItems(["Box", "Ellipse", "Freehand"])
|
|
496
|
+
v.insertWidget(1, self.mode_combo) # under label, above view
|
|
497
|
+
|
|
498
|
+
v.addWidget(self.view, 1)
|
|
499
|
+
|
|
500
|
+
btn_row = QHBoxLayout()
|
|
501
|
+
self.btn_apply = QPushButton("Use Target")
|
|
502
|
+
self.btn_cancel = QPushButton("Cancel")
|
|
503
|
+
self.btn_find_bg = QPushButton("Find Background")
|
|
504
|
+
self.btn_toggle = QPushButton("Disable Auto-Stretch" if self.auto_stretch else "Enable Auto-Stretch")
|
|
505
|
+
|
|
506
|
+
btn_row.addWidget(self.btn_apply)
|
|
507
|
+
btn_row.addWidget(self.btn_cancel)
|
|
508
|
+
btn_row.addWidget(self.btn_toggle)
|
|
509
|
+
btn_row.addWidget(self.btn_find_bg)
|
|
510
|
+
v.addLayout(btn_row)
|
|
511
|
+
|
|
512
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
513
|
+
|
|
514
|
+
zoom_row = QHBoxLayout()
|
|
515
|
+
self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
|
|
516
|
+
self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
|
|
517
|
+
self.btn_zoom_100 = themed_toolbtn("zoom-original", "1:1")
|
|
518
|
+
self.btn_zoom_fit = themed_toolbtn("zoom-fit-best", "Fit")
|
|
519
|
+
|
|
520
|
+
zoom_row.addWidget(self.btn_zoom_out)
|
|
521
|
+
zoom_row.addWidget(self.btn_zoom_in)
|
|
522
|
+
zoom_row.addWidget(self.btn_zoom_100)
|
|
523
|
+
zoom_row.addWidget(self.btn_zoom_fit)
|
|
524
|
+
zoom_row.addStretch(1)
|
|
525
|
+
v.addLayout(zoom_row)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# wiring
|
|
529
|
+
self.btn_cancel.clicked.connect(self.close)
|
|
530
|
+
self.btn_toggle.clicked.connect(self._toggle_autostretch)
|
|
531
|
+
self.btn_find_bg.clicked.connect(self._on_find_background)
|
|
532
|
+
self.btn_zoom_in.clicked.connect(lambda: self._zoom(1.25))
|
|
533
|
+
self.btn_zoom_out.clicked.connect(lambda: self._zoom(0.8))
|
|
534
|
+
self.btn_zoom_100.clicked.connect(self.zoom_100)
|
|
535
|
+
self.btn_zoom_fit.clicked.connect(self.fit_to_view)
|
|
536
|
+
self.btn_apply.clicked.connect(self._on_use_target)
|
|
537
|
+
|
|
538
|
+
# mouse events
|
|
539
|
+
self.view.viewport().installEventFilter(self)
|
|
540
|
+
|
|
541
|
+
# active doc tracking (optional, matches your style)
|
|
542
|
+
self._connected_current_doc_changed = False
|
|
543
|
+
if hasattr(self._main, "currentDocumentChanged"):
|
|
544
|
+
try:
|
|
545
|
+
self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
|
|
546
|
+
self._connected_current_doc_changed = True
|
|
547
|
+
except Exception:
|
|
548
|
+
self._connected_current_doc_changed = False
|
|
549
|
+
self.finished.connect(self._cleanup_connections)
|
|
550
|
+
|
|
551
|
+
self._pixmap_item = None
|
|
552
|
+
self._load_image()
|
|
553
|
+
QTimer.singleShot(0, self.fit_to_view)
|
|
554
|
+
|
|
555
|
+
# ---------- doc helpers ----------
|
|
556
|
+
def _begin_interactive_present(self):
|
|
557
|
+
"""
|
|
558
|
+
Switch to FAST transform while user is actively zooming/panning.
|
|
559
|
+
"""
|
|
560
|
+
if self._interactive_active:
|
|
561
|
+
# restart debounce
|
|
562
|
+
self._interactive_timer.start(self._zoom_debounce_ms)
|
|
563
|
+
return
|
|
564
|
+
|
|
565
|
+
self._interactive_active = True
|
|
566
|
+
|
|
567
|
+
# FAST path: disable smooth pixmap transform
|
|
568
|
+
try:
|
|
569
|
+
self.view.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False)
|
|
570
|
+
except Exception:
|
|
571
|
+
pass
|
|
572
|
+
|
|
573
|
+
# keep debounce running
|
|
574
|
+
self._interactive_timer.start(self._zoom_debounce_ms)
|
|
575
|
+
|
|
576
|
+
def _end_interactive_present(self):
|
|
577
|
+
"""
|
|
578
|
+
Restore SMOOTH transform after interaction stops, then repaint once.
|
|
579
|
+
"""
|
|
580
|
+
self._interactive_active = False
|
|
581
|
+
try:
|
|
582
|
+
self.view.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True)
|
|
583
|
+
except Exception:
|
|
584
|
+
pass
|
|
585
|
+
|
|
586
|
+
# Force a final high-quality redraw
|
|
587
|
+
try:
|
|
588
|
+
self.view.viewport().update()
|
|
589
|
+
except Exception:
|
|
590
|
+
pass
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _active_doc(self):
|
|
594
|
+
d = self.doc_manager.get_active_document()
|
|
595
|
+
return d if d is not None else self.doc
|
|
596
|
+
|
|
597
|
+
def _doc_image_float01(self) -> np.ndarray:
|
|
598
|
+
doc = self._active_doc()
|
|
599
|
+
img = getattr(doc, "image", None)
|
|
600
|
+
if img is None:
|
|
601
|
+
raise ValueError("No active image.")
|
|
602
|
+
|
|
603
|
+
img = np.asarray(img)
|
|
604
|
+
if img.size == 0:
|
|
605
|
+
raise ValueError("No active image.")
|
|
606
|
+
|
|
607
|
+
# normalize integers → [0,1]
|
|
608
|
+
if img.dtype.kind in "ui":
|
|
609
|
+
mx = float(np.iinfo(img.dtype).max)
|
|
610
|
+
img = img.astype(np.float32) / (mx if mx > 0 else 1.0)
|
|
611
|
+
else:
|
|
612
|
+
img = img.astype(np.float32, copy=False)
|
|
613
|
+
|
|
614
|
+
# ---- allow mono for ROI picking (convert to RGB for DISPLAY only) ----
|
|
615
|
+
if img.ndim == 2:
|
|
616
|
+
return np.dstack([img, img, img]).astype(np.float32, copy=False)
|
|
617
|
+
|
|
618
|
+
if img.ndim == 3:
|
|
619
|
+
if img.shape[2] == 1:
|
|
620
|
+
m = img[..., 0]
|
|
621
|
+
return np.dstack([m, m, m]).astype(np.float32, copy=False)
|
|
622
|
+
if img.shape[2] >= 3:
|
|
623
|
+
return img[..., :3].astype(np.float32, copy=False)
|
|
624
|
+
|
|
625
|
+
raise ValueError(f"Unsupported image shape for ROI picker: {img.shape}")
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _display_rgb01(self, img_rgb01: np.ndarray) -> np.ndarray:
|
|
629
|
+
# preview-only stretch
|
|
630
|
+
if not self.auto_stretch:
|
|
631
|
+
return np.clip(img_rgb01, 0.0, 1.0).astype(np.float32, copy=False)
|
|
632
|
+
|
|
633
|
+
# Use SASpro canonical stretch for preview (non-destructive; does NOT affect photometry)
|
|
634
|
+
try:
|
|
635
|
+
disp = stretch_color_image(
|
|
636
|
+
img_rgb01,
|
|
637
|
+
target_median=0.35,
|
|
638
|
+
linked=True, # better visibility for NB/odd color balance data
|
|
639
|
+
normalize=False, # keep the look stable; you can flip to True if you want punchier preview
|
|
640
|
+
apply_curves=False, # keep it “honest” and fast
|
|
641
|
+
curves_boost=0.0,
|
|
642
|
+
blackpoint_sigma=3.5, # roughly similar vibe to your old "sigma=2"
|
|
643
|
+
no_black_clip=False,
|
|
644
|
+
hdr_compress=False,
|
|
645
|
+
hdr_amount=0.0,
|
|
646
|
+
hdr_knee=0.75,
|
|
647
|
+
luma_only=False,
|
|
648
|
+
high_range=False,
|
|
649
|
+
)
|
|
650
|
+
return np.clip(disp, 0.0, 1.0).astype(np.float32, copy=False)
|
|
651
|
+
except Exception:
|
|
652
|
+
# worst case: just show clipped linear
|
|
653
|
+
return np.clip(img_rgb01, 0.0, 1.0).astype(np.float32, copy=False)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# ---------- render ----------
|
|
657
|
+
def _load_image(self):
|
|
658
|
+
self.scene.clear()
|
|
659
|
+
self.target_item = None
|
|
660
|
+
self.bg_item = None
|
|
661
|
+
self.target_rect_scene = QRectF()
|
|
662
|
+
|
|
663
|
+
try:
|
|
664
|
+
img = self._doc_image_float01()
|
|
665
|
+
except Exception as e:
|
|
666
|
+
QMessageBox.warning(self, "No Image", str(e))
|
|
667
|
+
self.close()
|
|
668
|
+
return
|
|
669
|
+
if img is None or img.size == 0:
|
|
670
|
+
QMessageBox.warning(self, "No Image", "Open an image first.")
|
|
671
|
+
self.close()
|
|
672
|
+
return
|
|
673
|
+
|
|
674
|
+
disp = self._display_rgb01(img)
|
|
675
|
+
h, w, _ = disp.shape
|
|
676
|
+
buf8 = np.ascontiguousarray((np.clip(disp, 0, 1) * 255).astype(np.uint8))
|
|
677
|
+
qimg = QImage(buf8.data, w, h, buf8.strides[0], QImage.Format.Format_RGB888)
|
|
678
|
+
pix = QPixmap.fromImage(qimg)
|
|
679
|
+
|
|
680
|
+
self._pixmap_item = self.scene.addPixmap(pix)
|
|
681
|
+
self._pixmap_item.setCacheMode(QGraphicsItem.CacheMode.DeviceCoordinateCache)
|
|
682
|
+
self._pixmap_item.setPos(0, 0)
|
|
683
|
+
self.scene.setSceneRect(0, 0, pix.width(), pix.height())
|
|
684
|
+
|
|
685
|
+
self.view.resetTransform()
|
|
686
|
+
self.view.fitInView(self._pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
|
|
687
|
+
self._user_zoomed = False
|
|
688
|
+
|
|
689
|
+
# always compute an initial background box
|
|
690
|
+
self._on_find_background()
|
|
691
|
+
|
|
692
|
+
def _toggle_autostretch(self):
|
|
693
|
+
self.auto_stretch = not self.auto_stretch
|
|
694
|
+
self.btn_toggle.setText("Disable Auto-Stretch" if self.auto_stretch else "Enable Auto-Stretch")
|
|
695
|
+
self._load_image()
|
|
696
|
+
|
|
697
|
+
def _zoom(self, factor: float):
|
|
698
|
+
self._user_zoomed = True
|
|
699
|
+
|
|
700
|
+
# start FAST interactive mode
|
|
701
|
+
self._begin_interactive_present()
|
|
702
|
+
|
|
703
|
+
cur = self.view.transform().m11()
|
|
704
|
+
new_scale = cur * factor
|
|
705
|
+
if new_scale < 0.01 or new_scale > 100.0:
|
|
706
|
+
return
|
|
707
|
+
|
|
708
|
+
self.view.scale(factor, factor)
|
|
709
|
+
|
|
710
|
+
def zoom_100(self):
|
|
711
|
+
self._user_zoomed = True
|
|
712
|
+
self.view.resetTransform()
|
|
713
|
+
self.view.scale(1.0, 1.0)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def fit_to_view(self):
|
|
717
|
+
self._user_zoomed = False
|
|
718
|
+
self.view.resetTransform()
|
|
719
|
+
if self._pixmap_item is not None:
|
|
720
|
+
self.view.fitInView(self._pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
|
|
721
|
+
|
|
722
|
+
def showEvent(self, e):
|
|
723
|
+
super().showEvent(e)
|
|
724
|
+
QTimer.singleShot(0, self.fit_to_view)
|
|
725
|
+
|
|
726
|
+
def resizeEvent(self, e):
|
|
727
|
+
super().resizeEvent(e)
|
|
728
|
+
if not self._user_zoomed:
|
|
729
|
+
self.fit_to_view()
|
|
730
|
+
|
|
731
|
+
# ---------- background auto-box ----------
|
|
732
|
+
def _on_find_background(self):
|
|
733
|
+
try:
|
|
734
|
+
img = self._doc_image_float01()
|
|
735
|
+
|
|
736
|
+
# Pull desired box size from the parent tool if available
|
|
737
|
+
box = 50
|
|
738
|
+
p = self.parent()
|
|
739
|
+
if p is not None and hasattr(p, "bg_box_size"):
|
|
740
|
+
try:
|
|
741
|
+
box = int(p.bg_box_size.value())
|
|
742
|
+
except Exception:
|
|
743
|
+
box = 50
|
|
744
|
+
|
|
745
|
+
x, y, w, h = auto_rect_box(img, box=box, margin=100)
|
|
746
|
+
# (or auto_rect_50x50(img) if you want fixed behavior)
|
|
747
|
+
except Exception as e:
|
|
748
|
+
QMessageBox.warning(self, "Background", str(e))
|
|
749
|
+
return
|
|
750
|
+
|
|
751
|
+
if self.bg_item:
|
|
752
|
+
try:
|
|
753
|
+
self.scene.removeItem(self.bg_item)
|
|
754
|
+
except Exception:
|
|
755
|
+
pass
|
|
756
|
+
|
|
757
|
+
pen = QPen(QColor(255, 215, 0), 3) # gold
|
|
758
|
+
pen.setCosmetic(True)
|
|
759
|
+
rect_scene = QRectF(float(x), float(y), float(w), float(h))
|
|
760
|
+
self.bg_item = self.scene.addRect(rect_scene, pen)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _target_mask(self) -> Optional[np.ndarray]:
|
|
764
|
+
if self._pixmap_item is None:
|
|
765
|
+
return None
|
|
766
|
+
bounds = self._pixmap_item.boundingRect()
|
|
767
|
+
W = int(bounds.width())
|
|
768
|
+
H = int(bounds.height())
|
|
769
|
+
if W <= 0 or H <= 0:
|
|
770
|
+
return None
|
|
771
|
+
|
|
772
|
+
mode = self.mode_combo.currentText()
|
|
773
|
+
|
|
774
|
+
# start with empty mask
|
|
775
|
+
mask_img = QImage(W, H, QImage.Format.Format_Grayscale8)
|
|
776
|
+
mask_img.fill(0)
|
|
777
|
+
|
|
778
|
+
p = QPainter(mask_img)
|
|
779
|
+
p.setPen(Qt.PenStyle.NoPen)
|
|
780
|
+
p.setBrush(QColor(255, 255, 255))
|
|
781
|
+
|
|
782
|
+
if mode == "Box" and not self.target_rect_scene.isNull():
|
|
783
|
+
p.drawRect(self.target_rect_scene.toRect())
|
|
784
|
+
elif mode == "Ellipse" and not self.target_rect_scene.isNull():
|
|
785
|
+
p.drawEllipse(self.target_rect_scene)
|
|
786
|
+
elif mode == "Freehand" and not self._path.isEmpty():
|
|
787
|
+
p.drawPath(self._path)
|
|
788
|
+
|
|
789
|
+
p.end()
|
|
790
|
+
|
|
791
|
+
ptr = mask_img.bits()
|
|
792
|
+
ptr.setsize(mask_img.bytesPerLine() * H)
|
|
793
|
+
arr = np.frombuffer(ptr, dtype=np.uint8).reshape(H, mask_img.bytesPerLine())
|
|
794
|
+
arr = arr[:, :W]
|
|
795
|
+
return (arr > 0)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
# ---------- target drawing ----------
|
|
799
|
+
def _clear_target_items(self):
|
|
800
|
+
for it in (self.target_item, self._ellipse_item, self._path_item):
|
|
801
|
+
if it is not None:
|
|
802
|
+
try: self.scene.removeItem(it)
|
|
803
|
+
except Exception: pass
|
|
804
|
+
self.target_item = None
|
|
805
|
+
self._ellipse_item = None
|
|
806
|
+
self._path_item = None
|
|
807
|
+
self.target_rect_scene = QRectF()
|
|
808
|
+
self._path = QPainterPath()
|
|
809
|
+
|
|
810
|
+
def eventFilter(self, source, event):
|
|
811
|
+
if source is self.view.viewport():
|
|
812
|
+
et = event.type()
|
|
813
|
+
|
|
814
|
+
if et == QEvent.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton:
|
|
815
|
+
self._drawing = True
|
|
816
|
+
self._origin_scene = self.view.mapToScene(event.pos())
|
|
817
|
+
mode = self.mode_combo.currentText()
|
|
818
|
+
|
|
819
|
+
self._clear_target_items()
|
|
820
|
+
|
|
821
|
+
if mode == "Freehand":
|
|
822
|
+
self._path = QPainterPath(self._origin_scene)
|
|
823
|
+
self._path_item = self.scene.addPath(self._path, self._pen_live)
|
|
824
|
+
|
|
825
|
+
return True
|
|
826
|
+
|
|
827
|
+
elif et == QEvent.Type.MouseMove and self._drawing:
|
|
828
|
+
cur = self.view.mapToScene(event.pos())
|
|
829
|
+
mode = self.mode_combo.currentText()
|
|
830
|
+
|
|
831
|
+
if mode == "Box":
|
|
832
|
+
self.target_rect_scene = QRectF(self._origin_scene, cur).normalized()
|
|
833
|
+
if self.target_item is None:
|
|
834
|
+
self.target_item = self.scene.addRect(self.target_rect_scene, self._pen_live)
|
|
835
|
+
else:
|
|
836
|
+
self.target_item.setRect(self.target_rect_scene)
|
|
837
|
+
|
|
838
|
+
elif mode == "Ellipse":
|
|
839
|
+
self.target_rect_scene = QRectF(self._origin_scene, cur).normalized()
|
|
840
|
+
if self._ellipse_item is None:
|
|
841
|
+
self._ellipse_item = self.scene.addEllipse(self.target_rect_scene, self._pen_live)
|
|
842
|
+
else:
|
|
843
|
+
self._ellipse_item.setRect(self.target_rect_scene)
|
|
844
|
+
|
|
845
|
+
else: # Freehand
|
|
846
|
+
self._path.lineTo(cur)
|
|
847
|
+
if self._path_item is not None:
|
|
848
|
+
self._path_item.setPath(self._path)
|
|
849
|
+
|
|
850
|
+
return True
|
|
851
|
+
|
|
852
|
+
elif et == QEvent.Type.MouseButtonRelease and event.button() == Qt.MouseButton.LeftButton and self._drawing:
|
|
853
|
+
self._drawing = False
|
|
854
|
+
cur = self.view.mapToScene(event.pos())
|
|
855
|
+
mode = self.mode_combo.currentText()
|
|
856
|
+
|
|
857
|
+
if mode in ("Box", "Ellipse"):
|
|
858
|
+
self.target_rect_scene = QRectF(self._origin_scene, cur).normalized()
|
|
859
|
+
if self.target_rect_scene.width() < 10 or self.target_rect_scene.height() < 10:
|
|
860
|
+
QMessageBox.warning(self, "Selection Too Small", "Please draw a larger target selection.")
|
|
861
|
+
self._clear_target_items()
|
|
862
|
+
return True
|
|
863
|
+
|
|
864
|
+
# finalize pen
|
|
865
|
+
if mode == "Box" and self.target_item is not None:
|
|
866
|
+
self.target_item.setPen(self._pen_final)
|
|
867
|
+
if mode == "Ellipse" and self._ellipse_item is not None:
|
|
868
|
+
self._ellipse_item.setPen(self._pen_final)
|
|
869
|
+
|
|
870
|
+
else: # Freehand
|
|
871
|
+
# close path and finalize
|
|
872
|
+
if self._path.elementCount() < 10:
|
|
873
|
+
QMessageBox.warning(self, "Selection Too Small", "Freehand selection too small.")
|
|
874
|
+
self._clear_target_items()
|
|
875
|
+
return True
|
|
876
|
+
self._path.closeSubpath()
|
|
877
|
+
if self._path_item is not None:
|
|
878
|
+
self._path_item.setPen(self._pen_final)
|
|
879
|
+
self._path_item.setPath(self._path)
|
|
880
|
+
|
|
881
|
+
return True
|
|
882
|
+
|
|
883
|
+
elif et == QEvent.Type.Wheel:
|
|
884
|
+
self._begin_interactive_present()
|
|
885
|
+
angle = event.angleDelta().y()
|
|
886
|
+
if angle == 0:
|
|
887
|
+
return True
|
|
888
|
+
self._zoom(1.25 if angle > 0 else 0.8)
|
|
889
|
+
return True
|
|
890
|
+
|
|
891
|
+
return super().eventFilter(source, event)
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _scene_rect_to_qrect(self, r: QRectF) -> QRect:
|
|
895
|
+
if r is None or r.isNull():
|
|
896
|
+
return QRect()
|
|
897
|
+
bounds = self._pixmap_item.boundingRect() if self._pixmap_item else QRectF()
|
|
898
|
+
W = int(bounds.width()); H = int(bounds.height())
|
|
899
|
+
x = int(max(0.0, min(bounds.width(), r.left())))
|
|
900
|
+
y = int(max(0.0, min(bounds.height(), r.top())))
|
|
901
|
+
w = int(max(1.0, min(bounds.width() - x, r.width())))
|
|
902
|
+
h = int(max(1.0, min(bounds.height() - y, r.height())))
|
|
903
|
+
return QRect(x, y, w, h)
|
|
904
|
+
|
|
905
|
+
def _on_use_target(self):
|
|
906
|
+
mask = self._target_mask()
|
|
907
|
+
if mask is None or int(mask.sum()) < 25:
|
|
908
|
+
QMessageBox.warning(self, "No Target", "Draw a target selection first.")
|
|
909
|
+
return
|
|
910
|
+
|
|
911
|
+
# background is whatever is drawn (gold); if missing, recompute
|
|
912
|
+
if self.bg_item is None:
|
|
913
|
+
self._on_find_background()
|
|
914
|
+
|
|
915
|
+
bgq = QRect()
|
|
916
|
+
if self.bg_item is not None:
|
|
917
|
+
bgq = self._scene_rect_to_qrect(self.bg_item.rect())
|
|
918
|
+
|
|
919
|
+
# compute a bbox for info text (optional, but useful)
|
|
920
|
+
try:
|
|
921
|
+
ys, xs = np.nonzero(mask)
|
|
922
|
+
if xs.size > 0 and ys.size > 0:
|
|
923
|
+
x0, x1 = int(xs.min()), int(xs.max())
|
|
924
|
+
y0, y1 = int(ys.min()), int(ys.max())
|
|
925
|
+
bbox = QRect(x0, y0, (x1 - x0 + 1), (y1 - y0 + 1))
|
|
926
|
+
else:
|
|
927
|
+
bbox = QRect()
|
|
928
|
+
except Exception:
|
|
929
|
+
bbox = QRect()
|
|
930
|
+
|
|
931
|
+
# push to parent MagnitudeToolDialog if it has setters
|
|
932
|
+
parent = self.parent()
|
|
933
|
+
if parent is not None:
|
|
934
|
+
if hasattr(parent, "set_object_mask"):
|
|
935
|
+
parent.set_object_mask(mask)
|
|
936
|
+
|
|
937
|
+
# (optional but recommended) also pass bg as mask if you add the setter
|
|
938
|
+
if hasattr(parent, "set_background_rect"):
|
|
939
|
+
parent.set_background_rect(bgq)
|
|
940
|
+
|
|
941
|
+
# convenience: update label if present
|
|
942
|
+
if hasattr(parent, "lbl_info"):
|
|
943
|
+
parent.lbl_info.setText(
|
|
944
|
+
f"Target set: {int(mask.sum())} px"
|
|
945
|
+
+ (f" (bbox x={bbox.x()}, y={bbox.y()}, w={bbox.width()}, h={bbox.height()})" if not bbox.isNull() else "")
|
|
946
|
+
+ "\n"
|
|
947
|
+
f"Background(auto): x={bgq.x()}, y={bgq.y()}, w={bgq.width()}, h={bgq.height()}"
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
self.close()
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _on_active_doc_changed(self, doc):
|
|
954
|
+
if doc is None or getattr(doc, "image", None) is None:
|
|
955
|
+
return
|
|
956
|
+
self.doc = doc
|
|
957
|
+
self._load_image()
|
|
958
|
+
|
|
959
|
+
def _cleanup_connections(self):
|
|
960
|
+
try:
|
|
961
|
+
if self._connected_current_doc_changed and hasattr(self._main, "currentDocumentChanged"):
|
|
962
|
+
self._main.currentDocumentChanged.disconnect(self._on_active_doc_changed)
|
|
963
|
+
except Exception:
|
|
964
|
+
pass
|
|
965
|
+
self._connected_current_doc_changed = False
|
|
966
|
+
|
|
967
|
+
def _combine_err(stat: Optional[float], sys: Optional[float]) -> Optional[float]:
|
|
968
|
+
if stat is None and sys is None:
|
|
969
|
+
return None
|
|
970
|
+
a = 0.0 if stat is None else float(stat)
|
|
971
|
+
b = 0.0 if sys is None else float(sys)
|
|
972
|
+
return float(math.sqrt(a*a + b*b))
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
class MagnitudeToolDialog(QDialog):
|
|
976
|
+
"""
|
|
977
|
+
Initial photometry/magnitude tool.
|
|
978
|
+
|
|
979
|
+
UX intent:
|
|
980
|
+
- Step 1: Fetch stars (reuses SFCC’s fetch_stars logic pattern: SIMBAD B/V/R + pixel coords)
|
|
981
|
+
- Step 2: Compute ZP from aperture photometry
|
|
982
|
+
- Step 3: Report magnitude / mag/arcsec^2 for a user-defined object/background rectangle
|
|
983
|
+
|
|
984
|
+
NOTE:
|
|
985
|
+
This v0 assumes you already have a way to define two rectangles in your main UI.
|
|
986
|
+
Wire `set_object_rect()` and `set_background_rect()` from your ROI selection tool.
|
|
987
|
+
"""
|
|
988
|
+
def __init__(self, doc_manager, parent=None):
|
|
989
|
+
super().__init__(parent)
|
|
990
|
+
self.setWindowTitle("Magnitude / Surface Brightness")
|
|
991
|
+
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
992
|
+
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
993
|
+
self.setModal(False)
|
|
994
|
+
self.setMinimumSize(520, 320)
|
|
995
|
+
self.sys_floor_mag = 0.10 # mag (typical 0.05–0.15)
|
|
996
|
+
self.object_mask = None
|
|
997
|
+
self.background_mask = None
|
|
998
|
+
self.doc_manager = doc_manager
|
|
999
|
+
|
|
1000
|
+
self.star_list: List[dict] = []
|
|
1001
|
+
self.wcs = None
|
|
1002
|
+
self.pixscale = None
|
|
1003
|
+
|
|
1004
|
+
self.object_rect = QRect()
|
|
1005
|
+
self.background_rect = QRect()
|
|
1006
|
+
|
|
1007
|
+
self.last_zp: Dict[str, Any] = {}
|
|
1008
|
+
|
|
1009
|
+
self._build_ui()
|
|
1010
|
+
self._update_band_controls()
|
|
1011
|
+
|
|
1012
|
+
def _build_ui(self):
|
|
1013
|
+
v = QVBoxLayout(self)
|
|
1014
|
+
|
|
1015
|
+
row = QHBoxLayout()
|
|
1016
|
+
self.btn_fetch = QPushButton("Step 1: Fetch SIMBAD Stars (needs WCS)")
|
|
1017
|
+
self.btn_fetch.clicked.connect(self.fetch_stars_from_active_doc)
|
|
1018
|
+
row.addWidget(self.btn_fetch)
|
|
1019
|
+
|
|
1020
|
+
self.btn_zp = QPushButton("Step 2: Compute Zero Points")
|
|
1021
|
+
self.btn_zp.clicked.connect(self.compute_zero_points)
|
|
1022
|
+
row.addWidget(self.btn_zp)
|
|
1023
|
+
v.addLayout(row)
|
|
1024
|
+
|
|
1025
|
+
self.btn_pick = QPushButton("Step 3: Pick Target Region…")
|
|
1026
|
+
self.btn_pick.clicked.connect(self.open_region_picker)
|
|
1027
|
+
v.addWidget(self.btn_pick)
|
|
1028
|
+
|
|
1029
|
+
box = QGroupBox("Photometry settings")
|
|
1030
|
+
form = QFormLayout(box)
|
|
1031
|
+
|
|
1032
|
+
# --- Band mapping (mono only) ---
|
|
1033
|
+
self.band_combo = QComboBox()
|
|
1034
|
+
self.band_combo.addItems(["L", "R", "G", "B"])
|
|
1035
|
+
self.band_combo.setCurrentText("L")
|
|
1036
|
+
form.addRow("Mono/L band", self.band_combo)
|
|
1037
|
+
|
|
1038
|
+
self.band_hint = QLabel("Mapping: L→V, R→R, G→V, B→B (SIMBAD provides B/V/R).")
|
|
1039
|
+
self.band_hint.setWordWrap(True)
|
|
1040
|
+
form.addRow("", self.band_hint)
|
|
1041
|
+
|
|
1042
|
+
self.sep_sigma = QSpinBox()
|
|
1043
|
+
self.sep_sigma.setRange(2, 50)
|
|
1044
|
+
self.sep_sigma.setValue(5)
|
|
1045
|
+
form.addRow("SEP detect σ", self.sep_sigma)
|
|
1046
|
+
|
|
1047
|
+
self.ap_r = QDoubleSpinBox()
|
|
1048
|
+
self.ap_r.setRange(1.0, 25.0)
|
|
1049
|
+
self.ap_r.setSingleStep(0.5)
|
|
1050
|
+
self.ap_r.setValue(6.0)
|
|
1051
|
+
form.addRow("Aperture radius (px)", self.ap_r)
|
|
1052
|
+
|
|
1053
|
+
self.ann_in = QDoubleSpinBox()
|
|
1054
|
+
self.ann_in.setRange(2.0, 60.0)
|
|
1055
|
+
self.ann_in.setSingleStep(0.5)
|
|
1056
|
+
self.ann_in.setValue(12.0)
|
|
1057
|
+
form.addRow("Annulus r_in (px)", self.ann_in)
|
|
1058
|
+
|
|
1059
|
+
self.ann_out = QDoubleSpinBox()
|
|
1060
|
+
self.ann_out.setRange(3.0, 80.0)
|
|
1061
|
+
self.ann_out.setSingleStep(0.5)
|
|
1062
|
+
self.ann_out.setValue(18.0)
|
|
1063
|
+
form.addRow("Annulus r_out (px)", self.ann_out)
|
|
1064
|
+
|
|
1065
|
+
self.clip_sigma = QDoubleSpinBox()
|
|
1066
|
+
self.clip_sigma.setRange(1.0, 10.0)
|
|
1067
|
+
self.clip_sigma.setSingleStep(0.5)
|
|
1068
|
+
self.clip_sigma.setValue(2.5)
|
|
1069
|
+
form.addRow("ZP sigma-clip", self.clip_sigma)
|
|
1070
|
+
|
|
1071
|
+
# --- Systematic uncertainty floor (rolled into total; popup reports totals only) ---
|
|
1072
|
+
self.sys_floor_spin = QDoubleSpinBox()
|
|
1073
|
+
self.sys_floor_spin.setRange(0.0, 1.0)
|
|
1074
|
+
self.sys_floor_spin.setDecimals(3)
|
|
1075
|
+
self.sys_floor_spin.setSingleStep(0.01)
|
|
1076
|
+
self.sys_floor_spin.setValue(float(getattr(self, "sys_floor_mag", 0.10) or 0.0))
|
|
1077
|
+
form.addRow("Systematic floor (mag)", self.sys_floor_spin)
|
|
1078
|
+
|
|
1079
|
+
self.bg_box_size = QSpinBox()
|
|
1080
|
+
self.bg_box_size.setRange(10, 300)
|
|
1081
|
+
self.bg_box_size.setSingleStep(5)
|
|
1082
|
+
self.bg_box_size.setValue(50)
|
|
1083
|
+
form.addRow("Auto background box (px)", self.bg_box_size)
|
|
1084
|
+
|
|
1085
|
+
hint = QLabel(
|
|
1086
|
+
"Popup reports total 3σ only: sqrt(stat² + sys_floor²). "
|
|
1087
|
+
"sys_floor is a conservative calibration mismatch term."
|
|
1088
|
+
)
|
|
1089
|
+
hint.setWordWrap(True)
|
|
1090
|
+
form.addRow("", hint)
|
|
1091
|
+
|
|
1092
|
+
v.addWidget(box)
|
|
1093
|
+
|
|
1094
|
+
self.lbl_info = QLabel("No stars fetched yet.")
|
|
1095
|
+
self.lbl_info.setWordWrap(True)
|
|
1096
|
+
v.addWidget(self.lbl_info)
|
|
1097
|
+
|
|
1098
|
+
row2 = QHBoxLayout()
|
|
1099
|
+
self.btn_measure = QPushButton("Step 4: Measure Object Region")
|
|
1100
|
+
self.btn_measure.clicked.connect(self.measure_object_region)
|
|
1101
|
+
row2.addWidget(self.btn_measure)
|
|
1102
|
+
|
|
1103
|
+
self.btn_close = QPushButton("Close")
|
|
1104
|
+
self.btn_close.clicked.connect(self.reject)
|
|
1105
|
+
row2.addWidget(self.btn_close)
|
|
1106
|
+
v.addLayout(row2)
|
|
1107
|
+
|
|
1108
|
+
# --- external wiring (from your ROI tool) ---
|
|
1109
|
+
def set_object_mask(self, mask: np.ndarray):
|
|
1110
|
+
self.object_mask = mask
|
|
1111
|
+
|
|
1112
|
+
def set_background_mask(self, mask: np.ndarray):
|
|
1113
|
+
self.background_mask = mask
|
|
1114
|
+
|
|
1115
|
+
def _active_is_rgb(self) -> bool:
|
|
1116
|
+
img, _hdr, _doc = self._get_active_image_and_header()
|
|
1117
|
+
if img is None:
|
|
1118
|
+
return False
|
|
1119
|
+
a = np.asarray(img)
|
|
1120
|
+
return (a.ndim == 3 and a.shape[2] >= 3)
|
|
1121
|
+
|
|
1122
|
+
def _update_band_controls(self):
|
|
1123
|
+
is_rgb = self._active_is_rgb()
|
|
1124
|
+
|
|
1125
|
+
# band selector only matters for mono
|
|
1126
|
+
self.band_combo.setEnabled(not is_rgb)
|
|
1127
|
+
|
|
1128
|
+
if is_rgb:
|
|
1129
|
+
self.band_hint.setText(
|
|
1130
|
+
"RGB image detected: fixed mapping is used: "
|
|
1131
|
+
"R→Rmag, G→Vmag, B→Bmag. (Mono/L band selector disabled.)"
|
|
1132
|
+
)
|
|
1133
|
+
else:
|
|
1134
|
+
self.band_hint.setText("Mapping: L→V, R→R, G→V, B→B (SIMBAD provides B/V/R).")
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def open_region_picker(self):
|
|
1138
|
+
dlg = MagnitudeRegionDialog(parent=self, doc_manager=self.doc_manager)
|
|
1139
|
+
dlg.show()
|
|
1140
|
+
|
|
1141
|
+
# optional: keep bg box updated when size changes
|
|
1142
|
+
try:
|
|
1143
|
+
self.bg_box_size.valueChanged.connect(lambda _v: dlg._on_find_background())
|
|
1144
|
+
except Exception:
|
|
1145
|
+
pass
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def set_object_rect(self, rect: QRect):
|
|
1149
|
+
self.object_rect = QRect(rect)
|
|
1150
|
+
|
|
1151
|
+
def set_background_rect(self, rect: QRect):
|
|
1152
|
+
self.background_rect = QRect(rect)
|
|
1153
|
+
|
|
1154
|
+
# --- internals ---
|
|
1155
|
+
def _get_active_image_and_header(self):
|
|
1156
|
+
doc = self.doc_manager.get_active_document()
|
|
1157
|
+
if doc is None:
|
|
1158
|
+
return None, None, None
|
|
1159
|
+
img = getattr(doc, "image", None)
|
|
1160
|
+
meta = getattr(doc, "metadata", {}) or {}
|
|
1161
|
+
hdr = meta.get("wcs_header") or meta.get("original_header") or meta.get("header")
|
|
1162
|
+
return img, hdr, doc
|
|
1163
|
+
|
|
1164
|
+
def fetch_stars_from_active_doc(self):
|
|
1165
|
+
self._update_band_controls()
|
|
1166
|
+
img, hdr, doc = self._get_active_image_and_header()
|
|
1167
|
+
if img is None or hdr is None:
|
|
1168
|
+
QMessageBox.warning(self, "No Data", "Active document must have an image and WCS header.")
|
|
1169
|
+
return
|
|
1170
|
+
|
|
1171
|
+
meta = getattr(doc, "metadata", {}) or {}
|
|
1172
|
+
sl = meta.get("SFCC_star_list")
|
|
1173
|
+
|
|
1174
|
+
if isinstance(sl, list) and len(sl) > 0:
|
|
1175
|
+
self.star_list = sl
|
|
1176
|
+
else:
|
|
1177
|
+
# no cached list → fetch now
|
|
1178
|
+
try:
|
|
1179
|
+
self.lbl_info.setText("No cached SFCC stars — fetching from SIMBAD…")
|
|
1180
|
+
QApplication.processEvents()
|
|
1181
|
+
self.star_list = self._fetch_simbad_stars_and_cache(img, hdr, doc)
|
|
1182
|
+
except Exception as e:
|
|
1183
|
+
QMessageBox.critical(self, "Fetch Stars Failed", str(e))
|
|
1184
|
+
return
|
|
1185
|
+
|
|
1186
|
+
# WCS / pixscale / exptime
|
|
1187
|
+
self.wcs, self.pixscale = _build_wcs_and_pixscale(hdr)
|
|
1188
|
+
|
|
1189
|
+
n = len(self.star_list)
|
|
1190
|
+
ps = f"{self.pixscale:.3f} arcsec/px" if self.pixscale else "N/A"
|
|
1191
|
+
|
|
1192
|
+
self.lbl_info.setText(f"Loaded {n} SIMBAD stars. Pixscale: {ps}")
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def compute_zero_points(self):
|
|
1196
|
+
self._update_band_controls()
|
|
1197
|
+
img, hdr, doc = self._get_active_image_and_header()
|
|
1198
|
+
if img is None or hdr is None:
|
|
1199
|
+
QMessageBox.warning(self, "No Data", "Active document must have an image and WCS header.")
|
|
1200
|
+
return
|
|
1201
|
+
if not self.star_list:
|
|
1202
|
+
QMessageBox.warning(self, "No Stars", "Fetch stars first.")
|
|
1203
|
+
return
|
|
1204
|
+
|
|
1205
|
+
img_f = _to_float_image(img)
|
|
1206
|
+
if img_f.ndim == 2:
|
|
1207
|
+
gray = img_f.astype(np.float32)
|
|
1208
|
+
else:
|
|
1209
|
+
gray = np.mean(img_f, axis=2).astype(np.float32)
|
|
1210
|
+
self.lbl_info.setText("Detecting sources with SEP…")
|
|
1211
|
+
QApplication.processEvents()
|
|
1212
|
+
sources = _detect_sources(gray, sigma=float(self.sep_sigma.value()))
|
|
1213
|
+
if sources.size == 0:
|
|
1214
|
+
QMessageBox.warning(self, "SEP", "SEP found no sources.")
|
|
1215
|
+
return
|
|
1216
|
+
|
|
1217
|
+
matches = _match_starlist_to_sources(self.star_list, sources, max_dist_px=3.0)
|
|
1218
|
+
if not matches:
|
|
1219
|
+
QMessageBox.warning(self, "No Matches", "No SIMBAD stars matched SEP detections.")
|
|
1220
|
+
return
|
|
1221
|
+
|
|
1222
|
+
self.lbl_info.setText(f"Matched {len(matches)} stars. Measuring apertures…")
|
|
1223
|
+
QApplication.processEvents()
|
|
1224
|
+
|
|
1225
|
+
band = self.band_combo.currentText().strip().upper()
|
|
1226
|
+
|
|
1227
|
+
if img_f.ndim == 2:
|
|
1228
|
+
zp = _compute_zero_points_mono(
|
|
1229
|
+
matches=matches,
|
|
1230
|
+
img_f=img_f,
|
|
1231
|
+
r_ap=float(self.ap_r.value()),
|
|
1232
|
+
r_in=float(self.ann_in.value()),
|
|
1233
|
+
r_out=float(self.ann_out.value()),
|
|
1234
|
+
band=band,
|
|
1235
|
+
clip_sigma=float(self.clip_sigma.value()),
|
|
1236
|
+
)
|
|
1237
|
+
self.last_zp = {"mode": "mono", **zp}
|
|
1238
|
+
|
|
1239
|
+
self.lbl_info.setText(
|
|
1240
|
+
"Zero point (mono):\n"
|
|
1241
|
+
f" Band={zp.get('band')} (catalog {zp.get('magkey')})\n"
|
|
1242
|
+
f" ZP={zp.get('ZP')} (n={zp.get('n')}, σ={zp.get('std')})"
|
|
1243
|
+
)
|
|
1244
|
+
else:
|
|
1245
|
+
zp = _compute_zero_points(
|
|
1246
|
+
matches=matches,
|
|
1247
|
+
img_f=img_f,
|
|
1248
|
+
r_ap=float(self.ap_r.value()),
|
|
1249
|
+
r_in=float(self.ann_in.value()),
|
|
1250
|
+
r_out=float(self.ann_out.value()),
|
|
1251
|
+
clip_sigma=float(self.clip_sigma.value()),
|
|
1252
|
+
)
|
|
1253
|
+
self.last_zp = {"mode": "rgb", **zp}
|
|
1254
|
+
|
|
1255
|
+
self.lbl_info.setText(
|
|
1256
|
+
"Zero points (median ± SEM):\n"
|
|
1257
|
+
f" ZP_R={zp['ZP_R']} (n={zp['n_R']}, scatter={zp['std_R']}, sem={zp['sem_R']})\n"
|
|
1258
|
+
f" ZP_G={zp['ZP_G']} (n={zp['n_G']}, scatter={zp['std_G']}, sem={zp['sem_G']})\n"
|
|
1259
|
+
f" ZP_B={zp['ZP_B']} (n={zp['n_B']}, scatter={zp['std_B']}, sem={zp['sem_B']})"
|
|
1260
|
+
)
|
|
1261
|
+
|
|
1262
|
+
def measure_object_region(self):
|
|
1263
|
+
img, hdr, doc = self._get_active_image_and_header()
|
|
1264
|
+
if img is None:
|
|
1265
|
+
QMessageBox.warning(self, "No Data", "No active image.")
|
|
1266
|
+
return
|
|
1267
|
+
if not self.last_zp:
|
|
1268
|
+
QMessageBox.warning(self, "No ZP", "Compute zero points first.")
|
|
1269
|
+
return
|
|
1270
|
+
|
|
1271
|
+
img_f = _to_float_image(img)
|
|
1272
|
+
H, W = img_f.shape[:2]
|
|
1273
|
+
|
|
1274
|
+
def rect_to_mask(r: QRect) -> Optional[np.ndarray]:
|
|
1275
|
+
if r is None or r.isNull():
|
|
1276
|
+
return None
|
|
1277
|
+
x0 = max(0, int(r.left()))
|
|
1278
|
+
y0 = max(0, int(r.top()))
|
|
1279
|
+
x1 = min(W, int(r.right()) + 1)
|
|
1280
|
+
y1 = min(H, int(r.bottom()) + 1)
|
|
1281
|
+
if x1 <= x0 or y1 <= y0:
|
|
1282
|
+
return None
|
|
1283
|
+
m = np.zeros((H, W), dtype=bool)
|
|
1284
|
+
m[y0:y1, x0:x1] = True
|
|
1285
|
+
return m
|
|
1286
|
+
|
|
1287
|
+
def sigma_from_mask(img_f: np.ndarray, m: np.ndarray):
|
|
1288
|
+
# robust sigma via MAD on masked pixels
|
|
1289
|
+
m = np.asarray(m, dtype=bool)
|
|
1290
|
+
if m.shape != (H, W):
|
|
1291
|
+
return None
|
|
1292
|
+
if np.count_nonzero(m) < 25:
|
|
1293
|
+
return None
|
|
1294
|
+
|
|
1295
|
+
if img_f.ndim == 2:
|
|
1296
|
+
v = img_f[m].astype(np.float64, copy=False)
|
|
1297
|
+
med = float(np.median(v))
|
|
1298
|
+
mad = float(np.median(np.abs(v - med)))
|
|
1299
|
+
sig = 1.4826 * mad
|
|
1300
|
+
return sig
|
|
1301
|
+
else:
|
|
1302
|
+
v = img_f[..., :3][m].reshape(-1, 3).astype(np.float64, copy=False)
|
|
1303
|
+
med = np.median(v, axis=0)
|
|
1304
|
+
mad = np.median(np.abs(v - med), axis=0)
|
|
1305
|
+
sig = 1.4826 * mad
|
|
1306
|
+
return sig.astype(float)
|
|
1307
|
+
|
|
1308
|
+
# ---------------- choose object mask ----------------
|
|
1309
|
+
obj_mask = self.object_mask
|
|
1310
|
+
if obj_mask is None:
|
|
1311
|
+
# fallback to rect mode if someone wired it
|
|
1312
|
+
obj_mask = rect_to_mask(self.object_rect)
|
|
1313
|
+
|
|
1314
|
+
if obj_mask is None or np.count_nonzero(obj_mask) < 25:
|
|
1315
|
+
QMessageBox.information(self, "No Regions", "Pick a target region first.")
|
|
1316
|
+
return
|
|
1317
|
+
if obj_mask.shape != (H, W):
|
|
1318
|
+
QMessageBox.warning(self, "Mask Mismatch", "Object mask size does not match the active image.")
|
|
1319
|
+
return
|
|
1320
|
+
|
|
1321
|
+
# ---------------- choose background mask ----------------
|
|
1322
|
+
bg_mask = self.background_mask
|
|
1323
|
+
|
|
1324
|
+
if bg_mask is None:
|
|
1325
|
+
# if a rect exists, use it
|
|
1326
|
+
bg_mask = rect_to_mask(self.background_rect)
|
|
1327
|
+
|
|
1328
|
+
if bg_mask is None:
|
|
1329
|
+
# auto-pick a background rect, then convert to mask
|
|
1330
|
+
try:
|
|
1331
|
+
img_f0 = img_f
|
|
1332
|
+
if img_f0.ndim == 2:
|
|
1333
|
+
img_f0 = np.dstack([img_f0] * 3)
|
|
1334
|
+
box = int(self.bg_box_size.value()) if hasattr(self, "bg_box_size") else 50
|
|
1335
|
+
|
|
1336
|
+
bx, by, bw, bh = auto_rect_box(img_f0, box=box, margin=100)
|
|
1337
|
+
self.background_rect = QRect(int(bx), int(by), int(bw), int(bh))
|
|
1338
|
+
bg_mask = rect_to_mask(self.background_rect)
|
|
1339
|
+
except Exception:
|
|
1340
|
+
bg_mask = None
|
|
1341
|
+
|
|
1342
|
+
if bg_mask is None or np.count_nonzero(bg_mask) < 25:
|
|
1343
|
+
QMessageBox.warning(self, "Background", "Background region is missing or too small.")
|
|
1344
|
+
return
|
|
1345
|
+
if bg_mask.shape != (H, W):
|
|
1346
|
+
QMessageBox.warning(self, "Mask Mismatch", "Background mask size does not match the active image.")
|
|
1347
|
+
return
|
|
1348
|
+
|
|
1349
|
+
# ---------------- compute sums/areas via masks ----------------
|
|
1350
|
+
obj_sum = _mask_sum(img_f, obj_mask)
|
|
1351
|
+
bkg_sum = _mask_sum(img_f, bg_mask)
|
|
1352
|
+
|
|
1353
|
+
obj_area = _mask_area(obj_mask)
|
|
1354
|
+
bkg_area = _mask_area(bg_mask)
|
|
1355
|
+
if obj_area <= 0 or bkg_area <= 0:
|
|
1356
|
+
QMessageBox.warning(self, "Bad Regions", "Object or background region has zero area.")
|
|
1357
|
+
return
|
|
1358
|
+
|
|
1359
|
+
# background scaled to object area
|
|
1360
|
+
scale = float(obj_area) / max(1.0, float(bkg_area))
|
|
1361
|
+
net = obj_sum - (bkg_sum * scale)
|
|
1362
|
+
|
|
1363
|
+
# ---------------- flux uncertainty from background sigma ----------------
|
|
1364
|
+
sigma_bg = sigma_from_mask(img_f, bg_mask)
|
|
1365
|
+
if sigma_bg is None:
|
|
1366
|
+
QMessageBox.warning(self, "Background", "Background stats failed.")
|
|
1367
|
+
return
|
|
1368
|
+
|
|
1369
|
+
if img_f.ndim == 2:
|
|
1370
|
+
flux_err = float(sigma_bg) * math.sqrt(float(obj_area))
|
|
1371
|
+
else:
|
|
1372
|
+
sigma_bg = np.asarray(sigma_bg, dtype=float) # (3,)
|
|
1373
|
+
flux_err = sigma_bg * math.sqrt(float(obj_area)) # (3,)
|
|
1374
|
+
|
|
1375
|
+
mode = (self.last_zp.get("mode") or ("mono" if img_f.ndim == 2 else "rgb"))
|
|
1376
|
+
|
|
1377
|
+
# pixscale / area for surface brightness
|
|
1378
|
+
_, pixscale = _build_wcs_and_pixscale(hdr)
|
|
1379
|
+
area_asec2 = (float(obj_area) * float(pixscale) * float(pixscale)) if (pixscale and pixscale > 0) else None
|
|
1380
|
+
pix_area_asec2 = (float(pixscale) * float(pixscale)) if (pixscale and pixscale > 0) else None
|
|
1381
|
+
|
|
1382
|
+
# background mean flux per pixel (mono: float, rgb: (3,))
|
|
1383
|
+
bkg_mean = (bkg_sum / float(bkg_area)) if bkg_area > 0 else None
|
|
1384
|
+
# systematic floor (mag) rolled into TOTAL only
|
|
1385
|
+
try:
|
|
1386
|
+
sys_floor = float(self.sys_floor_spin.value()) if hasattr(self, "sys_floor_spin") else float(getattr(self, "sys_floor_mag", 0.0) or 0.0)
|
|
1387
|
+
except Exception:
|
|
1388
|
+
sys_floor = float(getattr(self, "sys_floor_mag", 0.0) or 0.0)
|
|
1389
|
+
|
|
1390
|
+
def fmt(x):
|
|
1391
|
+
return "N/A" if x is None else f"{x:.3f}"
|
|
1392
|
+
|
|
1393
|
+
def fmt_sci(x):
|
|
1394
|
+
try:
|
|
1395
|
+
return "N/A" if x is None else f"{float(x):.6g}"
|
|
1396
|
+
except Exception:
|
|
1397
|
+
return "N/A"
|
|
1398
|
+
|
|
1399
|
+
def total_sigma(stat_mag_err: Optional[float]) -> Optional[float]:
|
|
1400
|
+
return _combine_err(stat_mag_err, sys_floor)
|
|
1401
|
+
|
|
1402
|
+
def total_3sigma(stat_mag_err: Optional[float]) -> Optional[float]:
|
|
1403
|
+
t = total_sigma(stat_mag_err)
|
|
1404
|
+
return None if t is None else (3.0 * float(t))
|
|
1405
|
+
|
|
1406
|
+
# -------- MONO --------
|
|
1407
|
+
if img_f.ndim == 2 or mode == "mono":
|
|
1408
|
+
ZP = self.last_zp.get("ZP")
|
|
1409
|
+
band = self.last_zp.get("band", self.band_combo.currentText().strip().upper())
|
|
1410
|
+
magkey = self.last_zp.get("magkey", _magkey_for_band(band))
|
|
1411
|
+
|
|
1412
|
+
# ZP uncertainty: prefer SEM; fallback to std/sqrt(n)
|
|
1413
|
+
zp_sem = self.last_zp.get("sem")
|
|
1414
|
+
if zp_sem is None:
|
|
1415
|
+
sd = self.last_zp.get("std")
|
|
1416
|
+
n = int(self.last_zp.get("n") or 0)
|
|
1417
|
+
zp_sem = (float(sd) / math.sqrt(n)) if (sd is not None and n > 1) else None
|
|
1418
|
+
|
|
1419
|
+
net_f = float(net)
|
|
1420
|
+
m = _mag_from_flux(net_f, ZP)
|
|
1421
|
+
mu = _mu_from_flux(net_f, float(area_asec2), ZP) if area_asec2 is not None else None
|
|
1422
|
+
mu_bg = None
|
|
1423
|
+
if pix_area_asec2 is not None and bkg_mean is not None:
|
|
1424
|
+
mu_bg = _mu_from_flux(float(bkg_mean), float(pix_area_asec2), ZP)
|
|
1425
|
+
m_stat = _mag_err_from_flux(net_f, float(flux_err), float(zp_sem)) if (zp_sem is not None) else None
|
|
1426
|
+
mu_stat = _mu_err_from_flux(net_f, float(flux_err), float(zp_sem)) if (zp_sem is not None and area_asec2 is not None) else None
|
|
1427
|
+
|
|
1428
|
+
m_3 = total_3sigma(m_stat)
|
|
1429
|
+
mu_3 = total_3sigma(mu_stat)
|
|
1430
|
+
|
|
1431
|
+
msg = (
|
|
1432
|
+
"Object region photometry (background-subtracted):\n"
|
|
1433
|
+
f" Net flux: {fmt_sci(net_f)} (flux σ: {fmt_sci(flux_err)})\n"
|
|
1434
|
+
f" Object area: {obj_area} px Background area: {bkg_area} px\n"
|
|
1435
|
+
f" Systematic floor (included): ±{sys_floor:.3f} mag\n\n"
|
|
1436
|
+
f"Integrated magnitude ({band}, catalog {magkey}):\n"
|
|
1437
|
+
f" m = {fmt(m)} ± {fmt(m_3)} (total 3σ)\n\n"
|
|
1438
|
+
)
|
|
1439
|
+
if pix_area_asec2 is not None:
|
|
1440
|
+
msg += f" Background μ (mag/arcsec²): {fmt(mu_bg)}\n"
|
|
1441
|
+
|
|
1442
|
+
msg += "\n"
|
|
1443
|
+
if area_asec2 is not None:
|
|
1444
|
+
msg += (
|
|
1445
|
+
f"Surface brightness (mag/arcsec²) [area={area_asec2:.3f} arcsec²]:\n"
|
|
1446
|
+
f" μ = {fmt(mu)} ± {fmt(mu_3)} (total 3σ)\n"
|
|
1447
|
+
)
|
|
1448
|
+
else:
|
|
1449
|
+
msg += "Surface brightness: N/A (no pixscale from WCS)\n"
|
|
1450
|
+
|
|
1451
|
+
QMessageBox.information(self, "Magnitude Results", msg)
|
|
1452
|
+
return
|
|
1453
|
+
|
|
1454
|
+
# -------- RGB --------
|
|
1455
|
+
ZP_R = self.last_zp.get("ZP_R")
|
|
1456
|
+
ZP_G = self.last_zp.get("ZP_G")
|
|
1457
|
+
ZP_B = self.last_zp.get("ZP_B")
|
|
1458
|
+
|
|
1459
|
+
sem_R = self.last_zp.get("sem_R")
|
|
1460
|
+
sem_G = self.last_zp.get("sem_G")
|
|
1461
|
+
sem_B = self.last_zp.get("sem_B")
|
|
1462
|
+
|
|
1463
|
+
netR, netG, netB = float(net[0]), float(net[1]), float(net[2])
|
|
1464
|
+
errR, errG, errB = float(flux_err[0]), float(flux_err[1]), float(flux_err[2])
|
|
1465
|
+
|
|
1466
|
+
mR = _mag_from_flux(netR, ZP_R)
|
|
1467
|
+
mG = _mag_from_flux(netG, ZP_G)
|
|
1468
|
+
mB = _mag_from_flux(netB, ZP_B)
|
|
1469
|
+
|
|
1470
|
+
mR_stat = _mag_err_from_flux(netR, errR, float(sem_R)) if (sem_R is not None) else None
|
|
1471
|
+
mG_stat = _mag_err_from_flux(netG, errG, float(sem_G)) if (sem_G is not None) else None
|
|
1472
|
+
mB_stat = _mag_err_from_flux(netB, errB, float(sem_B)) if (sem_B is not None) else None
|
|
1473
|
+
|
|
1474
|
+
mR_3 = total_3sigma(mR_stat)
|
|
1475
|
+
mG_3 = total_3sigma(mG_stat)
|
|
1476
|
+
mB_3 = total_3sigma(mB_stat)
|
|
1477
|
+
|
|
1478
|
+
mu_bg_R = mu_bg_G = mu_bg_B = None
|
|
1479
|
+
if pix_area_asec2 is not None and bkg_mean is not None:
|
|
1480
|
+
# bkg_mean is (3,)
|
|
1481
|
+
bR, bG, bB = float(bkg_mean[0]), float(bkg_mean[1]), float(bkg_mean[2])
|
|
1482
|
+
mu_bg_R = _mu_from_flux(bR, float(pix_area_asec2), ZP_R) if (ZP_R is not None and bR > 0) else None
|
|
1483
|
+
mu_bg_G = _mu_from_flux(bG, float(pix_area_asec2), ZP_G) if (ZP_G is not None and bG > 0) else None
|
|
1484
|
+
mu_bg_B = _mu_from_flux(bB, float(pix_area_asec2), ZP_B) if (ZP_B is not None and bB > 0) else None
|
|
1485
|
+
|
|
1486
|
+
|
|
1487
|
+
muR = muG = muB = None
|
|
1488
|
+
muR_3 = muG_3 = muB_3 = None
|
|
1489
|
+
|
|
1490
|
+
if area_asec2 is not None:
|
|
1491
|
+
A = float(area_asec2)
|
|
1492
|
+
muR = _mu_from_flux(netR, A, ZP_R)
|
|
1493
|
+
muG = _mu_from_flux(netG, A, ZP_G)
|
|
1494
|
+
muB = _mu_from_flux(netB, A, ZP_B)
|
|
1495
|
+
|
|
1496
|
+
muR_stat = _mu_err_from_flux(netR, errR, float(sem_R)) if (sem_R is not None) else None
|
|
1497
|
+
muG_stat = _mu_err_from_flux(netG, errG, float(sem_G)) if (sem_G is not None) else None
|
|
1498
|
+
muB_stat = _mu_err_from_flux(netB, errB, float(sem_B)) if (sem_B is not None) else None
|
|
1499
|
+
|
|
1500
|
+
muR_3 = total_3sigma(muR_stat)
|
|
1501
|
+
muG_3 = total_3sigma(muG_stat)
|
|
1502
|
+
muB_3 = total_3sigma(muB_stat)
|
|
1503
|
+
|
|
1504
|
+
msg = (
|
|
1505
|
+
"Object region photometry (background-subtracted):\n"
|
|
1506
|
+
f" Net flux (R,G,B): {fmt_sci(netR)}, {fmt_sci(netG)}, {fmt_sci(netB)}\n"
|
|
1507
|
+
f" Flux σ (R,G,B): {fmt_sci(errR)}, {fmt_sci(errG)}, {fmt_sci(errB)}\n"
|
|
1508
|
+
f" Object area: {obj_area} px Background area: {bkg_area} px\n"
|
|
1509
|
+
f" Systematic floor (included): ±{sys_floor:.3f} mag\n\n"
|
|
1510
|
+
"Integrated magnitude (total 3σ):\n"
|
|
1511
|
+
f" m_R = {fmt(mR)} ± {fmt(mR_3)}\n"
|
|
1512
|
+
f" m_G = {fmt(mG)} ± {fmt(mG_3)}\n"
|
|
1513
|
+
f" m_B = {fmt(mB)} ± {fmt(mB_3)}\n\n"
|
|
1514
|
+
)
|
|
1515
|
+
|
|
1516
|
+
if pix_area_asec2 is not None:
|
|
1517
|
+
msg += (
|
|
1518
|
+
f"Background surface brightness (mag/arcsec²):\n"
|
|
1519
|
+
f" μ_bg_R = {fmt(mu_bg_R)} μ_bg_G = {fmt(mu_bg_G)} μ_bg_B = {fmt(mu_bg_B)}\n\n"
|
|
1520
|
+
)
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
if area_asec2 is not None:
|
|
1524
|
+
msg += (
|
|
1525
|
+
f"Surface brightness (mag/arcsec²) [area={area_asec2:.3f} arcsec²] (total 3σ):\n"
|
|
1526
|
+
f" μ_R = {fmt(muR)} ± {fmt(muR_3)}\n"
|
|
1527
|
+
f" μ_G = {fmt(muG)} ± {fmt(muG_3)}\n"
|
|
1528
|
+
f" μ_B = {fmt(muB)} ± {fmt(muB_3)}\n"
|
|
1529
|
+
)
|
|
1530
|
+
else:
|
|
1531
|
+
msg += "Surface brightness: N/A (no pixscale from WCS)\n"
|
|
1532
|
+
|
|
1533
|
+
QMessageBox.information(self, "Magnitude Results", msg)
|
|
1534
|
+
|
|
1535
|
+
def _fetch_simbad_stars_and_cache(self, img, hdr, doc) -> List[dict]:
|
|
1536
|
+
"""
|
|
1537
|
+
SFCC-like star fetch, but UI-free.
|
|
1538
|
+
Produces list of dicts with keys: ra, dec, sp_clean, pickles_match, x, y, Bmag, Vmag, Rmag
|
|
1539
|
+
Caches into doc.metadata['SFCC_star_list'].
|
|
1540
|
+
"""
|
|
1541
|
+
# ---- build WCS ----
|
|
1542
|
+
wcs, _pixscale = _build_wcs_and_pixscale(hdr)
|
|
1543
|
+
if wcs is None:
|
|
1544
|
+
raise RuntimeError("Could not build 2D WCS from header.")
|
|
1545
|
+
wcs2 = wcs.celestial if hasattr(wcs, "celestial") else wcs
|
|
1546
|
+
|
|
1547
|
+
H, W = img.shape[:2]
|
|
1548
|
+
|
|
1549
|
+
# ---- center + corner radius ----
|
|
1550
|
+
pix = np.array([[W / 2, H / 2], [0, 0], [W, 0], [0, H], [W, H]], dtype=float)
|
|
1551
|
+
try:
|
|
1552
|
+
sky = wcs2.all_pix2world(pix, 0)
|
|
1553
|
+
except Exception as e:
|
|
1554
|
+
raise RuntimeError(f"WCS Conversion Error: {e}")
|
|
1555
|
+
|
|
1556
|
+
center_sky = SkyCoord(ra=float(sky[0, 0]) * u.deg, dec=float(sky[0, 1]) * u.deg, frame="icrs")
|
|
1557
|
+
corners_sky = SkyCoord(ra=sky[1:, 0] * u.deg, dec=sky[1:, 1] * u.deg, frame="icrs")
|
|
1558
|
+
radius = center_sky.separation(corners_sky).max() * 1.05
|
|
1559
|
+
|
|
1560
|
+
# ---- SIMBAD fields (new then legacy) ----
|
|
1561
|
+
Simbad.reset_votable_fields()
|
|
1562
|
+
|
|
1563
|
+
def _try_new_fields():
|
|
1564
|
+
Simbad.add_votable_fields("sp", "B", "V", "R", "ra", "dec")
|
|
1565
|
+
|
|
1566
|
+
def _try_legacy_fields():
|
|
1567
|
+
Simbad.add_votable_fields("sp", "flux(B)", "flux(V)", "flux(R)", "ra(d)", "dec(d)")
|
|
1568
|
+
|
|
1569
|
+
ok = False
|
|
1570
|
+
for _ in range(5):
|
|
1571
|
+
try:
|
|
1572
|
+
_try_new_fields()
|
|
1573
|
+
ok = True
|
|
1574
|
+
break
|
|
1575
|
+
except Exception:
|
|
1576
|
+
QApplication.processEvents()
|
|
1577
|
+
non_blocking_sleep(0.8)
|
|
1578
|
+
|
|
1579
|
+
if not ok:
|
|
1580
|
+
for _ in range(5):
|
|
1581
|
+
try:
|
|
1582
|
+
_try_legacy_fields()
|
|
1583
|
+
ok = True
|
|
1584
|
+
break
|
|
1585
|
+
except Exception:
|
|
1586
|
+
QApplication.processEvents()
|
|
1587
|
+
non_blocking_sleep(0.8)
|
|
1588
|
+
|
|
1589
|
+
if not ok:
|
|
1590
|
+
raise RuntimeError("Could not configure SIMBAD votable fields.")
|
|
1591
|
+
|
|
1592
|
+
Simbad.ROW_LIMIT = 10000
|
|
1593
|
+
|
|
1594
|
+
# ---- query ----
|
|
1595
|
+
result = None
|
|
1596
|
+
for attempt in range(1, 6):
|
|
1597
|
+
try:
|
|
1598
|
+
self.lbl_info.setText(f"Querying SIMBAD… (attempt {attempt}/5)")
|
|
1599
|
+
QApplication.processEvents()
|
|
1600
|
+
result = Simbad.query_region(center_sky, radius=radius)
|
|
1601
|
+
break
|
|
1602
|
+
except Exception:
|
|
1603
|
+
QApplication.processEvents()
|
|
1604
|
+
non_blocking_sleep(1.2)
|
|
1605
|
+
result = None
|
|
1606
|
+
|
|
1607
|
+
if result is None or len(result) == 0:
|
|
1608
|
+
# cache empty list to avoid re-query spam
|
|
1609
|
+
meta = dict(getattr(doc, "metadata", {}) or {})
|
|
1610
|
+
meta["SFCC_star_list"] = []
|
|
1611
|
+
self.doc_manager.update_active_document(doc.image, metadata=meta, step_name="Magnitude Stars Cached", doc=doc)
|
|
1612
|
+
return []
|
|
1613
|
+
|
|
1614
|
+
# ---- helpers ----
|
|
1615
|
+
def infer_letter(bv):
|
|
1616
|
+
if bv is None or (isinstance(bv, float) and np.isnan(bv)):
|
|
1617
|
+
return None
|
|
1618
|
+
if bv < 0.00: return "B"
|
|
1619
|
+
if bv < 0.30: return "A"
|
|
1620
|
+
if bv < 0.58: return "F"
|
|
1621
|
+
if bv < 0.81: return "G"
|
|
1622
|
+
if bv < 1.40: return "K"
|
|
1623
|
+
if bv > 1.40: return "M"
|
|
1624
|
+
return None
|
|
1625
|
+
|
|
1626
|
+
def safe_world2pix(ra_deg, dec_deg):
|
|
1627
|
+
try:
|
|
1628
|
+
xpix, ypix = wcs2.all_world2pix(ra_deg, dec_deg, 0)
|
|
1629
|
+
xpix, ypix = float(xpix), float(ypix)
|
|
1630
|
+
if np.isfinite(xpix) and np.isfinite(ypix):
|
|
1631
|
+
return xpix, ypix
|
|
1632
|
+
return None
|
|
1633
|
+
except NoConvergence as e:
|
|
1634
|
+
try:
|
|
1635
|
+
xpix, ypix = e.best_solution
|
|
1636
|
+
xpix, ypix = float(xpix), float(ypix)
|
|
1637
|
+
if np.isfinite(xpix) and np.isfinite(ypix):
|
|
1638
|
+
return xpix, ypix
|
|
1639
|
+
except Exception:
|
|
1640
|
+
pass
|
|
1641
|
+
return None
|
|
1642
|
+
except Exception:
|
|
1643
|
+
return None
|
|
1644
|
+
|
|
1645
|
+
cols_lower = {c.lower(): c for c in result.colnames}
|
|
1646
|
+
|
|
1647
|
+
ra_col = cols_lower.get("ra") or cols_lower.get("ra(d)") or cols_lower.get("ra_d")
|
|
1648
|
+
dec_col = cols_lower.get("dec") or cols_lower.get("dec(d)") or cols_lower.get("dec_d")
|
|
1649
|
+
b_col = cols_lower.get("b") or cols_lower.get("flux_b")
|
|
1650
|
+
v_col = cols_lower.get("v") or cols_lower.get("flux_v")
|
|
1651
|
+
r_col = cols_lower.get("r") or cols_lower.get("flux_r")
|
|
1652
|
+
|
|
1653
|
+
if ra_col is None or dec_col is None:
|
|
1654
|
+
raise RuntimeError(f"SIMBAD result missing ra/dec degree columns. colnames={result.colnames}")
|
|
1655
|
+
|
|
1656
|
+
# ---- pickles templates (optional) ----
|
|
1657
|
+
# If you want pickles matching for later use, you need a templates list.
|
|
1658
|
+
# If you don't have it here yet, we can still keep pickles_match=None and proceed.
|
|
1659
|
+
pickles_templates = getattr(self, "pickles_templates", None)
|
|
1660
|
+
if pickles_templates is None:
|
|
1661
|
+
pickles_templates = []
|
|
1662
|
+
setattr(self, "pickles_templates", pickles_templates)
|
|
1663
|
+
|
|
1664
|
+
star_list: List[dict] = []
|
|
1665
|
+
|
|
1666
|
+
for row in result:
|
|
1667
|
+
# spectral type column name
|
|
1668
|
+
raw_sp = row["SP_TYPE"] if "SP_TYPE" in result.colnames else (row["sp_type"] if "sp_type" in result.colnames else None)
|
|
1669
|
+
|
|
1670
|
+
bmag = _unmask_num(row[b_col]) if b_col is not None else None
|
|
1671
|
+
vmag = _unmask_num(row[v_col]) if v_col is not None else None
|
|
1672
|
+
rmag = _unmask_num(row[r_col]) if r_col is not None else None
|
|
1673
|
+
|
|
1674
|
+
ra_deg = _unmask_num(row[ra_col])
|
|
1675
|
+
dec_deg = _unmask_num(row[dec_col])
|
|
1676
|
+
if ra_deg is None or dec_deg is None:
|
|
1677
|
+
continue
|
|
1678
|
+
|
|
1679
|
+
sp_clean = None
|
|
1680
|
+
if raw_sp and str(raw_sp).strip():
|
|
1681
|
+
sp = str(raw_sp).strip().upper()
|
|
1682
|
+
if not (sp.startswith("SN") or sp.startswith("KA")):
|
|
1683
|
+
sp_clean = sp
|
|
1684
|
+
elif (bmag is not None) and (vmag is not None):
|
|
1685
|
+
sp_clean = infer_letter(bmag - vmag)
|
|
1686
|
+
|
|
1687
|
+
if not sp_clean:
|
|
1688
|
+
continue
|
|
1689
|
+
|
|
1690
|
+
xy = safe_world2pix(ra_deg, dec_deg)
|
|
1691
|
+
if xy is None:
|
|
1692
|
+
continue
|
|
1693
|
+
xpix, ypix = xy
|
|
1694
|
+
|
|
1695
|
+
if 0 <= xpix < W and 0 <= ypix < H:
|
|
1696
|
+
best_template = None
|
|
1697
|
+
try:
|
|
1698
|
+
matches = pickles_match_for_simbad(sp_clean, pickles_templates) if pickles_templates is not None else []
|
|
1699
|
+
best_template = matches[0] if matches else None
|
|
1700
|
+
except Exception:
|
|
1701
|
+
best_template = None
|
|
1702
|
+
|
|
1703
|
+
star_list.append({
|
|
1704
|
+
"ra": float(ra_deg), "dec": float(dec_deg),
|
|
1705
|
+
"sp_clean": sp_clean,
|
|
1706
|
+
"pickles_match": best_template,
|
|
1707
|
+
"x": float(xpix), "y": float(ypix),
|
|
1708
|
+
"Bmag": float(bmag) if bmag is not None else None,
|
|
1709
|
+
"Vmag": float(vmag) if vmag is not None else None,
|
|
1710
|
+
"Rmag": float(rmag) if rmag is not None else None,
|
|
1711
|
+
})
|
|
1712
|
+
|
|
1713
|
+
# ---- cache into metadata ----
|
|
1714
|
+
meta = dict(getattr(doc, "metadata", {}) or {})
|
|
1715
|
+
meta["SFCC_star_list"] = list(star_list) # JSON-ish
|
|
1716
|
+
self.doc_manager.update_active_document(doc.image, metadata=meta, step_name="Magnitude Stars Cached", doc=doc)
|
|
1717
|
+
|
|
1718
|
+
return star_list
|
|
1719
|
+
|
|
1720
|
+
|
|
1721
|
+
def open_magnitude_tool(doc_manager, parent=None) -> MagnitudeToolDialog:
|
|
1722
|
+
dlg = MagnitudeToolDialog(doc_manager=doc_manager, parent=parent)
|
|
1723
|
+
dlg.show()
|
|
1724
|
+
return dlg
|