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.

@@ -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