setiastrosuitepro 1.8.1.post2__py3-none-any.whl → 1.8.2__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (33) hide show
  1. setiastro/images/finderchart.png +0 -0
  2. setiastro/saspro/__main__.py +29 -38
  3. setiastro/saspro/_generated/build_info.py +2 -2
  4. setiastro/saspro/abe.py +1 -1
  5. setiastro/saspro/blink_comparator_pro.py +3 -1
  6. setiastro/saspro/bright_stars.py +305 -0
  7. setiastro/saspro/continuum_subtract.py +2 -1
  8. setiastro/saspro/cosmicclarity_preset.py +2 -1
  9. setiastro/saspro/doc_manager.py +8 -0
  10. setiastro/saspro/exoplanet_detector.py +22 -17
  11. setiastro/saspro/finder_chart.py +1639 -0
  12. setiastro/saspro/gui/main_window.py +36 -14
  13. setiastro/saspro/gui/mixins/menu_mixin.py +2 -0
  14. setiastro/saspro/gui/mixins/toolbar_mixin.py +9 -1
  15. setiastro/saspro/legacy/image_manager.py +18 -4
  16. setiastro/saspro/legacy/xisf.py +3 -3
  17. setiastro/saspro/main_helpers.py +18 -0
  18. setiastro/saspro/memory_utils.py +18 -14
  19. setiastro/saspro/resources.py +175 -161
  20. setiastro/saspro/runtime_torch.py +51 -10
  21. setiastro/saspro/sfcc.py +5 -3
  22. setiastro/saspro/stacking_suite.py +4 -3
  23. setiastro/saspro/star_alignment.py +4 -2
  24. setiastro/saspro/texture_clarity.py +1 -1
  25. setiastro/saspro/widgets/image_utils.py +12 -4
  26. setiastro/saspro/wimi.py +2 -1
  27. setiastro/saspro/xisf.py +3 -3
  28. {setiastrosuitepro-1.8.1.post2.dist-info → setiastrosuitepro-1.8.2.dist-info}/METADATA +4 -4
  29. {setiastrosuitepro-1.8.1.post2.dist-info → setiastrosuitepro-1.8.2.dist-info}/RECORD +33 -30
  30. {setiastrosuitepro-1.8.1.post2.dist-info → setiastrosuitepro-1.8.2.dist-info}/WHEEL +0 -0
  31. {setiastrosuitepro-1.8.1.post2.dist-info → setiastrosuitepro-1.8.2.dist-info}/entry_points.txt +0 -0
  32. {setiastrosuitepro-1.8.1.post2.dist-info → setiastrosuitepro-1.8.2.dist-info}/licenses/LICENSE +0 -0
  33. {setiastrosuitepro-1.8.1.post2.dist-info → setiastrosuitepro-1.8.2.dist-info}/licenses/license.txt +0 -0
@@ -0,0 +1,1639 @@
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
+
662
+ def _survey_to_hips_id(label: str) -> str:
663
+ key = (label or "").strip().lower()
664
+ if "dss" in key:
665
+ return "CDS/P/DSS2/color"
666
+ if "pan" in key:
667
+ return "CDS/P/PanSTARRS/DR1/color"
668
+ if "gaia" in key:
669
+ return "CDS/P/Gaia/DR3/flux-color"
670
+ return "CDS/P/DSS2/color"
671
+
672
+ def try_fetch_hips_cutout(center: "SkyCoord", fov_deg: float, out_px: int, survey_label: str):
673
+ """
674
+ Returns (rgb_float01, bg_wcs_celestial, err)
675
+ - rgb_float01: (H,W,3) float32 [0..1] or None
676
+ - bg_wcs_celestial: WCS(2D) or None
677
+ - err: str or None
678
+ """
679
+ hips_id = _survey_to_hips_id(survey_label)
680
+
681
+ try:
682
+ from astroquery.hips2fits import hips2fits
683
+ except Exception as e:
684
+ return None, None, f"{type(e).__name__}: {e}"
685
+
686
+ def _decode(hdul):
687
+ data = np.array(hdul[0].data, dtype=np.float32)
688
+
689
+ bg_wcs = None
690
+ try:
691
+ bg_wcs = WCS(hdul[0].header, relax=True)
692
+ if hasattr(bg_wcs, "celestial") and bg_wcs.celestial is not None:
693
+ bg_wcs = bg_wcs.celestial
694
+ except Exception:
695
+ bg_wcs = None
696
+
697
+ # Normalize to RGB
698
+ if data.ndim == 2:
699
+ data = np.repeat(data[..., None], 3, axis=2)
700
+ elif data.ndim == 3 and data.shape[0] in (3, 4):
701
+ data = np.transpose(data[:3, ...], (1, 2, 0))
702
+ elif data.ndim == 3 and data.shape[2] >= 3:
703
+ data = data[..., :3]
704
+
705
+ data = np.nan_to_num(data, nan=0.0, posinf=0.0, neginf=0.0)
706
+
707
+ lo, hi = np.percentile(data, [1.0, 99.5])
708
+ if hi > lo:
709
+ data = (data - lo) / (hi - lo)
710
+
711
+ return np.clip(data, 0.0, 1.0), bg_wcs
712
+
713
+ out_px = int(out_px)
714
+ ra_deg = float(center.ra.deg)
715
+ dec_deg = float(center.dec.deg)
716
+
717
+ # Prefer quantity for fov, but fall back if this build wants float
718
+ try:
719
+ import astropy.units as u
720
+ fov = float(fov_deg) * u.deg
721
+ except Exception:
722
+ fov = float(fov_deg)
723
+
724
+ last_err = None
725
+
726
+ # ---- Correct signature for YOUR astroquery 0.4.11 ----
727
+ # query(hips, width, height, projection, ra, dec, fov, *, coordsys='icrs', ...)
728
+ import astropy.units as u
729
+ from astropy.coordinates import Angle
730
+
731
+ out_px = int(out_px)
732
+
733
+ # IMPORTANT: pass Angle/Quantity, not floats
734
+ ra = center.ra.to(u.deg) # Angle
735
+ dec = center.dec.to(u.deg) # Angle
736
+ fov = Angle(float(fov_deg), unit=u.deg)
737
+
738
+ try:
739
+ hdul = hips2fits.query(
740
+ hips_id,
741
+ out_px, out_px,
742
+ "TAN",
743
+ ra, dec,
744
+ fov,
745
+ coordsys="icrs",
746
+ format="fits",
747
+ )
748
+ rgb01, bg_wcs = _decode(hdul)
749
+ return rgb01, bg_wcs, None
750
+
751
+ except Exception as e:
752
+ return None, None, f"{type(e).__name__}: {e}"
753
+
754
+ return None, None, last_err
755
+
756
+
757
+ def render_finder_chart(doc_image: np.ndarray, meta: dict, req: FinderChartRequest) -> Optional[np.ndarray]:
758
+ if WCS is None:
759
+ return None
760
+
761
+ doc_wcs = get_doc_wcs(meta)
762
+ if doc_wcs is None:
763
+ return None
764
+
765
+ H, Wimg = doc_image.shape[:2]
766
+ corners, center = image_footprint_sky(doc_wcs, Wimg, H)
767
+ fov_w, fov_h = estimate_fov_deg(corners)
768
+ fov = max(fov_w, fov_h) * float(req.scale_mult)
769
+
770
+ # Fetch background (+ WCS + error string)
771
+ bg, bg_wcs, err = try_fetch_hips_cutout(center[0], fov_deg=fov, out_px=req.out_px, survey_label=req.survey)
772
+
773
+ import matplotlib
774
+ matplotlib.use("Agg")
775
+ import matplotlib.pyplot as plt
776
+
777
+ fig = plt.figure(figsize=(req.out_px / 100.0, req.out_px / 100.0), dpi=100)
778
+ ax = fig.add_axes([0, 0, 1, 1])
779
+
780
+ # --- Draw background OR error message ---
781
+ if bg is None:
782
+ ax.set_facecolor((0, 0, 0))
783
+ msg = "No HiPS background.\n" + (err or "Unknown error")
784
+ ax.text(0.5, 0.5, msg, ha="center", va="center", transform=ax.transAxes)
785
+ else:
786
+ # If you want doc overlay, do it here
787
+ if bg_wcs is not None and doc_wcs is not None:
788
+ try:
789
+ overlay_u8 = _overlay_doc_on_bg(bg, bg_wcs, doc_image, doc_wcs, alpha=req.overlay_opacity)
790
+ ax.imshow(overlay_u8, origin="lower")
791
+ except Exception:
792
+ # fallback to plain bg if overlay fails
793
+ ax.imshow(bg, origin="lower")
794
+ else:
795
+ ax.imshow(bg, origin="lower")
796
+
797
+ # Optional: draw WCS-correct footprint polygon
798
+ if bg_wcs is not None:
799
+ try:
800
+ xs, ys = bg_wcs.world_to_pixel(corners)
801
+ ax.plot(
802
+ [xs[0], xs[1], xs[2], xs[3], xs[0]],
803
+ [ys[0], ys[1], ys[2], ys[3], ys[0]],
804
+ linewidth=2
805
+ )
806
+ except Exception:
807
+ pass
808
+
809
+ # center crosshair (axes coords)
810
+ ax.plot([0.5], [0.5], marker="+", markersize=20, transform=ax.transAxes)
811
+
812
+ # labels
813
+ ra = float(center[0].ra.deg)
814
+ dec = float(center[0].dec.deg)
815
+ ax.text(
816
+ 0.02, 0.98,
817
+ f"{req.survey} | {req.scale_mult}×FOV\nRA {ra:.6f}° Dec {dec:.6f}°\nFOV ~ {fov*60:.1f}′",
818
+ transform=ax.transAxes, va="top",
819
+ color="white",
820
+ bbox=dict(boxstyle="round,pad=0.25", facecolor=(0, 0, 0, 0.45), edgecolor=(1, 1, 1, 0.12))
821
+ )
822
+
823
+ if req.show_grid:
824
+ # keep axis ON so grid can render
825
+ ax.set_axis_on()
826
+ ax.set_xticks(np.linspace(0, req.out_px, 7))
827
+ ax.set_yticks(np.linspace(0, req.out_px, 7))
828
+ ax.grid(True, alpha=0.35)
829
+ # optionally hide tick labels but keep grid
830
+ ax.set_xticklabels([])
831
+ ax.set_yticklabels([])
832
+ else:
833
+ ax.set_xticks([])
834
+ ax.set_yticks([])
835
+ ax.set_axis_off()
836
+
837
+ fig.canvas.draw()
838
+
839
+ w, h = fig.canvas.get_width_height()
840
+ rgba = np.asarray(fig.canvas.buffer_rgba(), dtype=np.uint8).reshape(h, w, 4)
841
+ rgb = rgba[..., :3].copy()
842
+
843
+ plt.close(fig)
844
+ return rgb
845
+
846
+ def _to_u8_rgb(img: np.ndarray) -> np.ndarray:
847
+ a = np.asarray(img)
848
+ if a.ndim == 2:
849
+ a = np.repeat(a[..., None], 3, axis=2)
850
+ if a.shape[2] > 3:
851
+ a = a[..., :3]
852
+ a = a.astype(np.float32)
853
+ # simple robust normalize for display
854
+ lo, hi = np.percentile(a, [1.0, 99.5])
855
+ if hi > lo:
856
+ a = (a - lo) / (hi - lo)
857
+ a = np.clip(a, 0.0, 1.0)
858
+ return (a * 255.0 + 0.5).astype(np.uint8)
859
+
860
+ def _overlay_doc_on_bg(bg_rgb01: np.ndarray, bg_wcs: "WCS", doc_img: np.ndarray, doc_wcs: "WCS", alpha=0.35) -> np.ndarray:
861
+ import cv2
862
+
863
+ Hbg, Wbg = bg_rgb01.shape[:2]
864
+ bg_u8 = (np.clip(bg_rgb01, 0, 1) * 255.0 + 0.5).astype(np.uint8)
865
+
866
+ doc_u8 = _to_u8_rgb(doc_img)
867
+ H, W = doc_u8.shape[:2]
868
+
869
+ # doc pixel corners -> sky -> bg pixels
870
+ src = np.array([[0,0], [W-1,0], [W-1,H-1], [0,H-1]], dtype=np.float32)
871
+ sky = doc_wcs.pixel_to_world(src[:,0], src[:,1]) # SkyCoord
872
+ xbg, ybg = bg_wcs.world_to_pixel(sky)
873
+ dst = np.stack([xbg, ybg], axis=1).astype(np.float32)
874
+
875
+ M = cv2.getPerspectiveTransform(src, dst)
876
+ warped = cv2.warpPerspective(doc_u8, M, (Wbg, Hbg), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_TRANSPARENT)
877
+
878
+ # alpha blend where warped has content
879
+ mask = (warped.sum(axis=2) > 0).astype(np.float32)[..., None]
880
+ out = bg_u8.astype(np.float32) * (1 - alpha*mask) + warped.astype(np.float32) * (alpha*mask)
881
+ return np.clip(out, 0, 255).astype(np.uint8)
882
+
883
+
884
+ def _rgb_u8_to_qimage(rgb_u8: np.ndarray) -> QImage:
885
+ rgb_u8 = np.ascontiguousarray(rgb_u8)
886
+ h, w, _ = rgb_u8.shape
887
+ bpl = rgb_u8.strides[0]
888
+ # QImage uses the buffer; to be safe, copy via .copy() when making pixmap
889
+ return QImage(rgb_u8.data, w, h, bpl, QImage.Format.Format_RGB888)
890
+
891
+ def _draw_star_names(ax, bg_wcs: "WCS", center: "SkyCoord", fov_deg: float, *,
892
+ mag_limit: float = 2.0, max_labels: int = 30, renderer=None):
893
+ if bg_wcs is None:
894
+ return
895
+
896
+ import astropy.units as u
897
+ from astropy.coordinates import SkyCoord
898
+
899
+ ra0 = float(center.ra.deg)
900
+ dec0 = float(center.dec.deg)
901
+ radius = float(fov_deg) * 0.75
902
+
903
+ c0 = SkyCoord(ra0*u.deg, dec0*u.deg, frame="icrs") # <-- MOVE OUTSIDE LOOP
904
+
905
+ rows = []
906
+ for (name, ra, dec, vmag) in BRIGHT_STARS:
907
+ if float(vmag) > float(mag_limit):
908
+ continue
909
+ if abs(dec - dec0) > radius + 2.0:
910
+ continue
911
+ c1 = SkyCoord(float(ra)*u.deg, float(dec)*u.deg, frame="icrs")
912
+ if c0.separation(c1).deg <= radius:
913
+ rows.append((name, float(ra), float(dec), float(vmag)))
914
+
915
+ if not rows:
916
+ return
917
+
918
+ rows.sort(key=lambda r: r[3])
919
+ rows = rows[:max_labels]
920
+
921
+ coords = SkyCoord([r[1] for r in rows]*u.deg, [r[2] for r in rows]*u.deg, frame="icrs")
922
+ xs, ys = bg_wcs.world_to_pixel(coords)
923
+
924
+ out_px = int(getattr(ax.figure, "_sas_out_px", 0) or 0)
925
+ if out_px <= 0:
926
+ out_px = int(ax.figure.get_figwidth() * ax.figure.dpi)
927
+
928
+ kept = []
929
+ cell = 28
930
+ used = set()
931
+
932
+ for (row, x, y) in zip(rows, xs, ys):
933
+ x = float(x); y = float(y)
934
+ if not _inside_px(x, y, out_px, pad=0):
935
+ continue
936
+ gx = int(x // cell)
937
+ gy = int(y // cell)
938
+ key = (gx, gy)
939
+ if key in used:
940
+ continue
941
+ used.add(key)
942
+ kept.append((row[0], x, y))
943
+ if len(kept) >= int(max_labels):
944
+ break
945
+
946
+ for (name, x, y) in kept:
947
+ ax.plot([x], [y],
948
+ marker="o", markersize=2.5, alpha=0.85,
949
+ color="#ffb000",
950
+ transform=ax.get_transform("pixel"),
951
+ clip_on=True)
952
+
953
+ _place_label_inside(
954
+ ax, x, y, name,
955
+ dx=6, dy=4, fontsize=9,
956
+ color="#ffb000", alpha=0.95, outline=True,
957
+ out_px=out_px, pad=4,
958
+ renderer=renderer, # <-- PASS IT
959
+ )
960
+
961
+
962
+ def render_finder_chart_cached(
963
+ *,
964
+ doc_image: np.ndarray,
965
+ doc_wcs: WCS,
966
+ corners: SkyCoord,
967
+ center: SkyCoord,
968
+ fov_deg: float,
969
+ req: FinderChartRequest,
970
+ bg: Optional[np.ndarray],
971
+ bg_wcs: Optional[WCS],
972
+ err: Optional[str],
973
+ base_u8: Optional[np.ndarray] = None, # <-- NEW
974
+ ) -> np.ndarray:
975
+ import matplotlib
976
+ matplotlib.use("Agg")
977
+ import matplotlib.pyplot as plt
978
+
979
+ fig = plt.figure(figsize=(req.out_px / 100.0, req.out_px / 100.0), dpi=100)
980
+ fig._sas_out_px = int(req.out_px)
981
+
982
+ # Use WCSAxes when we have bg_wcs so we can draw RA/Dec labels & grid properly
983
+ if bg_wcs is not None:
984
+ ax = fig.add_subplot(111, projection=bg_wcs)
985
+ else:
986
+ ax = fig.add_axes([0, 0, 1, 1])
987
+
988
+ # ---- background (or error) ----
989
+ if bg is None:
990
+ ax.set_facecolor((0, 0, 0))
991
+ msg = "No HiPS background.\n" + (err or "Unknown error")
992
+ ax.text(0.5, 0.5, msg, ha="center", va="center", transform=ax.transAxes)
993
+ else:
994
+ # If provided, base_u8 already includes hips + optional warped doc overlay.
995
+ if base_u8 is not None:
996
+ ax.imshow(base_u8, origin="lower")
997
+ else:
998
+ # fallback (old path)
999
+ if bg_wcs is not None and doc_wcs is not None and req.overlay_opacity > 0.0:
1000
+ try:
1001
+ overlay_u8 = _overlay_doc_on_bg(bg, bg_wcs, doc_image, doc_wcs, alpha=req.overlay_opacity)
1002
+ ax.imshow(overlay_u8, origin="lower")
1003
+ except Exception:
1004
+ ax.imshow(bg, origin="lower")
1005
+ else:
1006
+ ax.imshow(bg, origin="lower")
1007
+
1008
+
1009
+ # footprint polygon in pixel coords
1010
+ if bg_wcs is not None:
1011
+ try:
1012
+ xs, ys = bg_wcs.world_to_pixel(corners)
1013
+ ax.plot(
1014
+ [xs[0], xs[1], xs[2], xs[3], xs[0]],
1015
+ [ys[0], ys[1], ys[2], ys[3], ys[0]],
1016
+ linewidth=2,
1017
+ transform=ax.get_transform("pixel"),
1018
+ )
1019
+ except Exception:
1020
+ pass
1021
+
1022
+ # center crosshair
1023
+ ax.plot([0.5], [0.5], marker="+", markersize=20, transform=ax.transAxes)
1024
+
1025
+ # top-left info text
1026
+ ra = float(center[0].ra.deg)
1027
+ dec = float(center[0].dec.deg)
1028
+ ax.text(
1029
+ 0.02, 0.98,
1030
+ f"{req.survey} | {req.scale_mult}×FOV\nRA {ra:.6f}° Dec {dec:.6f}°\nFOV ~ {fov_deg*60:.1f}′",
1031
+ transform=ax.transAxes, va="top"
1032
+ )
1033
+
1034
+ # ---- grid + RA/Dec labels ----
1035
+ if bg_wcs is not None:
1036
+ # RA/Dec edge labels
1037
+ try:
1038
+ ax.coords[0].set_axislabel("RA")
1039
+ ax.coords[1].set_axislabel("Dec")
1040
+ except Exception:
1041
+ pass
1042
+
1043
+ # toggle grid lines
1044
+ try:
1045
+ ax.coords.grid(bool(req.show_grid), alpha=0.35)
1046
+ except Exception:
1047
+ pass
1048
+
1049
+ # If grid is off, you may still want edge tick labels; keep axis visible.
1050
+ # WCSAxes handles ticks/labels automatically.
1051
+ else:
1052
+ # fallback: pixel grid only
1053
+ if req.show_grid:
1054
+ ax.set_axis_on()
1055
+ ax.set_xticks(np.linspace(0, req.out_px, 7))
1056
+ ax.set_yticks(np.linspace(0, req.out_px, 7))
1057
+ ax.grid(True, alpha=0.35)
1058
+ ax.set_xticklabels([])
1059
+ ax.set_yticklabels([])
1060
+ else:
1061
+ ax.set_xticks([])
1062
+ ax.set_yticks([])
1063
+ ax.set_axis_off()
1064
+
1065
+ # After background + grid setup (before overlays that need bbox measurements)
1066
+ fig.canvas.draw()
1067
+ renderer = fig.canvas.get_renderer()
1068
+
1069
+ # ---- star names overlay ----
1070
+ if getattr(req, "show_star_names", False) and (bg_wcs is not None):
1071
+ try:
1072
+ _draw_star_names(
1073
+ ax, bg_wcs, center[0], fov_deg,
1074
+ mag_limit=float(getattr(req, "star_mag_limit", 2.0)),
1075
+ max_labels=int(getattr(req, "star_max_labels", 30)), renderer=renderer
1076
+ )
1077
+ except Exception:
1078
+ pass
1079
+
1080
+ # ---- deep-sky overlay ----
1081
+ if getattr(req, "show_dso", False):
1082
+
1083
+ if bg_wcs is not None:
1084
+ try:
1085
+ _draw_dso_overlay(ax, bg_wcs, center[0], fov_deg, req, renderer=renderer)
1086
+ except Exception as e:
1087
+ print(f"[DSO] overlay error: {type(e).__name__}: {e}")
1088
+
1089
+ # ---- compass + scale bar ----
1090
+ if bg_wcs is not None and getattr(req, "show_compass", True):
1091
+ try:
1092
+ _draw_compass_NE(ax, bg_wcs, int(req.out_px), float(fov_deg))
1093
+ except Exception:
1094
+ pass
1095
+
1096
+ if bg_wcs is not None and getattr(req, "show_scale_bar", True):
1097
+ try:
1098
+ _draw_scale_bar(ax, bg_wcs, int(req.out_px), float(fov_deg))
1099
+ except Exception:
1100
+ pass
1101
+
1102
+ fig.canvas.draw()
1103
+ w, h = fig.canvas.get_width_height()
1104
+ rgba = np.asarray(fig.canvas.buffer_rgba(), dtype=np.uint8).reshape(h, w, 4)
1105
+ rgb = rgba[..., :3].copy()
1106
+ plt.close(fig)
1107
+ return rgb
1108
+
1109
+
1110
+ class FinderChartDialog(QDialog):
1111
+ """
1112
+ Minimal v1 Finder Chart dialog:
1113
+ - Survey dropdown
1114
+ - Size multiplier dropdown
1115
+ - Show grid checkbox
1116
+ - Render preview
1117
+ - Save PNG
1118
+ - Send to New Document (push into SASpro)
1119
+ """
1120
+ def __init__(self, doc, settings, parent=None):
1121
+ super().__init__(parent)
1122
+ self._doc = doc
1123
+ self._settings = settings
1124
+ self._last_rgb_u8: Optional[np.ndarray] = None
1125
+ # ---- HiPS cache (avoid refetching for UI-only changes) ----
1126
+ self._hips_cache_key = None
1127
+ self._hips_bg = None # float01 RGB background
1128
+ self._hips_wcs = None # celestial WCS for background
1129
+ self._hips_err = None
1130
+ self._render_timer = QTimer(self)
1131
+ self._render_timer.setSingleShot(True)
1132
+ self._render_timer.timeout.connect(self._render_debounced_fire)
1133
+ self._pending_force_refetch = False
1134
+ self._base_cache_key = None
1135
+ self._base_u8 = None
1136
+ # Cached geometry derived from the current doc WCS (used for overlays/labels)
1137
+ self._doc_wcs_cached = None
1138
+ self._corners_cached = None
1139
+ self._center_cached = None
1140
+ self._fov_deg_cached = None
1141
+ self.setWindowTitle("Finder Chart…")
1142
+ self.setModal(False)
1143
+ self.resize(920, 980)
1144
+
1145
+ root = QVBoxLayout(self)
1146
+
1147
+ # controls row 1 (primary)
1148
+ row1 = QHBoxLayout()
1149
+
1150
+ row1.addWidget(QLabel("Survey:"))
1151
+ self.cmb_survey = QComboBox()
1152
+ self.cmb_survey.addItems(["DSS2", "Pan-STARRS", "Gaia"])
1153
+ row1.addWidget(self.cmb_survey)
1154
+
1155
+ row1.addSpacing(12)
1156
+ row1.addWidget(QLabel("Size:"))
1157
+ self.cmb_size = QComboBox()
1158
+ self.cmb_size.addItems(["2× FOV", "4× FOV", "8× FOV"])
1159
+ row1.addWidget(self.cmb_size)
1160
+
1161
+ row1.addSpacing(12)
1162
+ self.chk_grid = QCheckBox("Show grid")
1163
+ row1.addWidget(self.chk_grid)
1164
+
1165
+ row1.addSpacing(12)
1166
+ row1.addWidget(QLabel("Output px:"))
1167
+ self.sb_px = QSpinBox()
1168
+ self.sb_px.setRange(300, 2400)
1169
+ self.sb_px.setSingleStep(100)
1170
+ self.sb_px.setValue(900)
1171
+ row1.addWidget(self.sb_px)
1172
+ row1.addSpacing(12)
1173
+ row1.addWidget(QLabel("Image opacity:"))
1174
+ self.sld_opacity = QSlider(Qt.Orientation.Horizontal)
1175
+ self.sld_opacity.setRange(0, 100)
1176
+ self.sld_opacity.setValue(35)
1177
+ self.sld_opacity.setFixedWidth(140)
1178
+ row1.addWidget(self.sld_opacity)
1179
+
1180
+ self.lbl_opacity = QLabel("35%")
1181
+ self.lbl_opacity.setFixedWidth(40)
1182
+ row1.addWidget(self.lbl_opacity)
1183
+ row1.addStretch(1)
1184
+ self.btn_render = QPushButton("Render")
1185
+ row1.addWidget(self.btn_render)
1186
+
1187
+
1188
+ root.addLayout(row1)
1189
+
1190
+ # controls row 2 (overlays)
1191
+ row2 = QHBoxLayout()
1192
+
1193
+ self.chk_stars = QCheckBox("Star names")
1194
+ row2.addWidget(self.chk_stars)
1195
+
1196
+ row2.addSpacing(8)
1197
+ row2.addWidget(QLabel("Star ≤"))
1198
+ self.sb_star_mag = QDoubleSpinBox()
1199
+ self.sb_star_mag.setRange(-2.0, 8.0)
1200
+ self.sb_star_mag.setSingleStep(0.5)
1201
+ self.sb_star_mag.setValue(5.0)
1202
+ self.sb_star_mag.setFixedWidth(70)
1203
+ row2.addWidget(self.sb_star_mag)
1204
+
1205
+ row2.addWidget(QLabel("Max"))
1206
+ self.sb_star_max = QSpinBox()
1207
+ self.sb_star_max.setRange(5, 200)
1208
+ self.sb_star_max.setValue(30)
1209
+ self.sb_star_max.setFixedWidth(60)
1210
+ row2.addWidget(self.sb_star_max)
1211
+
1212
+ row2.addSpacing(12)
1213
+ self.chk_dso = QCheckBox("Deep-sky")
1214
+ row2.addWidget(self.chk_dso)
1215
+
1216
+ self.cmb_dso = QComboBox()
1217
+ self.cmb_dso.addItems(["All (DSO)", "M", "NGC", "IC", "Abell", "SH2", "LBN", "LDN", "PN-G"])
1218
+ self.cmb_dso.setFixedWidth(170)
1219
+ row2.addWidget(self.cmb_dso)
1220
+
1221
+ row2.addWidget(QLabel("Mag ≤"))
1222
+ self.sb_dso_mag = QDoubleSpinBox()
1223
+ self.sb_dso_mag.setRange(-2.0, 30.0)
1224
+ self.sb_dso_mag.setSingleStep(0.5)
1225
+ self.sb_dso_mag.setValue(12.0)
1226
+ self.sb_dso_mag.setFixedWidth(70)
1227
+ row2.addWidget(self.sb_dso_mag)
1228
+
1229
+ row2.addWidget(QLabel("Max"))
1230
+ self.sb_dso_max = QSpinBox()
1231
+ self.sb_dso_max.setRange(5, 300)
1232
+ self.sb_dso_max.setValue(30)
1233
+ self.sb_dso_max.setFixedWidth(60)
1234
+ row2.addWidget(self.sb_dso_max)
1235
+
1236
+ row2.addSpacing(12)
1237
+ self.chk_compass = QCheckBox("Compass")
1238
+ self.chk_compass.setChecked(True)
1239
+ row2.addWidget(self.chk_compass)
1240
+
1241
+ self.chk_scale = QCheckBox("Scale bar")
1242
+ self.chk_scale.setChecked(True)
1243
+ row2.addWidget(self.chk_scale)
1244
+
1245
+
1246
+
1247
+ row2.addStretch(1)
1248
+ root.addLayout(row2)
1249
+
1250
+ # preview
1251
+ self.lbl = QLabel()
1252
+ self.lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
1253
+ self.lbl.setMinimumHeight(700)
1254
+ self.lbl.setStyleSheet("QLabel { background:#111; border:1px solid #333; }")
1255
+ root.addWidget(self.lbl, 1)
1256
+
1257
+ # buttons
1258
+ brow = QHBoxLayout()
1259
+
1260
+ self.lbl_status = QLabel("") # <-- NEW
1261
+ self.lbl_status.setStyleSheet("color:#bbb;") # subtle
1262
+ self.lbl_status.setMinimumWidth(160)
1263
+ brow.addWidget(self.lbl_status) # <-- NEW
1264
+
1265
+ brow.addStretch(1)
1266
+
1267
+ self.btn_send = QPushButton("Send to New Document")
1268
+ self.btn_save = QPushButton("Save PNG…")
1269
+ self.btn_close = QPushButton("Close")
1270
+ brow.addWidget(self.btn_send)
1271
+ brow.addWidget(self.btn_save)
1272
+ brow.addWidget(self.btn_close)
1273
+ root.addLayout(brow)
1274
+
1275
+ self.btn_render.clicked.connect(lambda: self._render_now(force_refetch=True))
1276
+ self.btn_save.clicked.connect(self._save_png)
1277
+ self.btn_send.clicked.connect(self._send_to_new_doc)
1278
+ self.btn_close.clicked.connect(self.close)
1279
+
1280
+ # grid: debounced (no refetch)
1281
+ self.chk_grid.toggled.connect(lambda _=False: self._schedule_render(force_refetch=False, delay_ms=150))
1282
+
1283
+ # survey/size/px: immediate refetch (or debounce if you want, but refetch is required)
1284
+ self.cmb_survey.currentIndexChanged.connect(lambda _=0: self._render_now(force_refetch=True))
1285
+ self.cmb_size.currentIndexChanged.connect(lambda _=0: self._render_now(force_refetch=True))
1286
+ self.sb_px.valueChanged.connect(lambda _=0: self._render_now(force_refetch=True))
1287
+
1288
+ # opacity: update label + debounce render (no refetch)
1289
+ self.sld_opacity.valueChanged.connect(self._on_opacity_changed)
1290
+ self.sld_opacity.sliderReleased.connect(lambda: self._render_now(force_refetch=False))
1291
+ # auto render once
1292
+ # placeholder so the user sees *something* immediately
1293
+ self.lbl.setText("Fetching survey background…")
1294
+ self.lbl.setStyleSheet("QLabel { background:#111; border:1px solid #333; color:#ccc; }")
1295
+ # --- overlay enable/disable: update enabled state + one refresh ---
1296
+ self.chk_stars.toggled.connect(lambda _=False: (self._set_overlay_controls_enabled(),
1297
+ self._schedule_render(force_refetch=False, delay_ms=150)))
1298
+
1299
+ self.chk_dso.toggled.connect(lambda _=False: (self._set_overlay_controls_enabled(),
1300
+ self._schedule_render(force_refetch=False, delay_ms=150)))
1301
+
1302
+ # --- star params: ONLY refresh if Star names is ON ---
1303
+ self.sb_star_mag.valueChanged.connect(lambda _=0: self._maybe_schedule_stars(150))
1304
+ self.sb_star_max.valueChanged.connect(lambda _=0: self._maybe_schedule_stars(150))
1305
+
1306
+ # --- dso params: ONLY refresh if Deep-sky is ON ---
1307
+ self.cmb_dso.currentIndexChanged.connect(lambda _=0: self._maybe_schedule_dso(150))
1308
+ self.sb_dso_mag.valueChanged.connect(lambda _=0: self._maybe_schedule_dso(150))
1309
+ self.sb_dso_max.valueChanged.connect(lambda _=0: self._maybe_schedule_dso(150))
1310
+
1311
+ self.chk_compass.toggled.connect(lambda _=False: self._schedule_render(force_refetch=False, delay_ms=150))
1312
+ self.chk_scale.toggled.connect(lambda _=False: self._schedule_render(force_refetch=False, delay_ms=150))
1313
+
1314
+ self._set_overlay_controls_enabled()
1315
+ # kick initial render AFTER the dialog has had a chance to show/paint
1316
+ QTimer.singleShot(0, self._initial_render)
1317
+
1318
+ def _maybe_schedule_stars(self, delay_ms: int = 150):
1319
+ # Only auto-refresh if the overlay is enabled
1320
+ if not self.chk_stars.isChecked():
1321
+ return
1322
+ self._schedule_render(force_refetch=False, delay_ms=delay_ms)
1323
+
1324
+ def _maybe_schedule_dso(self, delay_ms: int = 150):
1325
+ # Only auto-refresh if the overlay is enabled
1326
+ if not self.chk_dso.isChecked():
1327
+ return
1328
+ self._schedule_render(force_refetch=False, delay_ms=delay_ms)
1329
+
1330
+ def _set_overlay_controls_enabled(self):
1331
+ stars_on = self.chk_stars.isChecked()
1332
+ self.sb_star_mag.setEnabled(stars_on)
1333
+ self.sb_star_max.setEnabled(stars_on)
1334
+
1335
+ dso_on = self.chk_dso.isChecked()
1336
+ self.cmb_dso.setEnabled(dso_on)
1337
+ self.sb_dso_mag.setEnabled(dso_on)
1338
+ self.sb_dso_max.setEnabled(dso_on)
1339
+
1340
+
1341
+ def _set_busy(self, busy: bool, msg: str = "Rendering…"):
1342
+ self.btn_render.setEnabled(not busy)
1343
+ self.btn_send.setEnabled((not busy) and (self._last_rgb_u8 is not None))
1344
+ self.btn_save.setEnabled((not busy) and (self._last_rgb_u8 is not None))
1345
+
1346
+ if hasattr(self, "lbl_status") and self.lbl_status is not None:
1347
+ self.lbl_status.setText(msg if busy else "")
1348
+ self.lbl_status.setVisible(True)
1349
+
1350
+ # ensures the label paints immediately before heavy work
1351
+ QApplication.processEvents()
1352
+
1353
+
1354
+ def _initial_render(self):
1355
+ self._set_busy(True, "Fetching survey background…")
1356
+ # schedule again so the UI paints the busy message + cursor first
1357
+ QTimer.singleShot(0, lambda: self._render_now(force_refetch=True))
1358
+
1359
+
1360
+ def _schedule_render(self, *, force_refetch: bool = False, delay_ms: int = 200):
1361
+ # show immediate feedback during debounce
1362
+ if hasattr(self, "lbl_status") and self.lbl_status is not None:
1363
+ self.lbl_status.setText("Rendering…")
1364
+ QApplication.processEvents()
1365
+
1366
+ self._pending_force_refetch = self._pending_force_refetch or bool(force_refetch)
1367
+ self._render_timer.stop()
1368
+ self._render_timer.start(int(delay_ms))
1369
+
1370
+ def _render_debounced_fire(self):
1371
+ force = bool(self._pending_force_refetch)
1372
+ self._pending_force_refetch = False
1373
+ self._render(force_refetch=force)
1374
+
1375
+ def _render_now(self, *, force_refetch: bool = False):
1376
+ # Cancel any pending debounced render and render immediately
1377
+ self._render_timer.stop()
1378
+ self._pending_force_refetch = False
1379
+ self._render(force_refetch=force_refetch)
1380
+
1381
+
1382
+ def _compute_doc_geometry(self, img: np.ndarray, meta: dict, req: FinderChartRequest):
1383
+ doc_wcs = get_doc_wcs(meta)
1384
+ if doc_wcs is None:
1385
+ return None, None, None, None
1386
+
1387
+ H, Wimg = img.shape[:2]
1388
+ corners, center = image_footprint_sky(doc_wcs, Wimg, H)
1389
+ fov_w, fov_h = estimate_fov_deg(corners)
1390
+ fov = max(fov_w, fov_h) * float(req.scale_mult)
1391
+ return doc_wcs, corners, center, float(fov)
1392
+
1393
+ def _ensure_hips_background(self, req: FinderChartRequest, center: SkyCoord, fov_deg: float, *, force: bool = False):
1394
+ # Overscan factor: enough to cover the half-diagonal circle of the final square
1395
+ # sqrt(2) covers exactly; add a hair for safety near edges.
1396
+ s = float(math.sqrt(2.0) * 1.02)
1397
+
1398
+ out_px = int(req.out_px)
1399
+ fetch_px = int(math.ceil(out_px * s))
1400
+
1401
+ # IMPORTANT: scale FOV by the same factor so arcsec/px stays the same
1402
+ fetch_fov = float(fov_deg) * s
1403
+
1404
+ # Cache key must reflect the *fetch* parameters, not just final output
1405
+ key = (
1406
+ str(req.survey),
1407
+ int(fetch_px),
1408
+ round(float(center.ra.deg), 8),
1409
+ round(float(center.dec.deg), 8),
1410
+ round(float(fetch_fov), 8),
1411
+ )
1412
+
1413
+ if (not force) and (self._hips_cache_key == key) and (self._hips_bg is not None):
1414
+ return
1415
+
1416
+ bg_big, wcs_big, err = try_fetch_hips_cutout(
1417
+ center,
1418
+ fov_deg=fetch_fov,
1419
+ out_px=fetch_px,
1420
+ survey_label=req.survey,
1421
+ )
1422
+
1423
+ if bg_big is not None:
1424
+ # Crop back down to the user-requested size, and shift WCS accordingly
1425
+ bg, wcs_cropped, _ = _crop_center(bg_big, wcs_big, out_px)
1426
+ else:
1427
+ bg, wcs_cropped = None, None
1428
+
1429
+ self._hips_cache_key = key
1430
+ self._hips_bg = bg
1431
+ self._hips_wcs = wcs_cropped
1432
+ self._hips_err = err
1433
+
1434
+
1435
+ def _doc_key(self, img: np.ndarray, meta: dict) -> tuple:
1436
+ # cheap-ish: shape + metadata wcs fingerprint (or header checksum if you have one)
1437
+ w = meta.get("wcs")
1438
+ w_id = id(w) if w is not None else id(meta.get("original_header") or meta.get("fits_header") or meta.get("header"))
1439
+ return (img.shape, w_id, int(self.cmb_size.currentIndex())) # size affects scale_mult
1440
+
1441
+ def _compute_doc_geometry_cached(self, img, meta, req):
1442
+ key = self._doc_key(img, meta)
1443
+ if getattr(self, "_geom_cache_key", None) == key and self._doc_wcs_cached is not None:
1444
+ return self._doc_wcs_cached, self._corners_cached, self._center_cached, self._fov_deg_cached
1445
+
1446
+ doc_wcs, corners, center, fov_deg = self._compute_doc_geometry(img, meta, req)
1447
+ self._geom_cache_key = key
1448
+ self._doc_wcs_cached, self._corners_cached, self._center_cached, self._fov_deg_cached = doc_wcs, corners, center, fov_deg
1449
+ return doc_wcs, corners, center, fov_deg
1450
+
1451
+
1452
+ def _req(self) -> FinderChartRequest:
1453
+ survey = str(self.cmb_survey.currentText())
1454
+ mult = {0: 2, 1: 4, 2: 8}.get(int(self.cmb_size.currentIndex()), 2)
1455
+ show_grid = bool(self.chk_grid.isChecked())
1456
+ out_px = int(self.sb_px.value())
1457
+ overlay_opacity = float(self.sld_opacity.value()) / 100.0
1458
+
1459
+ return FinderChartRequest(
1460
+ survey=survey,
1461
+ scale_mult=mult,
1462
+ show_grid=show_grid,
1463
+
1464
+ show_star_names=bool(self.chk_stars.isChecked()),
1465
+ star_mag_limit=float(self.sb_star_mag.value()),
1466
+ star_max_labels=int(self.sb_star_max.value()),
1467
+
1468
+ show_dso=bool(self.chk_dso.isChecked()),
1469
+ dso_catalog=str(self.cmb_dso.currentText()),
1470
+ dso_mag_limit=float(self.sb_dso_mag.value()),
1471
+ dso_max_labels=int(self.sb_dso_max.value()),
1472
+
1473
+ show_compass=bool(self.chk_compass.isChecked()),
1474
+ show_scale_bar=bool(self.chk_scale.isChecked()),
1475
+
1476
+ out_px=out_px,
1477
+ overlay_opacity=overlay_opacity,
1478
+ )
1479
+
1480
+
1481
+ def _render(self, *, force_refetch: bool = False):
1482
+ self._set_busy(True, "Rendering finder chart…")
1483
+ try:
1484
+ img = np.asarray(self._doc.image)
1485
+ meta = dict(getattr(self._doc, "metadata", None) or {})
1486
+ req = self._req()
1487
+
1488
+ # 1) compute geometry from doc WCS
1489
+ doc_wcs, corners, center, fov_deg = self._compute_doc_geometry_cached(img, meta, req)
1490
+ if doc_wcs is None:
1491
+ QMessageBox.warning(self, "Finder Chart", "Could not render finder chart (missing WCS).")
1492
+ return
1493
+
1494
+ # cache these for reuse (overlay / footprint / labels)
1495
+ self._doc_wcs_cached = doc_wcs
1496
+ self._corners_cached = corners
1497
+ self._center_cached = center
1498
+ self._fov_deg_cached = fov_deg
1499
+
1500
+ # 2) fetch background only if needed
1501
+ self._ensure_hips_background(req, center[0], fov_deg, force=force_refetch)
1502
+ # 2.5) build/cache base raster (NO re-warp on overlay toggles)
1503
+ self._ensure_base_raster(req, img, doc_wcs, corners, center, fov_deg)
1504
+ # 3) render using cached background (NO network)
1505
+
1506
+
1507
+ rgb = render_finder_chart_cached(
1508
+ doc_image=img,
1509
+ doc_wcs=doc_wcs,
1510
+ corners=corners,
1511
+ center=center,
1512
+ fov_deg=fov_deg,
1513
+ req=req,
1514
+ bg=self._hips_bg,
1515
+ bg_wcs=self._hips_wcs,
1516
+ err=self._hips_err,
1517
+ base_u8=self._base_u8,
1518
+ )
1519
+
1520
+ self._last_rgb_u8 = rgb
1521
+ qimg = _rgb_u8_to_qimage(rgb).copy()
1522
+ self.lbl.setPixmap(QPixmap.fromImage(qimg))
1523
+
1524
+ except Exception as e:
1525
+ QMessageBox.critical(self, "Finder Chart", str(e))
1526
+ finally:
1527
+ self._set_busy(False)
1528
+
1529
+ def _ensure_base_raster(self, req, img, doc_wcs, corners, center, fov_deg):
1530
+ doc_sig = (img.shape, str(type(self._doc)), id(self._doc))
1531
+ base_key = (
1532
+ self._hips_cache_key, # ties to survey/out_px/center/fov
1533
+ round(req.overlay_opacity, 4),
1534
+ id(doc_wcs),
1535
+ doc_sig,
1536
+ )
1537
+ if getattr(self, "_base_cache_key", None) == base_key:
1538
+ return
1539
+
1540
+ if self._hips_bg is None:
1541
+ self._base_u8 = None
1542
+ self._base_cache_key = base_key
1543
+ return
1544
+
1545
+ if self._hips_wcs is not None and req.overlay_opacity > 0:
1546
+ self._base_u8 = _overlay_doc_on_bg(self._hips_bg, self._hips_wcs, img, doc_wcs, alpha=req.overlay_opacity)
1547
+ else:
1548
+ self._base_u8 = (np.clip(self._hips_bg, 0, 1) * 255.0 + 0.5).astype(np.uint8)
1549
+
1550
+ self._base_cache_key = base_key
1551
+
1552
+
1553
+ def _on_opacity_changed(self, v: int):
1554
+ self.lbl_opacity.setText(f"{int(v)}%")
1555
+ self._schedule_render(delay_ms=200) # no force refetch
1556
+
1557
+
1558
+ def _save_png(self):
1559
+ if self._last_rgb_u8 is None:
1560
+ QMessageBox.information(self, "Finder Chart", "Nothing rendered yet.")
1561
+ return
1562
+
1563
+ start_dir = ""
1564
+ try:
1565
+ start_dir = self._settings.value("finder_chart/last_dir", "", type=str) or ""
1566
+ except Exception:
1567
+ start_dir = ""
1568
+
1569
+ fn, _ = QFileDialog.getSaveFileName(self, "Save Finder Chart", start_dir, "PNG Image (*.png)")
1570
+ if not fn:
1571
+ return
1572
+
1573
+ try:
1574
+ if not fn.lower().endswith(".png"):
1575
+ fn += ".png"
1576
+ qimg = _rgb_u8_to_qimage(self._last_rgb_u8).copy()
1577
+ ok = qimg.save(fn, "PNG")
1578
+ if not ok:
1579
+ raise RuntimeError("QImage.save() failed.")
1580
+ try:
1581
+ self._settings.setValue("finder_chart/last_dir", fn)
1582
+ self._settings.sync()
1583
+ except Exception:
1584
+ pass
1585
+ except Exception as e:
1586
+ QMessageBox.critical(self, "Finder Chart", str(e))
1587
+
1588
+ def _send_to_new_doc(self):
1589
+ if self._last_rgb_u8 is None:
1590
+ QMessageBox.information(self, "Finder Chart", "Nothing rendered yet.")
1591
+ return
1592
+
1593
+ img01 = self._last_rgb_u8.astype(np.float32) / 255.0
1594
+
1595
+ req = self._req()
1596
+ meta = {
1597
+ "step_name": "Finder Chart",
1598
+ "finder_chart": {
1599
+ "survey": req.survey,
1600
+ "scale_mult": req.scale_mult,
1601
+ "show_grid": req.show_grid,
1602
+ "out_px": req.out_px,
1603
+ "overlay_opacity": req.overlay_opacity,
1604
+ },
1605
+ }
1606
+
1607
+ dm = self._get_doc_manager()
1608
+ if dm is None:
1609
+ QMessageBox.warning(self, "Finder Chart", "DocManager not found.")
1610
+ return
1611
+
1612
+ title = f"Finder Chart ({req.survey})"
1613
+
1614
+ try:
1615
+ if hasattr(dm, "open_array"):
1616
+ # matches PerfectPalettePicker
1617
+ dm.open_array(img01, metadata=meta, title=title)
1618
+ return
1619
+
1620
+ if hasattr(dm, "create_document"):
1621
+ # PPP fallback
1622
+ doc = dm.create_document(image=img01, metadata=meta, name=title)
1623
+ if hasattr(dm, "add_document"):
1624
+ dm.add_document(doc)
1625
+ return
1626
+
1627
+ raise RuntimeError("DocManager lacks open_array/create_document")
1628
+
1629
+ except Exception as e:
1630
+ QMessageBox.critical(self, "Finder Chart", f"Failed to open new view:\n{e}")
1631
+
1632
+
1633
+ def _get_doc_manager(self):
1634
+ mw = self.parent()
1635
+ if mw is None:
1636
+ return None
1637
+ return getattr(mw, "docman", None) or getattr(mw, "doc_manager", None)
1638
+
1639
+