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.

Files changed (40) hide show
  1. setiastro/images/finderchart.png +0 -0
  2. setiastro/images/magnitude.png +0 -0
  3. setiastro/saspro/__main__.py +29 -38
  4. setiastro/saspro/_generated/build_info.py +2 -2
  5. setiastro/saspro/abe.py +1 -1
  6. setiastro/saspro/backgroundneutral.py +54 -16
  7. setiastro/saspro/blink_comparator_pro.py +3 -1
  8. setiastro/saspro/bright_stars.py +305 -0
  9. setiastro/saspro/continuum_subtract.py +2 -1
  10. setiastro/saspro/cosmicclarity_preset.py +2 -1
  11. setiastro/saspro/doc_manager.py +8 -0
  12. setiastro/saspro/exoplanet_detector.py +22 -17
  13. setiastro/saspro/finder_chart.py +1650 -0
  14. setiastro/saspro/gui/main_window.py +131 -17
  15. setiastro/saspro/gui/mixins/header_mixin.py +40 -15
  16. setiastro/saspro/gui/mixins/menu_mixin.py +3 -0
  17. setiastro/saspro/gui/mixins/toolbar_mixin.py +16 -1
  18. setiastro/saspro/imageops/stretch.py +1 -1
  19. setiastro/saspro/legacy/image_manager.py +18 -4
  20. setiastro/saspro/legacy/xisf.py +3 -3
  21. setiastro/saspro/magnitude_tool.py +1724 -0
  22. setiastro/saspro/main_helpers.py +18 -0
  23. setiastro/saspro/memory_utils.py +18 -14
  24. setiastro/saspro/remove_stars.py +13 -30
  25. setiastro/saspro/resources.py +177 -161
  26. setiastro/saspro/runtime_torch.py +71 -10
  27. setiastro/saspro/sfcc.py +86 -77
  28. setiastro/saspro/stacking_suite.py +4 -3
  29. setiastro/saspro/star_alignment.py +4 -2
  30. setiastro/saspro/texture_clarity.py +1 -1
  31. setiastro/saspro/torch_rejection.py +59 -28
  32. setiastro/saspro/widgets/image_utils.py +12 -4
  33. setiastro/saspro/wimi.py +2 -1
  34. setiastro/saspro/xisf.py +3 -3
  35. {setiastrosuitepro-1.8.1.post2.dist-info → setiastrosuitepro-1.8.3.dist-info}/METADATA +4 -4
  36. {setiastrosuitepro-1.8.1.post2.dist-info → setiastrosuitepro-1.8.3.dist-info}/RECORD +40 -35
  37. {setiastrosuitepro-1.8.1.post2.dist-info → setiastrosuitepro-1.8.3.dist-info}/WHEEL +0 -0
  38. {setiastrosuitepro-1.8.1.post2.dist-info → setiastrosuitepro-1.8.3.dist-info}/entry_points.txt +0 -0
  39. {setiastrosuitepro-1.8.1.post2.dist-info → setiastrosuitepro-1.8.3.dist-info}/licenses/LICENSE +0 -0
  40. {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
+