setiastrosuitepro 1.8.1.post2__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/finderchart.png +0 -0
- setiastro/images/magnitude.png +0 -0
- setiastro/saspro/__main__.py +29 -38
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/abe.py +1 -1
- setiastro/saspro/backgroundneutral.py +54 -16
- setiastro/saspro/blink_comparator_pro.py +3 -1
- setiastro/saspro/bright_stars.py +305 -0
- setiastro/saspro/continuum_subtract.py +2 -1
- setiastro/saspro/cosmicclarity_preset.py +2 -1
- setiastro/saspro/doc_manager.py +8 -0
- setiastro/saspro/exoplanet_detector.py +22 -17
- setiastro/saspro/finder_chart.py +1650 -0
- setiastro/saspro/gui/main_window.py +131 -17
- setiastro/saspro/gui/mixins/header_mixin.py +40 -15
- setiastro/saspro/gui/mixins/menu_mixin.py +3 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +16 -1
- setiastro/saspro/imageops/stretch.py +1 -1
- setiastro/saspro/legacy/image_manager.py +18 -4
- setiastro/saspro/legacy/xisf.py +3 -3
- setiastro/saspro/magnitude_tool.py +1724 -0
- setiastro/saspro/main_helpers.py +18 -0
- setiastro/saspro/memory_utils.py +18 -14
- setiastro/saspro/remove_stars.py +13 -30
- setiastro/saspro/resources.py +177 -161
- setiastro/saspro/runtime_torch.py +71 -10
- setiastro/saspro/sfcc.py +86 -77
- setiastro/saspro/stacking_suite.py +4 -3
- setiastro/saspro/star_alignment.py +4 -2
- setiastro/saspro/texture_clarity.py +1 -1
- setiastro/saspro/torch_rejection.py +59 -28
- setiastro/saspro/widgets/image_utils.py +12 -4
- setiastro/saspro/wimi.py +2 -1
- setiastro/saspro/xisf.py +3 -3
- {setiastrosuitepro-1.8.1.post2.dist-info → setiastrosuitepro-1.8.3.dist-info}/METADATA +4 -4
- {setiastrosuitepro-1.8.1.post2.dist-info → setiastrosuitepro-1.8.3.dist-info}/RECORD +40 -35
- {setiastrosuitepro-1.8.1.post2.dist-info → setiastrosuitepro-1.8.3.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.8.1.post2.dist-info → setiastrosuitepro-1.8.3.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.8.1.post2.dist-info → setiastrosuitepro-1.8.3.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.8.1.post2.dist-info → setiastrosuitepro-1.8.3.dist-info}/licenses/license.txt +0 -0
|
@@ -0,0 +1,1650 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional, Tuple, TYPE_CHECKING
|
|
6
|
+
import io
|
|
7
|
+
import numpy as np
|
|
8
|
+
import csv
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List, Dict, Any
|
|
12
|
+
from PyQt6.QtCore import Qt, QTimer
|
|
13
|
+
from PyQt6.QtGui import QImage, QPixmap
|
|
14
|
+
from PyQt6.QtWidgets import (
|
|
15
|
+
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QComboBox,
|
|
16
|
+
QCheckBox, QFileDialog, QMessageBox, QSpinBox, QSlider, QApplication, QDoubleSpinBox
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from astropy.wcs import WCS
|
|
20
|
+
from astropy.io import fits
|
|
21
|
+
from astropy.coordinates import SkyCoord
|
|
22
|
+
from astropy.wcs.utils import proj_plane_pixel_scales
|
|
23
|
+
from matplotlib import patheffects as pe
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from setiastro.saspro.resources import get_data_path
|
|
26
|
+
from setiastro.saspro.bright_stars import BRIGHT_STARS
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from astropy.wcs import WCS as AstropyWCS
|
|
30
|
+
from astropy.coordinates import SkyCoord as AstropySkyCoord
|
|
31
|
+
else:
|
|
32
|
+
AstropyWCS = object
|
|
33
|
+
AstropySkyCoord = object
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class FinderChartRequest:
|
|
38
|
+
survey: str
|
|
39
|
+
scale_mult: int
|
|
40
|
+
show_grid: bool
|
|
41
|
+
|
|
42
|
+
# star overlay
|
|
43
|
+
show_star_names: bool = False
|
|
44
|
+
star_mag_limit: float = 2.0
|
|
45
|
+
star_max_labels: int = 30
|
|
46
|
+
|
|
47
|
+
# deep-sky overlay
|
|
48
|
+
show_dso: bool = False
|
|
49
|
+
dso_catalog: str = "Messier"
|
|
50
|
+
dso_mag_limit: float = 10.0
|
|
51
|
+
dso_max_labels: int = 30
|
|
52
|
+
|
|
53
|
+
# chart aids
|
|
54
|
+
show_compass: bool = True
|
|
55
|
+
show_scale_bar: bool = True
|
|
56
|
+
|
|
57
|
+
out_px: int = 900
|
|
58
|
+
overlay_opacity: float = 0.35
|
|
59
|
+
|
|
60
|
+
# ---------------- Catalog loading (cached) ----------------
|
|
61
|
+
|
|
62
|
+
_CATALOG_CACHE: Dict[str, List[Dict[str, Any]]] = {}
|
|
63
|
+
|
|
64
|
+
def _catalog_path(name: str) -> Path:
|
|
65
|
+
# everything now comes from celestial_catalog.csv
|
|
66
|
+
return Path(get_data_path("data/catalogs/celestial_catalog.csv"))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _safe_float(v, default=None):
|
|
71
|
+
try:
|
|
72
|
+
if v is None:
|
|
73
|
+
return default
|
|
74
|
+
s = str(v).strip()
|
|
75
|
+
if not s:
|
|
76
|
+
return default
|
|
77
|
+
return float(s)
|
|
78
|
+
except Exception:
|
|
79
|
+
return default
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_size_re = re.compile(r"(\d+(?:\.\d+)?)\s*[x×]\s*(\d+(?:\.\d+)?)")
|
|
83
|
+
|
|
84
|
+
def _parse_size_arcmin(info: str) -> Optional[tuple]:
|
|
85
|
+
"""
|
|
86
|
+
Parse sizes like " 6.0x4.0" or "90x40" from Info field.
|
|
87
|
+
Returns (w_arcmin, h_arcmin) or None.
|
|
88
|
+
"""
|
|
89
|
+
if not info:
|
|
90
|
+
return None
|
|
91
|
+
m = _size_re.search(str(info))
|
|
92
|
+
if not m:
|
|
93
|
+
return None
|
|
94
|
+
w = _safe_float(m.group(1), None)
|
|
95
|
+
h = _safe_float(m.group(2), None)
|
|
96
|
+
if w is None or h is None:
|
|
97
|
+
return None
|
|
98
|
+
# Assume arcminutes (matches your Messier examples)
|
|
99
|
+
return (float(w), float(h))
|
|
100
|
+
|
|
101
|
+
def _open_catalog_csv(path: Path):
|
|
102
|
+
"""
|
|
103
|
+
Catalogs may be saved as UTF-8, UTF-8 with BOM, or Windows-1252 / latin-1.
|
|
104
|
+
IMPORTANT: we must *force a decode* here (open() alone doesn't decode until read).
|
|
105
|
+
Returns a *text* file-like object suitable for csv.DictReader.
|
|
106
|
+
"""
|
|
107
|
+
data = path.read_bytes()
|
|
108
|
+
|
|
109
|
+
last = None
|
|
110
|
+
for enc in ("latin-1", "utf-8-sig", "utf-8", "cp1252"):
|
|
111
|
+
try:
|
|
112
|
+
text = data.decode(enc) # <-- force decode NOW
|
|
113
|
+
# newline="" behavior like open(..., newline="") for csv module
|
|
114
|
+
return io.StringIO(text, newline="")
|
|
115
|
+
except UnicodeDecodeError as e:
|
|
116
|
+
last = e
|
|
117
|
+
|
|
118
|
+
# If we get here, decoding failed for all options
|
|
119
|
+
raise last or UnicodeDecodeError("utf-8", b"", 0, 1, "Unknown decode error")
|
|
120
|
+
|
|
121
|
+
def _canon_catalog_code(name: str, catalog: str) -> str:
|
|
122
|
+
n = (name or "").strip().upper()
|
|
123
|
+
c = (catalog or "").strip().upper()
|
|
124
|
+
|
|
125
|
+
# --- Sharpless (SH2) ---
|
|
126
|
+
if c in {"SHARPLESS", "SH2", "SH-2", "SH 2", "SH_2"}:
|
|
127
|
+
return "SH2"
|
|
128
|
+
if n.startswith(("SH2-", "SH2 ", "SH-2", "SH 2", "SH_2")):
|
|
129
|
+
return "SH2"
|
|
130
|
+
|
|
131
|
+
# --- Planetary Nebula Galactic (PN-G / PNG) ---
|
|
132
|
+
if c in {"PNG", "PN-G", "PN G", "PN_G"}:
|
|
133
|
+
return "PN-G"
|
|
134
|
+
if n.startswith(("PN-G", "PN G", "PN_G", "PNG ")):
|
|
135
|
+
return "PN-G"
|
|
136
|
+
|
|
137
|
+
# Keep the catalog column as-is for everything else (NGC/IC/Abell/etc.)
|
|
138
|
+
return c
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _load_catalog_rows(kind: str) -> List[Dict[str, Any]]:
|
|
142
|
+
if kind in _CATALOG_CACHE:
|
|
143
|
+
return _CATALOG_CACHE[kind]
|
|
144
|
+
|
|
145
|
+
path = _catalog_path(kind)
|
|
146
|
+
rows: List[Dict[str, Any]] = []
|
|
147
|
+
|
|
148
|
+
if not path.exists():
|
|
149
|
+
print(f"[DSO] catalog path missing: {path}")
|
|
150
|
+
_CATALOG_CACHE[kind] = rows
|
|
151
|
+
return rows
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
with _open_catalog_csv(path) as f:
|
|
155
|
+
reader = csv.DictReader(f)
|
|
156
|
+
for r in reader:
|
|
157
|
+
def getk(*keys):
|
|
158
|
+
for k in keys:
|
|
159
|
+
if k in r and r[k] is not None:
|
|
160
|
+
return r[k]
|
|
161
|
+
kb = "\ufeff" + k
|
|
162
|
+
if kb in r and r[kb] is not None:
|
|
163
|
+
return r[kb]
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
name = str(getk("Name", "NAME") or "").strip()
|
|
167
|
+
ra = _safe_float(getk("RA", "Ra", "ra"), None)
|
|
168
|
+
dec = _safe_float(getk("Dec", "DEC", "dec"), None)
|
|
169
|
+
if not name or ra is None or dec is None:
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
mag = _safe_float(getk("Magnitude", "MAG", "mag"), None)
|
|
173
|
+
info = str(getk("Info", "Diameter") or "").strip()
|
|
174
|
+
typ = str(getk("Type", "LongType") or "").strip()
|
|
175
|
+
cat = str(getk("Catalog") or "").strip()
|
|
176
|
+
name = str(getk("Name", "NAME") or "").strip()
|
|
177
|
+
canon = _canon_catalog_code(name, cat)
|
|
178
|
+
|
|
179
|
+
rows.append({
|
|
180
|
+
"name": name,
|
|
181
|
+
"ra": float(ra),
|
|
182
|
+
"dec": float(dec),
|
|
183
|
+
"mag": mag,
|
|
184
|
+
"info": info,
|
|
185
|
+
"catalog": cat, # raw
|
|
186
|
+
"catalog_code": canon, # canonical (used for filtering)
|
|
187
|
+
"type": typ,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
except Exception as e:
|
|
191
|
+
print(f"[DSO] catalog read failed: {path} ({type(e).__name__}: {e})")
|
|
192
|
+
_CATALOG_CACHE[kind] = []
|
|
193
|
+
return []
|
|
194
|
+
|
|
195
|
+
# per-kind filtering (based on Catalog column in celestial_catalog.csv)
|
|
196
|
+
k = (kind or "").strip().upper()
|
|
197
|
+
|
|
198
|
+
# Map UI label -> allowed catalog codes (also uppercase)
|
|
199
|
+
allowed_map = {
|
|
200
|
+
"M": {"M", "MESSIER"},
|
|
201
|
+
"NGC": {"NGC"},
|
|
202
|
+
"IC": {"IC"},
|
|
203
|
+
"ABELL": {"ABELL"},
|
|
204
|
+
"SH2": {"SH2"},
|
|
205
|
+
"LBN": {"LBN"},
|
|
206
|
+
"LDN": {"LDN"},
|
|
207
|
+
"PN-G": {"PN-G"},
|
|
208
|
+
"ALL (DSO)": None,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
allowed = allowed_map.get(k, None)
|
|
212
|
+
|
|
213
|
+
def _catcode(row) -> str:
|
|
214
|
+
return (row.get("catalog_code") or row.get("catalog") or "").strip().upper()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if allowed is not None:
|
|
218
|
+
rows = [r for r in rows if _catcode(r) in allowed]
|
|
219
|
+
|
|
220
|
+
_CATALOG_CACHE[kind] = rows
|
|
221
|
+
return rows
|
|
222
|
+
|
|
223
|
+
def _pixel_scale_arcsec(bg_wcs: "WCS") -> Optional[float]:
|
|
224
|
+
"""
|
|
225
|
+
Approx arcsec/pixel using astropy helper. Works for TAN-ish WCS.
|
|
226
|
+
"""
|
|
227
|
+
if bg_wcs is None:
|
|
228
|
+
return None
|
|
229
|
+
try:
|
|
230
|
+
# returns degrees/pixel per axis
|
|
231
|
+
sc = proj_plane_pixel_scales(bg_wcs) # deg/pix
|
|
232
|
+
deg_per_pix = float(np.nanmedian(sc))
|
|
233
|
+
if not np.isfinite(deg_per_pix) or deg_per_pix <= 0:
|
|
234
|
+
return None
|
|
235
|
+
return deg_per_pix * 3600.0
|
|
236
|
+
except Exception:
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
def _draw_dso_overlay(ax, bg_wcs: "WCS", center: "SkyCoord", fov_deg: float, req: FinderChartRequest, renderer):
|
|
240
|
+
"""
|
|
241
|
+
Plot catalog objects within ~0.75*fov radius; declutter via coarse grid.
|
|
242
|
+
"""
|
|
243
|
+
if bg_wcs is None or center is None:
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
import astropy.units as u
|
|
247
|
+
from astropy.coordinates import SkyCoord
|
|
248
|
+
|
|
249
|
+
rows = _load_catalog_rows(req.dso_catalog)
|
|
250
|
+
|
|
251
|
+
if not rows:
|
|
252
|
+
return
|
|
253
|
+
out_px = int(getattr(ax.figure, "_sas_out_px", 0) or 0)
|
|
254
|
+
if out_px <= 0:
|
|
255
|
+
out_px = int(ax.figure.get_figwidth() * ax.figure.dpi)
|
|
256
|
+
ra0 = float(center.ra.deg)
|
|
257
|
+
dec0 = float(center.dec.deg)
|
|
258
|
+
radius = float(fov_deg) * 0.75
|
|
259
|
+
|
|
260
|
+
c0 = SkyCoord(ra0*u.deg, dec0*u.deg, frame="icrs")
|
|
261
|
+
|
|
262
|
+
# filter by radius + mag
|
|
263
|
+
cand = []
|
|
264
|
+
for r in rows:
|
|
265
|
+
if abs(r["dec"] - dec0) > radius + 2.0:
|
|
266
|
+
continue
|
|
267
|
+
c1 = SkyCoord(r["ra"]*u.deg, r["dec"]*u.deg, frame="icrs")
|
|
268
|
+
if c0.separation(c1).deg > radius:
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
mag = r.get("mag", None)
|
|
272
|
+
if mag is not None and mag > float(req.dso_mag_limit):
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
# score: brighter first; unknown mag goes later
|
|
276
|
+
score = (mag if mag is not None else 99.0)
|
|
277
|
+
cand.append((score, r))
|
|
278
|
+
|
|
279
|
+
if not cand:
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
cand.sort(key=lambda t: t[0])
|
|
283
|
+
cand = cand[:max(1, int(req.dso_max_labels) * 4)] # keep a bit extra before declutter
|
|
284
|
+
|
|
285
|
+
# project
|
|
286
|
+
coords = SkyCoord([t[1]["ra"] for t in cand]*u.deg, [t[1]["dec"] for t in cand]*u.deg, frame="icrs")
|
|
287
|
+
xs, ys = bg_wcs.world_to_pixel(coords)
|
|
288
|
+
out_px = int(getattr(ax.figure, "_sas_out_px", 0) or 0)
|
|
289
|
+
if out_px <= 0:
|
|
290
|
+
out_px = int(ax.figure.get_figwidth() * ax.figure.dpi)
|
|
291
|
+
# declutter: one label per coarse cell
|
|
292
|
+
kept = []
|
|
293
|
+
cell = 34 # px
|
|
294
|
+
used = set()
|
|
295
|
+
|
|
296
|
+
for (t, x, y) in zip(cand, xs, ys):
|
|
297
|
+
x = float(x); y = float(y)
|
|
298
|
+
if not _inside_px(x, y, out_px, pad=0):
|
|
299
|
+
continue
|
|
300
|
+
if not (np.isfinite(x) and np.isfinite(y)):
|
|
301
|
+
continue
|
|
302
|
+
gx = int(x // cell)
|
|
303
|
+
gy = int(y // cell)
|
|
304
|
+
key = (gx, gy)
|
|
305
|
+
if key in used:
|
|
306
|
+
continue
|
|
307
|
+
used.add(key)
|
|
308
|
+
kept.append((t[1], float(x), float(y)))
|
|
309
|
+
if len(kept) >= int(req.dso_max_labels):
|
|
310
|
+
break
|
|
311
|
+
|
|
312
|
+
if not kept:
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
# optional size ellipse (Info field, arcmin)
|
|
316
|
+
arcsec_per_pix = _pixel_scale_arcsec(bg_wcs)
|
|
317
|
+
|
|
318
|
+
for (r, x, y) in kept:
|
|
319
|
+
name = r["name"]
|
|
320
|
+
|
|
321
|
+
# marker
|
|
322
|
+
ax.plot(
|
|
323
|
+
[x], [y],
|
|
324
|
+
marker="s",
|
|
325
|
+
markersize=3.5,
|
|
326
|
+
alpha=0.9,
|
|
327
|
+
color="#66ccff", # light cyan (optional)
|
|
328
|
+
transform=ax.get_transform("pixel"),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
# size circle if we can parse it (Info field, arcmin)
|
|
332
|
+
if arcsec_per_pix is not None:
|
|
333
|
+
sz = _parse_size_arcmin(r.get("info", ""))
|
|
334
|
+
if sz is not None:
|
|
335
|
+
w_arcmin, h_arcmin = sz
|
|
336
|
+
|
|
337
|
+
# Use the MAJOR axis only (honest; no PA info)
|
|
338
|
+
major_arcmin = float(max(w_arcmin, h_arcmin))
|
|
339
|
+
|
|
340
|
+
# convert major-axis diameter arcmin -> pixel radius
|
|
341
|
+
diam_px = (major_arcmin * 60.0) / arcsec_per_pix
|
|
342
|
+
rad_px = 0.5 * diam_px
|
|
343
|
+
|
|
344
|
+
if np.isfinite(rad_px) and rad_px > 2:
|
|
345
|
+
try:
|
|
346
|
+
from matplotlib.patches import Circle
|
|
347
|
+
c = Circle(
|
|
348
|
+
(x, y),
|
|
349
|
+
radius=rad_px,
|
|
350
|
+
fill=False,
|
|
351
|
+
linewidth=1,
|
|
352
|
+
alpha=0.75,
|
|
353
|
+
edgecolor="#5145ff", # cyan outline (NOT black)
|
|
354
|
+
transform=ax.get_transform("pixel"),
|
|
355
|
+
)
|
|
356
|
+
ax.add_patch(c)
|
|
357
|
+
except Exception:
|
|
358
|
+
pass
|
|
359
|
+
|
|
360
|
+
# label
|
|
361
|
+
_place_label_inside(
|
|
362
|
+
ax, x, y, name,
|
|
363
|
+
dx=7, dy=5, fontsize=9,
|
|
364
|
+
color="white", alpha=0.95, outline=True,
|
|
365
|
+
out_px=out_px, pad=4, renderer=renderer
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
def _draw_compass_NE(ax, bg_wcs: "WCS", out_px: int, fov_deg: float):
|
|
369
|
+
"""
|
|
370
|
+
Draw N/E arrows in the bottom-right using image pixel/data coordinates.
|
|
371
|
+
Works reliably with WCSAxes.
|
|
372
|
+
"""
|
|
373
|
+
if bg_wcs is None:
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
import astropy.units as u
|
|
377
|
+
from astropy.coordinates import SkyCoord
|
|
378
|
+
|
|
379
|
+
# place near bottom-right in IMAGE pixel coords
|
|
380
|
+
cx = out_px * 0.86
|
|
381
|
+
cy = out_px * 0.12
|
|
382
|
+
base_len = out_px * 0.09 # pixels
|
|
383
|
+
|
|
384
|
+
# choose a small sky step relative to FOV (avoid huge jumps on big FOV)
|
|
385
|
+
step_deg = max(0.01, min(0.15, float(fov_deg) * 0.08)) * u.deg
|
|
386
|
+
|
|
387
|
+
try:
|
|
388
|
+
csky = bg_wcs.pixel_to_world(out_px * 0.5, out_px * 0.5)
|
|
389
|
+
ra = csky.ra.to(u.deg)
|
|
390
|
+
dec = csky.dec.to(u.deg)
|
|
391
|
+
|
|
392
|
+
# North: +Dec
|
|
393
|
+
cn = SkyCoord(ra, dec + step_deg, frame="icrs")
|
|
394
|
+
|
|
395
|
+
# East: +RA (compensate a bit for cos(dec))
|
|
396
|
+
cosd = max(0.15, float(np.cos(dec.to_value(u.rad))))
|
|
397
|
+
ce = SkyCoord(ra + (step_deg / cosd), dec, frame="icrs")
|
|
398
|
+
|
|
399
|
+
x0, y0 = bg_wcs.world_to_pixel(SkyCoord(ra, dec, frame="icrs"))
|
|
400
|
+
xn, yn = bg_wcs.world_to_pixel(cn)
|
|
401
|
+
xe, ye = bg_wcs.world_to_pixel(ce)
|
|
402
|
+
|
|
403
|
+
vn = np.array([float(xn - x0), float(yn - y0)], dtype=np.float64)
|
|
404
|
+
ve = np.array([float(xe - x0), float(ye - y0)], dtype=np.float64)
|
|
405
|
+
|
|
406
|
+
def _n(v):
|
|
407
|
+
n = float(np.hypot(v[0], v[1])) or 1.0
|
|
408
|
+
return v / n
|
|
409
|
+
|
|
410
|
+
vn = _n(vn) * base_len
|
|
411
|
+
ve = _n(ve) * base_len
|
|
412
|
+
|
|
413
|
+
except Exception:
|
|
414
|
+
# fallback: at least show something
|
|
415
|
+
vn = np.array([0.0, base_len])
|
|
416
|
+
ve = np.array([base_len, 0.0])
|
|
417
|
+
|
|
418
|
+
# Draw in DATA coords (image pixels) — this is the big fix
|
|
419
|
+
tx = ax.transData
|
|
420
|
+
|
|
421
|
+
ax.annotate(
|
|
422
|
+
"", xy=(cx + ve[0], cy + ve[1]), xytext=(cx, cy),
|
|
423
|
+
xycoords=tx, textcoords=tx,
|
|
424
|
+
arrowprops=dict(arrowstyle="->", linewidth=2.2, color="white", alpha=0.95)
|
|
425
|
+
)
|
|
426
|
+
ax.annotate(
|
|
427
|
+
"", xy=(cx + vn[0], cy + vn[1]), xytext=(cx, cy),
|
|
428
|
+
xycoords=tx, textcoords=tx,
|
|
429
|
+
arrowprops=dict(arrowstyle="->", linewidth=2.2, color="white", alpha=0.95)
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
bbox = dict(boxstyle="round,pad=0.15", facecolor=(0, 0, 0, 0.55), edgecolor=(1, 1, 1, 0.15))
|
|
433
|
+
|
|
434
|
+
ax.text(cx + ve[0] + 6, cy + ve[1] + 2, "E",
|
|
435
|
+
transform=tx, fontsize=10, color="white", alpha=0.98, bbox=bbox)
|
|
436
|
+
ax.text(cx + vn[0] + 6, cy + vn[1] + 2, "N",
|
|
437
|
+
transform=tx, fontsize=10, color="white", alpha=0.98, bbox=bbox)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _draw_scale_bar(ax, bg_wcs: "WCS", out_px: int, fov_deg: float):
|
|
441
|
+
"""
|
|
442
|
+
Draw a scale bar bottom-left with units + arcsec/pixel.
|
|
443
|
+
"""
|
|
444
|
+
if bg_wcs is None:
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
arcsec_per_pix = _pixel_scale_arcsec(bg_wcs)
|
|
448
|
+
if arcsec_per_pix is None:
|
|
449
|
+
return
|
|
450
|
+
|
|
451
|
+
fov_arcmin = float(fov_deg) * 60.0
|
|
452
|
+
if fov_arcmin >= 240:
|
|
453
|
+
bar_arcmin = 30
|
|
454
|
+
elif fov_arcmin >= 120:
|
|
455
|
+
bar_arcmin = 20
|
|
456
|
+
elif fov_arcmin >= 60:
|
|
457
|
+
bar_arcmin = 10
|
|
458
|
+
else:
|
|
459
|
+
bar_arcmin = 5
|
|
460
|
+
|
|
461
|
+
bar_px = (bar_arcmin * 60.0) / arcsec_per_pix
|
|
462
|
+
if not np.isfinite(bar_px) or bar_px < 30:
|
|
463
|
+
return
|
|
464
|
+
|
|
465
|
+
x0 = out_px * 0.08
|
|
466
|
+
y0 = out_px * 0.10
|
|
467
|
+
x1 = x0 + bar_px
|
|
468
|
+
|
|
469
|
+
tx = ax.transData # BIG FIX
|
|
470
|
+
|
|
471
|
+
ax.plot([x0, x1], [y0, y0], linewidth=3, color="white", alpha=0.95, transform=tx)
|
|
472
|
+
ax.plot([x0, x0], [y0 - 5, y0 + 5], linewidth=2, color="white", alpha=0.95, transform=tx)
|
|
473
|
+
ax.plot([x1, x1], [y0 - 5, y0 + 5], linewidth=2, color="white", alpha=0.95, transform=tx)
|
|
474
|
+
|
|
475
|
+
bbox = dict(boxstyle="round,pad=0.2", facecolor=(0, 0, 0, 0.55), edgecolor=(1, 1, 1, 0.12))
|
|
476
|
+
ax.text(
|
|
477
|
+
x0, y0 + 14,
|
|
478
|
+
f"{bar_arcmin}′ ({arcsec_per_pix:.2f}″/px)",
|
|
479
|
+
transform=tx,
|
|
480
|
+
fontsize=10,
|
|
481
|
+
color="white",
|
|
482
|
+
alpha=0.98,
|
|
483
|
+
bbox=bbox
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def get_doc_wcs(meta: dict) -> Optional["AstropyWCS"]:
|
|
488
|
+
"""Prefer prebuilt WCS object; else build from header. Always return *celestial* (2D) WCS."""
|
|
489
|
+
if WCS is None:
|
|
490
|
+
return None
|
|
491
|
+
|
|
492
|
+
w = meta.get("wcs")
|
|
493
|
+
if w is None:
|
|
494
|
+
hdr = meta.get("original_header") or meta.get("fits_header") or meta.get("header")
|
|
495
|
+
if hdr is None:
|
|
496
|
+
return None
|
|
497
|
+
|
|
498
|
+
# normalize to fits.Header if needed
|
|
499
|
+
if fits is not None and not isinstance(hdr, fits.Header):
|
|
500
|
+
try:
|
|
501
|
+
h2 = fits.Header()
|
|
502
|
+
for k, v in dict(hdr).items():
|
|
503
|
+
try:
|
|
504
|
+
h2[k] = v
|
|
505
|
+
except Exception:
|
|
506
|
+
pass
|
|
507
|
+
hdr = h2
|
|
508
|
+
except Exception:
|
|
509
|
+
return None
|
|
510
|
+
|
|
511
|
+
try:
|
|
512
|
+
w = WCS(hdr, relax=True)
|
|
513
|
+
except Exception:
|
|
514
|
+
return None
|
|
515
|
+
|
|
516
|
+
# --- CRITICAL FIX: if WCS has extra axes, use celestial slice ---
|
|
517
|
+
try:
|
|
518
|
+
if hasattr(w, "celestial"):
|
|
519
|
+
wc = w.celestial
|
|
520
|
+
if wc is not None:
|
|
521
|
+
return wc
|
|
522
|
+
except Exception:
|
|
523
|
+
pass
|
|
524
|
+
|
|
525
|
+
return w
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def image_footprint_sky(wcs: "WCS", w: int, h: int):
|
|
529
|
+
"""Return (corners SkyCoord[4], center SkyCoord)."""
|
|
530
|
+
wc = wcs
|
|
531
|
+
try:
|
|
532
|
+
if hasattr(wcs, "celestial") and wcs.celestial is not None:
|
|
533
|
+
wc = wcs.celestial
|
|
534
|
+
except Exception:
|
|
535
|
+
pass
|
|
536
|
+
|
|
537
|
+
xs = np.array([0.5, w - 0.5, w - 0.5, 0.5], dtype=np.float64)
|
|
538
|
+
ys = np.array([0.5, 0.5, h - 0.5, h - 0.5], dtype=np.float64)
|
|
539
|
+
|
|
540
|
+
corners = wc.pixel_to_world(xs, ys)
|
|
541
|
+
center = wc.pixel_to_world(np.array([w / 2.0]), np.array([h / 2.0]))
|
|
542
|
+
return corners, center
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _ang_sep_deg(a1, d1, a2, d2) -> float:
|
|
546
|
+
"""Small helper; inputs degrees."""
|
|
547
|
+
ra1 = math.radians(a1); dec1 = math.radians(d1)
|
|
548
|
+
ra2 = math.radians(a2); dec2 = math.radians(d2)
|
|
549
|
+
return math.degrees(math.acos(
|
|
550
|
+
max(-1.0, min(1.0, math.sin(dec1)*math.sin(dec2)
|
|
551
|
+
+ math.cos(dec1)*math.cos(dec2)*math.cos(ra1-ra2)))
|
|
552
|
+
))
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def estimate_fov_deg(corners: "AstropySkyCoord") -> Tuple[float, float]:
|
|
556
|
+
"""Approx FOV width/height in degrees using corner separations."""
|
|
557
|
+
ra = corners.ra.deg
|
|
558
|
+
dec = corners.dec.deg
|
|
559
|
+
|
|
560
|
+
w1 = _ang_sep_deg(ra[0], dec[0], ra[1], dec[1])
|
|
561
|
+
w2 = _ang_sep_deg(ra[3], dec[3], ra[2], dec[2])
|
|
562
|
+
width = 0.5 * (w1 + w2)
|
|
563
|
+
|
|
564
|
+
h1 = _ang_sep_deg(ra[0], dec[0], ra[3], dec[3])
|
|
565
|
+
h2 = _ang_sep_deg(ra[1], dec[1], ra[2], dec[2])
|
|
566
|
+
height = 0.5 * (h1 + h2)
|
|
567
|
+
|
|
568
|
+
return float(width), float(height)
|
|
569
|
+
|
|
570
|
+
def _wcs_shift_for_crop(w: WCS, x0: int, y0: int) -> WCS:
|
|
571
|
+
"""
|
|
572
|
+
Return a copy of WCS adjusted for cropping (x0,y0) pixels off left/bottom.
|
|
573
|
+
Array pixels are 0-based; FITS WCS CRPIX is 1-based, but the shift is the same in pixels.
|
|
574
|
+
"""
|
|
575
|
+
if w is None:
|
|
576
|
+
return None
|
|
577
|
+
try:
|
|
578
|
+
wc = w.deepcopy()
|
|
579
|
+
# CRPIX is in pixel units; subtract crop origin
|
|
580
|
+
wc.wcs.crpix = np.array(wc.wcs.crpix, dtype=float) - np.array([float(x0), float(y0)], dtype=float)
|
|
581
|
+
return wc
|
|
582
|
+
except Exception:
|
|
583
|
+
return w
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _crop_center(bg_rgb01: np.ndarray, bg_wcs: Optional[WCS], out_px: int):
|
|
587
|
+
"""
|
|
588
|
+
Center-crop bg to (out_px,out_px). Returns (cropped_bg, cropped_wcs, (x0,y0)).
|
|
589
|
+
"""
|
|
590
|
+
H, W = bg_rgb01.shape[:2]
|
|
591
|
+
out_px = int(out_px)
|
|
592
|
+
if out_px <= 0 or out_px > min(H, W):
|
|
593
|
+
return bg_rgb01, bg_wcs, (0, 0)
|
|
594
|
+
|
|
595
|
+
x0 = int((W - out_px) // 2)
|
|
596
|
+
y0 = int((H - out_px) // 2)
|
|
597
|
+
|
|
598
|
+
crop = bg_rgb01[y0:y0 + out_px, x0:x0 + out_px, :]
|
|
599
|
+
wc = _wcs_shift_for_crop(bg_wcs, x0, y0) if bg_wcs is not None else None
|
|
600
|
+
return crop, wc, (x0, y0)
|
|
601
|
+
|
|
602
|
+
def _inside_px(x: float, y: float, out_px: int, pad: int = 0) -> bool:
|
|
603
|
+
if not (np.isfinite(x) and np.isfinite(y)):
|
|
604
|
+
return False
|
|
605
|
+
return (-pad) <= x <= (out_px - 1 + pad) and (-pad) <= y <= (out_px - 1 + pad)
|
|
606
|
+
|
|
607
|
+
def _place_label_inside(ax, x, y, text, *, dx=6, dy=4, fontsize=9,
|
|
608
|
+
color="white", alpha=0.95, outline=True,
|
|
609
|
+
out_px=900, pad=3, renderer=None):
|
|
610
|
+
"""
|
|
611
|
+
Place a label near (x,y) but ensure its bounding box stays inside [0,out_px).
|
|
612
|
+
Requires a renderer (create once per chart render).
|
|
613
|
+
"""
|
|
614
|
+
if renderer is None:
|
|
615
|
+
# best-effort fallback (still avoids draw); may be None on some backends until first draw
|
|
616
|
+
renderer = getattr(ax.figure.canvas, "get_renderer", lambda: None)()
|
|
617
|
+
if renderer is None:
|
|
618
|
+
# can't measure; just place it and clip
|
|
619
|
+
t = ax.text(x + dx, y + dy, text, fontsize=fontsize, color=color, alpha=alpha,
|
|
620
|
+
transform=ax.get_transform("pixel"), clip_on=True)
|
|
621
|
+
if outline:
|
|
622
|
+
t.set_path_effects([pe.Stroke(linewidth=2.0, foreground=(0, 0, 0, 0.85)), pe.Normal()])
|
|
623
|
+
return t
|
|
624
|
+
|
|
625
|
+
t = ax.text(x + dx, y + dy, text, fontsize=fontsize, color=color, alpha=alpha,
|
|
626
|
+
transform=ax.get_transform("pixel"), clip_on=True)
|
|
627
|
+
if outline:
|
|
628
|
+
t.set_path_effects([pe.Stroke(linewidth=2.0, foreground=(0, 0, 0, 0.85)), pe.Normal()])
|
|
629
|
+
|
|
630
|
+
bb = t.get_window_extent(renderer=renderer)
|
|
631
|
+
inv = ax.get_transform("pixel").inverted()
|
|
632
|
+
|
|
633
|
+
(x0, y0) = inv.transform((bb.x0, bb.y0))
|
|
634
|
+
(x1, y1) = inv.transform((bb.x1, bb.y1))
|
|
635
|
+
|
|
636
|
+
shift_x = 0.0
|
|
637
|
+
shift_y = 0.0
|
|
638
|
+
|
|
639
|
+
if x0 < pad:
|
|
640
|
+
shift_x += (pad - x0)
|
|
641
|
+
if x1 > (out_px - pad):
|
|
642
|
+
shift_x -= (x1 - (out_px - pad))
|
|
643
|
+
if y0 < pad:
|
|
644
|
+
shift_y += (pad - y0)
|
|
645
|
+
if y1 > (out_px - pad):
|
|
646
|
+
shift_y -= (y1 - (out_px - pad))
|
|
647
|
+
|
|
648
|
+
if shift_x or shift_y:
|
|
649
|
+
t.set_position((x + dx + shift_x, y + dy + shift_y))
|
|
650
|
+
|
|
651
|
+
# Re-check once (no draw needed)
|
|
652
|
+
bb2 = t.get_window_extent(renderer=renderer)
|
|
653
|
+
(xx0, yy0) = inv.transform((bb2.x0, bb2.y0))
|
|
654
|
+
(xx1, yy1) = inv.transform((bb2.x1, bb2.y1))
|
|
655
|
+
if (xx0 < 0) or (yy0 < 0) or (xx1 > out_px) or (yy1 > out_px):
|
|
656
|
+
t.remove()
|
|
657
|
+
return None
|
|
658
|
+
|
|
659
|
+
return t
|
|
660
|
+
|
|
661
|
+
SURVEY_HIPS = {
|
|
662
|
+
# Optical
|
|
663
|
+
"DSS2 (Color)": "CDS/P/DSS2/color",
|
|
664
|
+
"DSS2 (Red)": "CDS/P/DSS2/red",
|
|
665
|
+
"DSS2 (Blue)": "CDS/P/DSS2/blue",
|
|
666
|
+
"DSS2 (IR)": "CDS/P/DSS2/IR",
|
|
667
|
+
"Pan-STARRS DR1 (Color)": "CDS/P/PanSTARRS/DR1/color",
|
|
668
|
+
"SDSS9 (Color Alt)": "CDS/P/SDSS9/color-alt",
|
|
669
|
+
"DESI Legacy DR10 (Color)": "CDS/P/DESI-Legacy-Surveys/DR10/color",
|
|
670
|
+
|
|
671
|
+
# Infrared
|
|
672
|
+
"2MASS (J)": "CDS/P/2MASS/J",
|
|
673
|
+
"2MASS (H)": "CDS/P/2MASS/H",
|
|
674
|
+
|
|
675
|
+
# Gaia
|
|
676
|
+
"Gaia DR3 (Flux Color)": "CDS/P/Gaia/DR3/flux-color",
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
def _survey_to_hips_id(label: str) -> str:
|
|
680
|
+
return SURVEY_HIPS.get(label, "CDS/P/DSS2/color")
|
|
681
|
+
|
|
682
|
+
def try_fetch_hips_cutout(center: "SkyCoord", fov_deg: float, out_px: int, survey_label: str):
|
|
683
|
+
"""
|
|
684
|
+
Returns (rgb_float01, bg_wcs_celestial, err)
|
|
685
|
+
- rgb_float01: (H,W,3) float32 [0..1] or None
|
|
686
|
+
- bg_wcs_celestial: WCS(2D) or None
|
|
687
|
+
- err: str or None
|
|
688
|
+
"""
|
|
689
|
+
hips_id = _survey_to_hips_id(survey_label)
|
|
690
|
+
|
|
691
|
+
try:
|
|
692
|
+
from astroquery.hips2fits import hips2fits
|
|
693
|
+
except Exception as e:
|
|
694
|
+
return None, None, f"{type(e).__name__}: {e}"
|
|
695
|
+
|
|
696
|
+
def _decode(hdul):
|
|
697
|
+
data = np.array(hdul[0].data, dtype=np.float32)
|
|
698
|
+
|
|
699
|
+
bg_wcs = None
|
|
700
|
+
try:
|
|
701
|
+
bg_wcs = WCS(hdul[0].header, relax=True)
|
|
702
|
+
if hasattr(bg_wcs, "celestial") and bg_wcs.celestial is not None:
|
|
703
|
+
bg_wcs = bg_wcs.celestial
|
|
704
|
+
except Exception:
|
|
705
|
+
bg_wcs = None
|
|
706
|
+
|
|
707
|
+
# Normalize to RGB
|
|
708
|
+
if data.ndim == 2:
|
|
709
|
+
data = np.repeat(data[..., None], 3, axis=2)
|
|
710
|
+
elif data.ndim == 3 and data.shape[0] in (3, 4):
|
|
711
|
+
data = np.transpose(data[:3, ...], (1, 2, 0))
|
|
712
|
+
elif data.ndim == 3 and data.shape[2] >= 3:
|
|
713
|
+
data = data[..., :3]
|
|
714
|
+
|
|
715
|
+
data = np.nan_to_num(data, nan=0.0, posinf=0.0, neginf=0.0)
|
|
716
|
+
|
|
717
|
+
lo, hi = np.percentile(data, [1.0, 99.5])
|
|
718
|
+
if hi > lo:
|
|
719
|
+
data = (data - lo) / (hi - lo)
|
|
720
|
+
|
|
721
|
+
return np.clip(data, 0.0, 1.0), bg_wcs
|
|
722
|
+
|
|
723
|
+
out_px = int(out_px)
|
|
724
|
+
ra_deg = float(center.ra.deg)
|
|
725
|
+
dec_deg = float(center.dec.deg)
|
|
726
|
+
|
|
727
|
+
# Prefer quantity for fov, but fall back if this build wants float
|
|
728
|
+
try:
|
|
729
|
+
import astropy.units as u
|
|
730
|
+
fov = float(fov_deg) * u.deg
|
|
731
|
+
except Exception:
|
|
732
|
+
fov = float(fov_deg)
|
|
733
|
+
|
|
734
|
+
last_err = None
|
|
735
|
+
|
|
736
|
+
# ---- Correct signature for YOUR astroquery 0.4.11 ----
|
|
737
|
+
# query(hips, width, height, projection, ra, dec, fov, *, coordsys='icrs', ...)
|
|
738
|
+
import astropy.units as u
|
|
739
|
+
from astropy.coordinates import Angle
|
|
740
|
+
|
|
741
|
+
out_px = int(out_px)
|
|
742
|
+
|
|
743
|
+
# IMPORTANT: pass Angle/Quantity, not floats
|
|
744
|
+
ra = center.ra.to(u.deg) # Angle
|
|
745
|
+
dec = center.dec.to(u.deg) # Angle
|
|
746
|
+
fov = Angle(float(fov_deg), unit=u.deg)
|
|
747
|
+
|
|
748
|
+
try:
|
|
749
|
+
hdul = hips2fits.query(
|
|
750
|
+
hips_id,
|
|
751
|
+
out_px, out_px,
|
|
752
|
+
"TAN",
|
|
753
|
+
ra, dec,
|
|
754
|
+
fov,
|
|
755
|
+
coordsys="icrs",
|
|
756
|
+
format="fits",
|
|
757
|
+
)
|
|
758
|
+
rgb01, bg_wcs = _decode(hdul)
|
|
759
|
+
return rgb01, bg_wcs, None
|
|
760
|
+
|
|
761
|
+
except Exception as e:
|
|
762
|
+
return None, None, f"{type(e).__name__}: {e}"
|
|
763
|
+
|
|
764
|
+
return None, None, last_err
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def render_finder_chart(doc_image: np.ndarray, meta: dict, req: FinderChartRequest) -> Optional[np.ndarray]:
|
|
768
|
+
if WCS is None:
|
|
769
|
+
return None
|
|
770
|
+
|
|
771
|
+
doc_wcs = get_doc_wcs(meta)
|
|
772
|
+
if doc_wcs is None:
|
|
773
|
+
return None
|
|
774
|
+
|
|
775
|
+
H, Wimg = doc_image.shape[:2]
|
|
776
|
+
corners, center = image_footprint_sky(doc_wcs, Wimg, H)
|
|
777
|
+
fov_w, fov_h = estimate_fov_deg(corners)
|
|
778
|
+
fov = max(fov_w, fov_h) * float(req.scale_mult)
|
|
779
|
+
|
|
780
|
+
# Fetch background (+ WCS + error string)
|
|
781
|
+
bg, bg_wcs, err = try_fetch_hips_cutout(center[0], fov_deg=fov, out_px=req.out_px, survey_label=req.survey)
|
|
782
|
+
|
|
783
|
+
import matplotlib
|
|
784
|
+
matplotlib.use("Agg")
|
|
785
|
+
import matplotlib.pyplot as plt
|
|
786
|
+
|
|
787
|
+
fig = plt.figure(figsize=(req.out_px / 100.0, req.out_px / 100.0), dpi=100)
|
|
788
|
+
ax = fig.add_axes([0, 0, 1, 1])
|
|
789
|
+
|
|
790
|
+
# --- Draw background OR error message ---
|
|
791
|
+
if bg is None:
|
|
792
|
+
ax.set_facecolor((0, 0, 0))
|
|
793
|
+
msg = "No HiPS background.\n" + (err or "Unknown error")
|
|
794
|
+
ax.text(0.5, 0.5, msg, ha="center", va="center", transform=ax.transAxes)
|
|
795
|
+
else:
|
|
796
|
+
# If you want doc overlay, do it here
|
|
797
|
+
if bg_wcs is not None and doc_wcs is not None:
|
|
798
|
+
try:
|
|
799
|
+
overlay_u8 = _overlay_doc_on_bg(bg, bg_wcs, doc_image, doc_wcs, alpha=req.overlay_opacity)
|
|
800
|
+
ax.imshow(overlay_u8, origin="lower")
|
|
801
|
+
except Exception:
|
|
802
|
+
# fallback to plain bg if overlay fails
|
|
803
|
+
ax.imshow(bg, origin="lower")
|
|
804
|
+
else:
|
|
805
|
+
ax.imshow(bg, origin="lower")
|
|
806
|
+
|
|
807
|
+
# Optional: draw WCS-correct footprint polygon
|
|
808
|
+
if bg_wcs is not None:
|
|
809
|
+
try:
|
|
810
|
+
xs, ys = bg_wcs.world_to_pixel(corners)
|
|
811
|
+
ax.plot(
|
|
812
|
+
[xs[0], xs[1], xs[2], xs[3], xs[0]],
|
|
813
|
+
[ys[0], ys[1], ys[2], ys[3], ys[0]],
|
|
814
|
+
linewidth=2
|
|
815
|
+
)
|
|
816
|
+
except Exception:
|
|
817
|
+
pass
|
|
818
|
+
|
|
819
|
+
# center crosshair (axes coords)
|
|
820
|
+
ax.plot([0.5], [0.5], marker="+", markersize=20, transform=ax.transAxes)
|
|
821
|
+
|
|
822
|
+
# labels
|
|
823
|
+
ra = float(center[0].ra.deg)
|
|
824
|
+
dec = float(center[0].dec.deg)
|
|
825
|
+
ax.text(
|
|
826
|
+
0.02, 0.98,
|
|
827
|
+
f"{req.survey} | {req.scale_mult}×FOV\nRA {ra:.6f}° Dec {dec:.6f}°\nFOV ~ {fov*60:.1f}′",
|
|
828
|
+
transform=ax.transAxes, va="top",
|
|
829
|
+
color="white",
|
|
830
|
+
bbox=dict(boxstyle="round,pad=0.25", facecolor=(0, 0, 0, 0.45), edgecolor=(1, 1, 1, 0.12))
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
if req.show_grid:
|
|
834
|
+
# keep axis ON so grid can render
|
|
835
|
+
ax.set_axis_on()
|
|
836
|
+
ax.set_xticks(np.linspace(0, req.out_px, 7))
|
|
837
|
+
ax.set_yticks(np.linspace(0, req.out_px, 7))
|
|
838
|
+
ax.grid(True, alpha=0.35)
|
|
839
|
+
# optionally hide tick labels but keep grid
|
|
840
|
+
ax.set_xticklabels([])
|
|
841
|
+
ax.set_yticklabels([])
|
|
842
|
+
else:
|
|
843
|
+
ax.set_xticks([])
|
|
844
|
+
ax.set_yticks([])
|
|
845
|
+
ax.set_axis_off()
|
|
846
|
+
|
|
847
|
+
fig.canvas.draw()
|
|
848
|
+
|
|
849
|
+
w, h = fig.canvas.get_width_height()
|
|
850
|
+
rgba = np.asarray(fig.canvas.buffer_rgba(), dtype=np.uint8).reshape(h, w, 4)
|
|
851
|
+
rgb = rgba[..., :3].copy()
|
|
852
|
+
|
|
853
|
+
plt.close(fig)
|
|
854
|
+
return rgb
|
|
855
|
+
|
|
856
|
+
def _to_u8_rgb(img: np.ndarray) -> np.ndarray:
|
|
857
|
+
a = np.asarray(img)
|
|
858
|
+
if a.ndim == 2:
|
|
859
|
+
a = np.repeat(a[..., None], 3, axis=2)
|
|
860
|
+
if a.shape[2] > 3:
|
|
861
|
+
a = a[..., :3]
|
|
862
|
+
a = a.astype(np.float32)
|
|
863
|
+
# simple robust normalize for display
|
|
864
|
+
lo, hi = np.percentile(a, [1.0, 99.5])
|
|
865
|
+
if hi > lo:
|
|
866
|
+
a = (a - lo) / (hi - lo)
|
|
867
|
+
a = np.clip(a, 0.0, 1.0)
|
|
868
|
+
return (a * 255.0 + 0.5).astype(np.uint8)
|
|
869
|
+
|
|
870
|
+
def _overlay_doc_on_bg(bg_rgb01: np.ndarray, bg_wcs: "WCS", doc_img: np.ndarray, doc_wcs: "WCS", alpha=0.35) -> np.ndarray:
|
|
871
|
+
import cv2
|
|
872
|
+
|
|
873
|
+
Hbg, Wbg = bg_rgb01.shape[:2]
|
|
874
|
+
bg_u8 = (np.clip(bg_rgb01, 0, 1) * 255.0 + 0.5).astype(np.uint8)
|
|
875
|
+
|
|
876
|
+
doc_u8 = _to_u8_rgb(doc_img)
|
|
877
|
+
H, W = doc_u8.shape[:2]
|
|
878
|
+
|
|
879
|
+
# doc pixel corners -> sky -> bg pixels
|
|
880
|
+
src = np.array([[0,0], [W-1,0], [W-1,H-1], [0,H-1]], dtype=np.float32)
|
|
881
|
+
sky = doc_wcs.pixel_to_world(src[:,0], src[:,1]) # SkyCoord
|
|
882
|
+
xbg, ybg = bg_wcs.world_to_pixel(sky)
|
|
883
|
+
dst = np.stack([xbg, ybg], axis=1).astype(np.float32)
|
|
884
|
+
|
|
885
|
+
M = cv2.getPerspectiveTransform(src, dst)
|
|
886
|
+
warped = cv2.warpPerspective(doc_u8, M, (Wbg, Hbg), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_TRANSPARENT)
|
|
887
|
+
|
|
888
|
+
# alpha blend where warped has content
|
|
889
|
+
mask = (warped.sum(axis=2) > 0).astype(np.float32)[..., None]
|
|
890
|
+
out = bg_u8.astype(np.float32) * (1 - alpha*mask) + warped.astype(np.float32) * (alpha*mask)
|
|
891
|
+
return np.clip(out, 0, 255).astype(np.uint8)
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _rgb_u8_to_qimage(rgb_u8: np.ndarray) -> QImage:
|
|
895
|
+
rgb_u8 = np.ascontiguousarray(rgb_u8)
|
|
896
|
+
h, w, _ = rgb_u8.shape
|
|
897
|
+
bpl = rgb_u8.strides[0]
|
|
898
|
+
# QImage uses the buffer; to be safe, copy via .copy() when making pixmap
|
|
899
|
+
return QImage(rgb_u8.data, w, h, bpl, QImage.Format.Format_RGB888)
|
|
900
|
+
|
|
901
|
+
def _draw_star_names(ax, bg_wcs: "WCS", center: "SkyCoord", fov_deg: float, *,
|
|
902
|
+
mag_limit: float = 2.0, max_labels: int = 30, renderer=None):
|
|
903
|
+
if bg_wcs is None:
|
|
904
|
+
return
|
|
905
|
+
|
|
906
|
+
import astropy.units as u
|
|
907
|
+
from astropy.coordinates import SkyCoord
|
|
908
|
+
|
|
909
|
+
ra0 = float(center.ra.deg)
|
|
910
|
+
dec0 = float(center.dec.deg)
|
|
911
|
+
radius = float(fov_deg) * 0.75
|
|
912
|
+
|
|
913
|
+
c0 = SkyCoord(ra0*u.deg, dec0*u.deg, frame="icrs") # <-- MOVE OUTSIDE LOOP
|
|
914
|
+
|
|
915
|
+
rows = []
|
|
916
|
+
for (name, ra, dec, vmag) in BRIGHT_STARS:
|
|
917
|
+
if float(vmag) > float(mag_limit):
|
|
918
|
+
continue
|
|
919
|
+
if abs(dec - dec0) > radius + 2.0:
|
|
920
|
+
continue
|
|
921
|
+
c1 = SkyCoord(float(ra)*u.deg, float(dec)*u.deg, frame="icrs")
|
|
922
|
+
if c0.separation(c1).deg <= radius:
|
|
923
|
+
rows.append((name, float(ra), float(dec), float(vmag)))
|
|
924
|
+
|
|
925
|
+
if not rows:
|
|
926
|
+
return
|
|
927
|
+
|
|
928
|
+
rows.sort(key=lambda r: r[3])
|
|
929
|
+
rows = rows[:max_labels]
|
|
930
|
+
|
|
931
|
+
coords = SkyCoord([r[1] for r in rows]*u.deg, [r[2] for r in rows]*u.deg, frame="icrs")
|
|
932
|
+
xs, ys = bg_wcs.world_to_pixel(coords)
|
|
933
|
+
|
|
934
|
+
out_px = int(getattr(ax.figure, "_sas_out_px", 0) or 0)
|
|
935
|
+
if out_px <= 0:
|
|
936
|
+
out_px = int(ax.figure.get_figwidth() * ax.figure.dpi)
|
|
937
|
+
|
|
938
|
+
kept = []
|
|
939
|
+
cell = 28
|
|
940
|
+
used = set()
|
|
941
|
+
|
|
942
|
+
for (row, x, y) in zip(rows, xs, ys):
|
|
943
|
+
x = float(x); y = float(y)
|
|
944
|
+
if not _inside_px(x, y, out_px, pad=0):
|
|
945
|
+
continue
|
|
946
|
+
gx = int(x // cell)
|
|
947
|
+
gy = int(y // cell)
|
|
948
|
+
key = (gx, gy)
|
|
949
|
+
if key in used:
|
|
950
|
+
continue
|
|
951
|
+
used.add(key)
|
|
952
|
+
kept.append((row[0], x, y))
|
|
953
|
+
if len(kept) >= int(max_labels):
|
|
954
|
+
break
|
|
955
|
+
|
|
956
|
+
for (name, x, y) in kept:
|
|
957
|
+
ax.plot([x], [y],
|
|
958
|
+
marker="o", markersize=2.5, alpha=0.85,
|
|
959
|
+
color="#ffb000",
|
|
960
|
+
transform=ax.get_transform("pixel"),
|
|
961
|
+
clip_on=True)
|
|
962
|
+
|
|
963
|
+
_place_label_inside(
|
|
964
|
+
ax, x, y, name,
|
|
965
|
+
dx=6, dy=4, fontsize=9,
|
|
966
|
+
color="#ffb000", alpha=0.95, outline=True,
|
|
967
|
+
out_px=out_px, pad=4,
|
|
968
|
+
renderer=renderer, # <-- PASS IT
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def render_finder_chart_cached(
|
|
973
|
+
*,
|
|
974
|
+
doc_image: np.ndarray,
|
|
975
|
+
doc_wcs: WCS,
|
|
976
|
+
corners: SkyCoord,
|
|
977
|
+
center: SkyCoord,
|
|
978
|
+
fov_deg: float,
|
|
979
|
+
req: FinderChartRequest,
|
|
980
|
+
bg: Optional[np.ndarray],
|
|
981
|
+
bg_wcs: Optional[WCS],
|
|
982
|
+
err: Optional[str],
|
|
983
|
+
base_u8: Optional[np.ndarray] = None, # <-- NEW
|
|
984
|
+
) -> np.ndarray:
|
|
985
|
+
import matplotlib
|
|
986
|
+
matplotlib.use("Agg")
|
|
987
|
+
import matplotlib.pyplot as plt
|
|
988
|
+
|
|
989
|
+
fig = plt.figure(figsize=(req.out_px / 100.0, req.out_px / 100.0), dpi=100)
|
|
990
|
+
fig._sas_out_px = int(req.out_px)
|
|
991
|
+
|
|
992
|
+
# Use WCSAxes when we have bg_wcs so we can draw RA/Dec labels & grid properly
|
|
993
|
+
if bg_wcs is not None:
|
|
994
|
+
ax = fig.add_subplot(111, projection=bg_wcs)
|
|
995
|
+
else:
|
|
996
|
+
ax = fig.add_axes([0, 0, 1, 1])
|
|
997
|
+
|
|
998
|
+
# ---- background (or error) ----
|
|
999
|
+
if bg is None:
|
|
1000
|
+
ax.set_facecolor((0, 0, 0))
|
|
1001
|
+
msg = "No HiPS background.\n" + (err or "Unknown error")
|
|
1002
|
+
ax.text(0.5, 0.5, msg, ha="center", va="center", transform=ax.transAxes)
|
|
1003
|
+
else:
|
|
1004
|
+
# If provided, base_u8 already includes hips + optional warped doc overlay.
|
|
1005
|
+
if base_u8 is not None:
|
|
1006
|
+
ax.imshow(base_u8, origin="lower")
|
|
1007
|
+
else:
|
|
1008
|
+
# fallback (old path)
|
|
1009
|
+
if bg_wcs is not None and doc_wcs is not None and req.overlay_opacity > 0.0:
|
|
1010
|
+
try:
|
|
1011
|
+
overlay_u8 = _overlay_doc_on_bg(bg, bg_wcs, doc_image, doc_wcs, alpha=req.overlay_opacity)
|
|
1012
|
+
ax.imshow(overlay_u8, origin="lower")
|
|
1013
|
+
except Exception:
|
|
1014
|
+
ax.imshow(bg, origin="lower")
|
|
1015
|
+
else:
|
|
1016
|
+
ax.imshow(bg, origin="lower")
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
# footprint polygon in pixel coords
|
|
1020
|
+
if bg_wcs is not None:
|
|
1021
|
+
try:
|
|
1022
|
+
xs, ys = bg_wcs.world_to_pixel(corners)
|
|
1023
|
+
ax.plot(
|
|
1024
|
+
[xs[0], xs[1], xs[2], xs[3], xs[0]],
|
|
1025
|
+
[ys[0], ys[1], ys[2], ys[3], ys[0]],
|
|
1026
|
+
linewidth=2,
|
|
1027
|
+
transform=ax.get_transform("pixel"),
|
|
1028
|
+
)
|
|
1029
|
+
except Exception:
|
|
1030
|
+
pass
|
|
1031
|
+
|
|
1032
|
+
# center crosshair
|
|
1033
|
+
ax.plot([0.5], [0.5], marker="+", markersize=20, transform=ax.transAxes)
|
|
1034
|
+
|
|
1035
|
+
# top-left info text
|
|
1036
|
+
ra = float(center[0].ra.deg)
|
|
1037
|
+
dec = float(center[0].dec.deg)
|
|
1038
|
+
ax.text(
|
|
1039
|
+
0.02, 0.98,
|
|
1040
|
+
f"{req.survey} | {req.scale_mult}×FOV\nRA {ra:.6f}° Dec {dec:.6f}°\nFOV ~ {fov_deg*60:.1f}′",
|
|
1041
|
+
transform=ax.transAxes, va="top"
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
# ---- grid + RA/Dec labels ----
|
|
1045
|
+
if bg_wcs is not None:
|
|
1046
|
+
# RA/Dec edge labels
|
|
1047
|
+
try:
|
|
1048
|
+
ax.coords[0].set_axislabel("RA")
|
|
1049
|
+
ax.coords[1].set_axislabel("Dec")
|
|
1050
|
+
except Exception:
|
|
1051
|
+
pass
|
|
1052
|
+
|
|
1053
|
+
# toggle grid lines
|
|
1054
|
+
try:
|
|
1055
|
+
ax.coords.grid(bool(req.show_grid), alpha=0.35)
|
|
1056
|
+
except Exception:
|
|
1057
|
+
pass
|
|
1058
|
+
|
|
1059
|
+
# If grid is off, you may still want edge tick labels; keep axis visible.
|
|
1060
|
+
# WCSAxes handles ticks/labels automatically.
|
|
1061
|
+
else:
|
|
1062
|
+
# fallback: pixel grid only
|
|
1063
|
+
if req.show_grid:
|
|
1064
|
+
ax.set_axis_on()
|
|
1065
|
+
ax.set_xticks(np.linspace(0, req.out_px, 7))
|
|
1066
|
+
ax.set_yticks(np.linspace(0, req.out_px, 7))
|
|
1067
|
+
ax.grid(True, alpha=0.35)
|
|
1068
|
+
ax.set_xticklabels([])
|
|
1069
|
+
ax.set_yticklabels([])
|
|
1070
|
+
else:
|
|
1071
|
+
ax.set_xticks([])
|
|
1072
|
+
ax.set_yticks([])
|
|
1073
|
+
ax.set_axis_off()
|
|
1074
|
+
|
|
1075
|
+
# After background + grid setup (before overlays that need bbox measurements)
|
|
1076
|
+
fig.canvas.draw()
|
|
1077
|
+
renderer = fig.canvas.get_renderer()
|
|
1078
|
+
|
|
1079
|
+
# ---- star names overlay ----
|
|
1080
|
+
if getattr(req, "show_star_names", False) and (bg_wcs is not None):
|
|
1081
|
+
try:
|
|
1082
|
+
_draw_star_names(
|
|
1083
|
+
ax, bg_wcs, center[0], fov_deg,
|
|
1084
|
+
mag_limit=float(getattr(req, "star_mag_limit", 2.0)),
|
|
1085
|
+
max_labels=int(getattr(req, "star_max_labels", 30)), renderer=renderer
|
|
1086
|
+
)
|
|
1087
|
+
except Exception:
|
|
1088
|
+
pass
|
|
1089
|
+
|
|
1090
|
+
# ---- deep-sky overlay ----
|
|
1091
|
+
if getattr(req, "show_dso", False):
|
|
1092
|
+
|
|
1093
|
+
if bg_wcs is not None:
|
|
1094
|
+
try:
|
|
1095
|
+
_draw_dso_overlay(ax, bg_wcs, center[0], fov_deg, req, renderer=renderer)
|
|
1096
|
+
except Exception as e:
|
|
1097
|
+
print(f"[DSO] overlay error: {type(e).__name__}: {e}")
|
|
1098
|
+
|
|
1099
|
+
# ---- compass + scale bar ----
|
|
1100
|
+
if bg_wcs is not None and getattr(req, "show_compass", True):
|
|
1101
|
+
try:
|
|
1102
|
+
_draw_compass_NE(ax, bg_wcs, int(req.out_px), float(fov_deg))
|
|
1103
|
+
except Exception:
|
|
1104
|
+
pass
|
|
1105
|
+
|
|
1106
|
+
if bg_wcs is not None and getattr(req, "show_scale_bar", True):
|
|
1107
|
+
try:
|
|
1108
|
+
_draw_scale_bar(ax, bg_wcs, int(req.out_px), float(fov_deg))
|
|
1109
|
+
except Exception:
|
|
1110
|
+
pass
|
|
1111
|
+
|
|
1112
|
+
fig.canvas.draw()
|
|
1113
|
+
w, h = fig.canvas.get_width_height()
|
|
1114
|
+
rgba = np.asarray(fig.canvas.buffer_rgba(), dtype=np.uint8).reshape(h, w, 4)
|
|
1115
|
+
rgb = rgba[..., :3].copy()
|
|
1116
|
+
plt.close(fig)
|
|
1117
|
+
return rgb
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
class FinderChartDialog(QDialog):
|
|
1121
|
+
"""
|
|
1122
|
+
Minimal v1 Finder Chart dialog:
|
|
1123
|
+
- Survey dropdown
|
|
1124
|
+
- Size multiplier dropdown
|
|
1125
|
+
- Show grid checkbox
|
|
1126
|
+
- Render preview
|
|
1127
|
+
- Save PNG
|
|
1128
|
+
- Send to New Document (push into SASpro)
|
|
1129
|
+
"""
|
|
1130
|
+
def __init__(self, doc, settings, parent=None):
|
|
1131
|
+
super().__init__(parent)
|
|
1132
|
+
self._doc = doc
|
|
1133
|
+
self._settings = settings
|
|
1134
|
+
self._last_rgb_u8: Optional[np.ndarray] = None
|
|
1135
|
+
# ---- HiPS cache (avoid refetching for UI-only changes) ----
|
|
1136
|
+
self._hips_cache_key = None
|
|
1137
|
+
self._hips_bg = None # float01 RGB background
|
|
1138
|
+
self._hips_wcs = None # celestial WCS for background
|
|
1139
|
+
self._hips_err = None
|
|
1140
|
+
self._render_timer = QTimer(self)
|
|
1141
|
+
self._render_timer.setSingleShot(True)
|
|
1142
|
+
self._render_timer.timeout.connect(self._render_debounced_fire)
|
|
1143
|
+
self._pending_force_refetch = False
|
|
1144
|
+
self._base_cache_key = None
|
|
1145
|
+
self._base_u8 = None
|
|
1146
|
+
# Cached geometry derived from the current doc WCS (used for overlays/labels)
|
|
1147
|
+
self._doc_wcs_cached = None
|
|
1148
|
+
self._corners_cached = None
|
|
1149
|
+
self._center_cached = None
|
|
1150
|
+
self._fov_deg_cached = None
|
|
1151
|
+
self.setWindowTitle("Finder Chart…")
|
|
1152
|
+
self.setModal(False)
|
|
1153
|
+
self.resize(920, 980)
|
|
1154
|
+
|
|
1155
|
+
root = QVBoxLayout(self)
|
|
1156
|
+
|
|
1157
|
+
# controls row 1 (primary)
|
|
1158
|
+
row1 = QHBoxLayout()
|
|
1159
|
+
|
|
1160
|
+
row1.addWidget(QLabel("Survey:"))
|
|
1161
|
+
self.cmb_survey = QComboBox()
|
|
1162
|
+
self.cmb_survey.clear()
|
|
1163
|
+
self.cmb_survey.addItems(list(SURVEY_HIPS.keys()))
|
|
1164
|
+
row1.addWidget(self.cmb_survey)
|
|
1165
|
+
|
|
1166
|
+
row1.addSpacing(12)
|
|
1167
|
+
row1.addWidget(QLabel("Size:"))
|
|
1168
|
+
self.cmb_size = QComboBox()
|
|
1169
|
+
self.cmb_size.addItems(["2× FOV", "4× FOV", "8× FOV"])
|
|
1170
|
+
row1.addWidget(self.cmb_size)
|
|
1171
|
+
|
|
1172
|
+
row1.addSpacing(12)
|
|
1173
|
+
self.chk_grid = QCheckBox("Show grid")
|
|
1174
|
+
row1.addWidget(self.chk_grid)
|
|
1175
|
+
|
|
1176
|
+
row1.addSpacing(12)
|
|
1177
|
+
row1.addWidget(QLabel("Output px:"))
|
|
1178
|
+
self.sb_px = QSpinBox()
|
|
1179
|
+
self.sb_px.setRange(300, 2400)
|
|
1180
|
+
self.sb_px.setSingleStep(100)
|
|
1181
|
+
self.sb_px.setValue(900)
|
|
1182
|
+
row1.addWidget(self.sb_px)
|
|
1183
|
+
row1.addSpacing(12)
|
|
1184
|
+
row1.addWidget(QLabel("Image opacity:"))
|
|
1185
|
+
self.sld_opacity = QSlider(Qt.Orientation.Horizontal)
|
|
1186
|
+
self.sld_opacity.setRange(0, 100)
|
|
1187
|
+
self.sld_opacity.setValue(35)
|
|
1188
|
+
self.sld_opacity.setFixedWidth(140)
|
|
1189
|
+
row1.addWidget(self.sld_opacity)
|
|
1190
|
+
|
|
1191
|
+
self.lbl_opacity = QLabel("35%")
|
|
1192
|
+
self.lbl_opacity.setFixedWidth(40)
|
|
1193
|
+
row1.addWidget(self.lbl_opacity)
|
|
1194
|
+
row1.addStretch(1)
|
|
1195
|
+
self.btn_render = QPushButton("Render")
|
|
1196
|
+
row1.addWidget(self.btn_render)
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
root.addLayout(row1)
|
|
1200
|
+
|
|
1201
|
+
# controls row 2 (overlays)
|
|
1202
|
+
row2 = QHBoxLayout()
|
|
1203
|
+
|
|
1204
|
+
self.chk_stars = QCheckBox("Star names")
|
|
1205
|
+
row2.addWidget(self.chk_stars)
|
|
1206
|
+
|
|
1207
|
+
row2.addSpacing(8)
|
|
1208
|
+
row2.addWidget(QLabel("Star ≤"))
|
|
1209
|
+
self.sb_star_mag = QDoubleSpinBox()
|
|
1210
|
+
self.sb_star_mag.setRange(-2.0, 8.0)
|
|
1211
|
+
self.sb_star_mag.setSingleStep(0.5)
|
|
1212
|
+
self.sb_star_mag.setValue(5.0)
|
|
1213
|
+
self.sb_star_mag.setFixedWidth(70)
|
|
1214
|
+
row2.addWidget(self.sb_star_mag)
|
|
1215
|
+
|
|
1216
|
+
row2.addWidget(QLabel("Max"))
|
|
1217
|
+
self.sb_star_max = QSpinBox()
|
|
1218
|
+
self.sb_star_max.setRange(5, 200)
|
|
1219
|
+
self.sb_star_max.setValue(30)
|
|
1220
|
+
self.sb_star_max.setFixedWidth(60)
|
|
1221
|
+
row2.addWidget(self.sb_star_max)
|
|
1222
|
+
|
|
1223
|
+
row2.addSpacing(12)
|
|
1224
|
+
self.chk_dso = QCheckBox("Deep-sky")
|
|
1225
|
+
row2.addWidget(self.chk_dso)
|
|
1226
|
+
|
|
1227
|
+
self.cmb_dso = QComboBox()
|
|
1228
|
+
self.cmb_dso.addItems(["All (DSO)", "M", "NGC", "IC", "Abell", "SH2", "LBN", "LDN", "PN-G"])
|
|
1229
|
+
self.cmb_dso.setFixedWidth(170)
|
|
1230
|
+
row2.addWidget(self.cmb_dso)
|
|
1231
|
+
|
|
1232
|
+
row2.addWidget(QLabel("Mag ≤"))
|
|
1233
|
+
self.sb_dso_mag = QDoubleSpinBox()
|
|
1234
|
+
self.sb_dso_mag.setRange(-2.0, 30.0)
|
|
1235
|
+
self.sb_dso_mag.setSingleStep(0.5)
|
|
1236
|
+
self.sb_dso_mag.setValue(12.0)
|
|
1237
|
+
self.sb_dso_mag.setFixedWidth(70)
|
|
1238
|
+
row2.addWidget(self.sb_dso_mag)
|
|
1239
|
+
|
|
1240
|
+
row2.addWidget(QLabel("Max"))
|
|
1241
|
+
self.sb_dso_max = QSpinBox()
|
|
1242
|
+
self.sb_dso_max.setRange(5, 300)
|
|
1243
|
+
self.sb_dso_max.setValue(30)
|
|
1244
|
+
self.sb_dso_max.setFixedWidth(60)
|
|
1245
|
+
row2.addWidget(self.sb_dso_max)
|
|
1246
|
+
|
|
1247
|
+
row2.addSpacing(12)
|
|
1248
|
+
self.chk_compass = QCheckBox("Compass")
|
|
1249
|
+
self.chk_compass.setChecked(True)
|
|
1250
|
+
row2.addWidget(self.chk_compass)
|
|
1251
|
+
|
|
1252
|
+
self.chk_scale = QCheckBox("Scale bar")
|
|
1253
|
+
self.chk_scale.setChecked(True)
|
|
1254
|
+
row2.addWidget(self.chk_scale)
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
row2.addStretch(1)
|
|
1259
|
+
root.addLayout(row2)
|
|
1260
|
+
|
|
1261
|
+
# preview
|
|
1262
|
+
self.lbl = QLabel()
|
|
1263
|
+
self.lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
1264
|
+
self.lbl.setMinimumHeight(700)
|
|
1265
|
+
self.lbl.setStyleSheet("QLabel { background:#111; border:1px solid #333; }")
|
|
1266
|
+
root.addWidget(self.lbl, 1)
|
|
1267
|
+
|
|
1268
|
+
# buttons
|
|
1269
|
+
brow = QHBoxLayout()
|
|
1270
|
+
|
|
1271
|
+
self.lbl_status = QLabel("") # <-- NEW
|
|
1272
|
+
self.lbl_status.setStyleSheet("color:#bbb;") # subtle
|
|
1273
|
+
self.lbl_status.setMinimumWidth(160)
|
|
1274
|
+
brow.addWidget(self.lbl_status) # <-- NEW
|
|
1275
|
+
|
|
1276
|
+
brow.addStretch(1)
|
|
1277
|
+
|
|
1278
|
+
self.btn_send = QPushButton("Send to New Document")
|
|
1279
|
+
self.btn_save = QPushButton("Save PNG…")
|
|
1280
|
+
self.btn_close = QPushButton("Close")
|
|
1281
|
+
brow.addWidget(self.btn_send)
|
|
1282
|
+
brow.addWidget(self.btn_save)
|
|
1283
|
+
brow.addWidget(self.btn_close)
|
|
1284
|
+
root.addLayout(brow)
|
|
1285
|
+
|
|
1286
|
+
self.btn_render.clicked.connect(lambda: self._render_now(force_refetch=True))
|
|
1287
|
+
self.btn_save.clicked.connect(self._save_png)
|
|
1288
|
+
self.btn_send.clicked.connect(self._send_to_new_doc)
|
|
1289
|
+
self.btn_close.clicked.connect(self.close)
|
|
1290
|
+
|
|
1291
|
+
# grid: debounced (no refetch)
|
|
1292
|
+
self.chk_grid.toggled.connect(lambda _=False: self._schedule_render(force_refetch=False, delay_ms=150))
|
|
1293
|
+
|
|
1294
|
+
# survey/size/px: immediate refetch (or debounce if you want, but refetch is required)
|
|
1295
|
+
self.cmb_survey.currentIndexChanged.connect(lambda _=0: self._render_now(force_refetch=True))
|
|
1296
|
+
self.cmb_size.currentIndexChanged.connect(lambda _=0: self._render_now(force_refetch=True))
|
|
1297
|
+
self.sb_px.valueChanged.connect(lambda _=0: self._render_now(force_refetch=True))
|
|
1298
|
+
|
|
1299
|
+
# opacity: update label + debounce render (no refetch)
|
|
1300
|
+
self.sld_opacity.valueChanged.connect(self._on_opacity_changed)
|
|
1301
|
+
self.sld_opacity.sliderReleased.connect(lambda: self._render_now(force_refetch=False))
|
|
1302
|
+
# auto render once
|
|
1303
|
+
# placeholder so the user sees *something* immediately
|
|
1304
|
+
self.lbl.setText("Fetching survey background…")
|
|
1305
|
+
self.lbl.setStyleSheet("QLabel { background:#111; border:1px solid #333; color:#ccc; }")
|
|
1306
|
+
# --- overlay enable/disable: update enabled state + one refresh ---
|
|
1307
|
+
self.chk_stars.toggled.connect(lambda _=False: (self._set_overlay_controls_enabled(),
|
|
1308
|
+
self._schedule_render(force_refetch=False, delay_ms=150)))
|
|
1309
|
+
|
|
1310
|
+
self.chk_dso.toggled.connect(lambda _=False: (self._set_overlay_controls_enabled(),
|
|
1311
|
+
self._schedule_render(force_refetch=False, delay_ms=150)))
|
|
1312
|
+
|
|
1313
|
+
# --- star params: ONLY refresh if Star names is ON ---
|
|
1314
|
+
self.sb_star_mag.valueChanged.connect(lambda _=0: self._maybe_schedule_stars(150))
|
|
1315
|
+
self.sb_star_max.valueChanged.connect(lambda _=0: self._maybe_schedule_stars(150))
|
|
1316
|
+
|
|
1317
|
+
# --- dso params: ONLY refresh if Deep-sky is ON ---
|
|
1318
|
+
self.cmb_dso.currentIndexChanged.connect(lambda _=0: self._maybe_schedule_dso(150))
|
|
1319
|
+
self.sb_dso_mag.valueChanged.connect(lambda _=0: self._maybe_schedule_dso(150))
|
|
1320
|
+
self.sb_dso_max.valueChanged.connect(lambda _=0: self._maybe_schedule_dso(150))
|
|
1321
|
+
|
|
1322
|
+
self.chk_compass.toggled.connect(lambda _=False: self._schedule_render(force_refetch=False, delay_ms=150))
|
|
1323
|
+
self.chk_scale.toggled.connect(lambda _=False: self._schedule_render(force_refetch=False, delay_ms=150))
|
|
1324
|
+
|
|
1325
|
+
self._set_overlay_controls_enabled()
|
|
1326
|
+
# kick initial render AFTER the dialog has had a chance to show/paint
|
|
1327
|
+
QTimer.singleShot(0, self._initial_render)
|
|
1328
|
+
|
|
1329
|
+
def _maybe_schedule_stars(self, delay_ms: int = 150):
|
|
1330
|
+
# Only auto-refresh if the overlay is enabled
|
|
1331
|
+
if not self.chk_stars.isChecked():
|
|
1332
|
+
return
|
|
1333
|
+
self._schedule_render(force_refetch=False, delay_ms=delay_ms)
|
|
1334
|
+
|
|
1335
|
+
def _maybe_schedule_dso(self, delay_ms: int = 150):
|
|
1336
|
+
# Only auto-refresh if the overlay is enabled
|
|
1337
|
+
if not self.chk_dso.isChecked():
|
|
1338
|
+
return
|
|
1339
|
+
self._schedule_render(force_refetch=False, delay_ms=delay_ms)
|
|
1340
|
+
|
|
1341
|
+
def _set_overlay_controls_enabled(self):
|
|
1342
|
+
stars_on = self.chk_stars.isChecked()
|
|
1343
|
+
self.sb_star_mag.setEnabled(stars_on)
|
|
1344
|
+
self.sb_star_max.setEnabled(stars_on)
|
|
1345
|
+
|
|
1346
|
+
dso_on = self.chk_dso.isChecked()
|
|
1347
|
+
self.cmb_dso.setEnabled(dso_on)
|
|
1348
|
+
self.sb_dso_mag.setEnabled(dso_on)
|
|
1349
|
+
self.sb_dso_max.setEnabled(dso_on)
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def _set_busy(self, busy: bool, msg: str = "Rendering…"):
|
|
1353
|
+
self.btn_render.setEnabled(not busy)
|
|
1354
|
+
self.btn_send.setEnabled((not busy) and (self._last_rgb_u8 is not None))
|
|
1355
|
+
self.btn_save.setEnabled((not busy) and (self._last_rgb_u8 is not None))
|
|
1356
|
+
|
|
1357
|
+
if hasattr(self, "lbl_status") and self.lbl_status is not None:
|
|
1358
|
+
self.lbl_status.setText(msg if busy else "")
|
|
1359
|
+
self.lbl_status.setVisible(True)
|
|
1360
|
+
|
|
1361
|
+
# ensures the label paints immediately before heavy work
|
|
1362
|
+
QApplication.processEvents()
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def _initial_render(self):
|
|
1366
|
+
self._set_busy(True, "Fetching survey background…")
|
|
1367
|
+
# schedule again so the UI paints the busy message + cursor first
|
|
1368
|
+
QTimer.singleShot(0, lambda: self._render_now(force_refetch=True))
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
def _schedule_render(self, *, force_refetch: bool = False, delay_ms: int = 200):
|
|
1372
|
+
# show immediate feedback during debounce
|
|
1373
|
+
if hasattr(self, "lbl_status") and self.lbl_status is not None:
|
|
1374
|
+
self.lbl_status.setText("Rendering…")
|
|
1375
|
+
QApplication.processEvents()
|
|
1376
|
+
|
|
1377
|
+
self._pending_force_refetch = self._pending_force_refetch or bool(force_refetch)
|
|
1378
|
+
self._render_timer.stop()
|
|
1379
|
+
self._render_timer.start(int(delay_ms))
|
|
1380
|
+
|
|
1381
|
+
def _render_debounced_fire(self):
|
|
1382
|
+
force = bool(self._pending_force_refetch)
|
|
1383
|
+
self._pending_force_refetch = False
|
|
1384
|
+
self._render(force_refetch=force)
|
|
1385
|
+
|
|
1386
|
+
def _render_now(self, *, force_refetch: bool = False):
|
|
1387
|
+
# Cancel any pending debounced render and render immediately
|
|
1388
|
+
self._render_timer.stop()
|
|
1389
|
+
self._pending_force_refetch = False
|
|
1390
|
+
self._render(force_refetch=force_refetch)
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def _compute_doc_geometry(self, img: np.ndarray, meta: dict, req: FinderChartRequest):
|
|
1394
|
+
doc_wcs = get_doc_wcs(meta)
|
|
1395
|
+
if doc_wcs is None:
|
|
1396
|
+
return None, None, None, None
|
|
1397
|
+
|
|
1398
|
+
H, Wimg = img.shape[:2]
|
|
1399
|
+
corners, center = image_footprint_sky(doc_wcs, Wimg, H)
|
|
1400
|
+
fov_w, fov_h = estimate_fov_deg(corners)
|
|
1401
|
+
fov = max(fov_w, fov_h) * float(req.scale_mult)
|
|
1402
|
+
return doc_wcs, corners, center, float(fov)
|
|
1403
|
+
|
|
1404
|
+
def _ensure_hips_background(self, req: FinderChartRequest, center: SkyCoord, fov_deg: float, *, force: bool = False):
|
|
1405
|
+
# Overscan factor: enough to cover the half-diagonal circle of the final square
|
|
1406
|
+
# sqrt(2) covers exactly; add a hair for safety near edges.
|
|
1407
|
+
s = float(math.sqrt(2.0) * 1.02)
|
|
1408
|
+
|
|
1409
|
+
out_px = int(req.out_px)
|
|
1410
|
+
fetch_px = int(math.ceil(out_px * s))
|
|
1411
|
+
|
|
1412
|
+
# IMPORTANT: scale FOV by the same factor so arcsec/px stays the same
|
|
1413
|
+
fetch_fov = float(fov_deg) * s
|
|
1414
|
+
|
|
1415
|
+
# Cache key must reflect the *fetch* parameters, not just final output
|
|
1416
|
+
key = (
|
|
1417
|
+
str(req.survey),
|
|
1418
|
+
int(fetch_px),
|
|
1419
|
+
round(float(center.ra.deg), 8),
|
|
1420
|
+
round(float(center.dec.deg), 8),
|
|
1421
|
+
round(float(fetch_fov), 8),
|
|
1422
|
+
)
|
|
1423
|
+
|
|
1424
|
+
if (not force) and (self._hips_cache_key == key) and (self._hips_bg is not None):
|
|
1425
|
+
return
|
|
1426
|
+
|
|
1427
|
+
bg_big, wcs_big, err = try_fetch_hips_cutout(
|
|
1428
|
+
center,
|
|
1429
|
+
fov_deg=fetch_fov,
|
|
1430
|
+
out_px=fetch_px,
|
|
1431
|
+
survey_label=req.survey,
|
|
1432
|
+
)
|
|
1433
|
+
|
|
1434
|
+
if bg_big is not None:
|
|
1435
|
+
# Crop back down to the user-requested size, and shift WCS accordingly
|
|
1436
|
+
bg, wcs_cropped, _ = _crop_center(bg_big, wcs_big, out_px)
|
|
1437
|
+
else:
|
|
1438
|
+
bg, wcs_cropped = None, None
|
|
1439
|
+
|
|
1440
|
+
self._hips_cache_key = key
|
|
1441
|
+
self._hips_bg = bg
|
|
1442
|
+
self._hips_wcs = wcs_cropped
|
|
1443
|
+
self._hips_err = err
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
def _doc_key(self, img: np.ndarray, meta: dict) -> tuple:
|
|
1447
|
+
# cheap-ish: shape + metadata wcs fingerprint (or header checksum if you have one)
|
|
1448
|
+
w = meta.get("wcs")
|
|
1449
|
+
w_id = id(w) if w is not None else id(meta.get("original_header") or meta.get("fits_header") or meta.get("header"))
|
|
1450
|
+
return (img.shape, w_id, int(self.cmb_size.currentIndex())) # size affects scale_mult
|
|
1451
|
+
|
|
1452
|
+
def _compute_doc_geometry_cached(self, img, meta, req):
|
|
1453
|
+
key = self._doc_key(img, meta)
|
|
1454
|
+
if getattr(self, "_geom_cache_key", None) == key and self._doc_wcs_cached is not None:
|
|
1455
|
+
return self._doc_wcs_cached, self._corners_cached, self._center_cached, self._fov_deg_cached
|
|
1456
|
+
|
|
1457
|
+
doc_wcs, corners, center, fov_deg = self._compute_doc_geometry(img, meta, req)
|
|
1458
|
+
self._geom_cache_key = key
|
|
1459
|
+
self._doc_wcs_cached, self._corners_cached, self._center_cached, self._fov_deg_cached = doc_wcs, corners, center, fov_deg
|
|
1460
|
+
return doc_wcs, corners, center, fov_deg
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def _req(self) -> FinderChartRequest:
|
|
1464
|
+
survey = str(self.cmb_survey.currentText())
|
|
1465
|
+
mult = {0: 2, 1: 4, 2: 8}.get(int(self.cmb_size.currentIndex()), 2)
|
|
1466
|
+
show_grid = bool(self.chk_grid.isChecked())
|
|
1467
|
+
out_px = int(self.sb_px.value())
|
|
1468
|
+
overlay_opacity = float(self.sld_opacity.value()) / 100.0
|
|
1469
|
+
|
|
1470
|
+
return FinderChartRequest(
|
|
1471
|
+
survey=survey,
|
|
1472
|
+
scale_mult=mult,
|
|
1473
|
+
show_grid=show_grid,
|
|
1474
|
+
|
|
1475
|
+
show_star_names=bool(self.chk_stars.isChecked()),
|
|
1476
|
+
star_mag_limit=float(self.sb_star_mag.value()),
|
|
1477
|
+
star_max_labels=int(self.sb_star_max.value()),
|
|
1478
|
+
|
|
1479
|
+
show_dso=bool(self.chk_dso.isChecked()),
|
|
1480
|
+
dso_catalog=str(self.cmb_dso.currentText()),
|
|
1481
|
+
dso_mag_limit=float(self.sb_dso_mag.value()),
|
|
1482
|
+
dso_max_labels=int(self.sb_dso_max.value()),
|
|
1483
|
+
|
|
1484
|
+
show_compass=bool(self.chk_compass.isChecked()),
|
|
1485
|
+
show_scale_bar=bool(self.chk_scale.isChecked()),
|
|
1486
|
+
|
|
1487
|
+
out_px=out_px,
|
|
1488
|
+
overlay_opacity=overlay_opacity,
|
|
1489
|
+
)
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
def _render(self, *, force_refetch: bool = False):
|
|
1493
|
+
self._set_busy(True, "Rendering finder chart…")
|
|
1494
|
+
try:
|
|
1495
|
+
img = np.asarray(self._doc.image)
|
|
1496
|
+
meta = dict(getattr(self._doc, "metadata", None) or {})
|
|
1497
|
+
req = self._req()
|
|
1498
|
+
|
|
1499
|
+
# 1) compute geometry from doc WCS
|
|
1500
|
+
doc_wcs, corners, center, fov_deg = self._compute_doc_geometry_cached(img, meta, req)
|
|
1501
|
+
if doc_wcs is None:
|
|
1502
|
+
QMessageBox.warning(self, "Finder Chart", "Could not render finder chart (missing WCS).")
|
|
1503
|
+
return
|
|
1504
|
+
|
|
1505
|
+
# cache these for reuse (overlay / footprint / labels)
|
|
1506
|
+
self._doc_wcs_cached = doc_wcs
|
|
1507
|
+
self._corners_cached = corners
|
|
1508
|
+
self._center_cached = center
|
|
1509
|
+
self._fov_deg_cached = fov_deg
|
|
1510
|
+
|
|
1511
|
+
# 2) fetch background only if needed
|
|
1512
|
+
self._ensure_hips_background(req, center[0], fov_deg, force=force_refetch)
|
|
1513
|
+
# 2.5) build/cache base raster (NO re-warp on overlay toggles)
|
|
1514
|
+
self._ensure_base_raster(req, img, doc_wcs, corners, center, fov_deg)
|
|
1515
|
+
# 3) render using cached background (NO network)
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
rgb = render_finder_chart_cached(
|
|
1519
|
+
doc_image=img,
|
|
1520
|
+
doc_wcs=doc_wcs,
|
|
1521
|
+
corners=corners,
|
|
1522
|
+
center=center,
|
|
1523
|
+
fov_deg=fov_deg,
|
|
1524
|
+
req=req,
|
|
1525
|
+
bg=self._hips_bg,
|
|
1526
|
+
bg_wcs=self._hips_wcs,
|
|
1527
|
+
err=self._hips_err,
|
|
1528
|
+
base_u8=self._base_u8,
|
|
1529
|
+
)
|
|
1530
|
+
|
|
1531
|
+
self._last_rgb_u8 = rgb
|
|
1532
|
+
qimg = _rgb_u8_to_qimage(rgb).copy()
|
|
1533
|
+
self.lbl.setPixmap(QPixmap.fromImage(qimg))
|
|
1534
|
+
|
|
1535
|
+
except Exception as e:
|
|
1536
|
+
QMessageBox.critical(self, "Finder Chart", str(e))
|
|
1537
|
+
finally:
|
|
1538
|
+
self._set_busy(False)
|
|
1539
|
+
|
|
1540
|
+
def _ensure_base_raster(self, req, img, doc_wcs, corners, center, fov_deg):
|
|
1541
|
+
doc_sig = (img.shape, str(type(self._doc)), id(self._doc))
|
|
1542
|
+
base_key = (
|
|
1543
|
+
self._hips_cache_key, # ties to survey/out_px/center/fov
|
|
1544
|
+
round(req.overlay_opacity, 4),
|
|
1545
|
+
id(doc_wcs),
|
|
1546
|
+
doc_sig,
|
|
1547
|
+
)
|
|
1548
|
+
if getattr(self, "_base_cache_key", None) == base_key:
|
|
1549
|
+
return
|
|
1550
|
+
|
|
1551
|
+
if self._hips_bg is None:
|
|
1552
|
+
self._base_u8 = None
|
|
1553
|
+
self._base_cache_key = base_key
|
|
1554
|
+
return
|
|
1555
|
+
|
|
1556
|
+
if self._hips_wcs is not None and req.overlay_opacity > 0:
|
|
1557
|
+
self._base_u8 = _overlay_doc_on_bg(self._hips_bg, self._hips_wcs, img, doc_wcs, alpha=req.overlay_opacity)
|
|
1558
|
+
else:
|
|
1559
|
+
self._base_u8 = (np.clip(self._hips_bg, 0, 1) * 255.0 + 0.5).astype(np.uint8)
|
|
1560
|
+
|
|
1561
|
+
self._base_cache_key = base_key
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
def _on_opacity_changed(self, v: int):
|
|
1565
|
+
self.lbl_opacity.setText(f"{int(v)}%")
|
|
1566
|
+
self._schedule_render(delay_ms=200) # no force refetch
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
def _save_png(self):
|
|
1570
|
+
if self._last_rgb_u8 is None:
|
|
1571
|
+
QMessageBox.information(self, "Finder Chart", "Nothing rendered yet.")
|
|
1572
|
+
return
|
|
1573
|
+
|
|
1574
|
+
start_dir = ""
|
|
1575
|
+
try:
|
|
1576
|
+
start_dir = self._settings.value("finder_chart/last_dir", "", type=str) or ""
|
|
1577
|
+
except Exception:
|
|
1578
|
+
start_dir = ""
|
|
1579
|
+
|
|
1580
|
+
fn, _ = QFileDialog.getSaveFileName(self, "Save Finder Chart", start_dir, "PNG Image (*.png)")
|
|
1581
|
+
if not fn:
|
|
1582
|
+
return
|
|
1583
|
+
|
|
1584
|
+
try:
|
|
1585
|
+
if not fn.lower().endswith(".png"):
|
|
1586
|
+
fn += ".png"
|
|
1587
|
+
qimg = _rgb_u8_to_qimage(self._last_rgb_u8).copy()
|
|
1588
|
+
ok = qimg.save(fn, "PNG")
|
|
1589
|
+
if not ok:
|
|
1590
|
+
raise RuntimeError("QImage.save() failed.")
|
|
1591
|
+
try:
|
|
1592
|
+
self._settings.setValue("finder_chart/last_dir", fn)
|
|
1593
|
+
self._settings.sync()
|
|
1594
|
+
except Exception:
|
|
1595
|
+
pass
|
|
1596
|
+
except Exception as e:
|
|
1597
|
+
QMessageBox.critical(self, "Finder Chart", str(e))
|
|
1598
|
+
|
|
1599
|
+
def _send_to_new_doc(self):
|
|
1600
|
+
if self._last_rgb_u8 is None:
|
|
1601
|
+
QMessageBox.information(self, "Finder Chart", "Nothing rendered yet.")
|
|
1602
|
+
return
|
|
1603
|
+
|
|
1604
|
+
img01 = self._last_rgb_u8.astype(np.float32) / 255.0
|
|
1605
|
+
|
|
1606
|
+
req = self._req()
|
|
1607
|
+
meta = {
|
|
1608
|
+
"step_name": "Finder Chart",
|
|
1609
|
+
"finder_chart": {
|
|
1610
|
+
"survey": req.survey,
|
|
1611
|
+
"scale_mult": req.scale_mult,
|
|
1612
|
+
"show_grid": req.show_grid,
|
|
1613
|
+
"out_px": req.out_px,
|
|
1614
|
+
"overlay_opacity": req.overlay_opacity,
|
|
1615
|
+
},
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
dm = self._get_doc_manager()
|
|
1619
|
+
if dm is None:
|
|
1620
|
+
QMessageBox.warning(self, "Finder Chart", "DocManager not found.")
|
|
1621
|
+
return
|
|
1622
|
+
|
|
1623
|
+
title = f"Finder Chart ({req.survey})"
|
|
1624
|
+
|
|
1625
|
+
try:
|
|
1626
|
+
if hasattr(dm, "open_array"):
|
|
1627
|
+
# matches PerfectPalettePicker
|
|
1628
|
+
dm.open_array(img01, metadata=meta, title=title)
|
|
1629
|
+
return
|
|
1630
|
+
|
|
1631
|
+
if hasattr(dm, "create_document"):
|
|
1632
|
+
# PPP fallback
|
|
1633
|
+
doc = dm.create_document(image=img01, metadata=meta, name=title)
|
|
1634
|
+
if hasattr(dm, "add_document"):
|
|
1635
|
+
dm.add_document(doc)
|
|
1636
|
+
return
|
|
1637
|
+
|
|
1638
|
+
raise RuntimeError("DocManager lacks open_array/create_document")
|
|
1639
|
+
|
|
1640
|
+
except Exception as e:
|
|
1641
|
+
QMessageBox.critical(self, "Finder Chart", f"Failed to open new view:\n{e}")
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
def _get_doc_manager(self):
|
|
1645
|
+
mw = self.parent()
|
|
1646
|
+
if mw is None:
|
|
1647
|
+
return None
|
|
1648
|
+
return getattr(mw, "docman", None) or getattr(mw, "doc_manager", None)
|
|
1649
|
+
|
|
1650
|
+
|