setiastrosuitepro 1.8.2__py3-none-any.whl → 1.8.3__py3-none-any.whl

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

Potentially problematic release.


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

Binary file
@@ -1,3 +1,3 @@
1
1
  # Auto-generated at build time. Do not edit.
2
- BUILD_TIMESTAMP = "2026-01-29T01:10:38Z"
3
- APP_VERSION = "1.8.2"
2
+ BUILD_TIMESTAMP = "2026-01-29T18:14:59Z"
3
+ APP_VERSION = "1.8.3"
@@ -168,40 +168,78 @@ def _find_best_patch_center(lum: np.ndarray) -> tuple[int, int]:
168
168
  return best_pt
169
169
 
170
170
 
171
- def auto_rect_50x50(img_rgb: np.ndarray) -> tuple[int, int, int, int]:
171
+
172
+ def auto_rect_box(img_rgb: np.ndarray, box: int = 50, margin: int = 100) -> tuple[int, int, int, int]:
172
173
  """
173
- Find a robust 50×50 background rectangle (≥100 px margins) in image space.
174
+ Find a robust box×box background rectangle (>= margin px margins) in image space.
174
175
  Returns (x, y, w, h).
176
+
177
+ Notes:
178
+ - img_rgb must be HxWx3 float/uint in any range (we only use relative luminance).
179
+ - box is clamped so it always fits within the image + margins.
175
180
  """
176
- h, w, ch = img_rgb.shape
177
- if ch != 3:
181
+ if img_rgb.ndim != 3 or img_rgb.shape[2] != 3:
178
182
  raise ValueError("Auto background finder expects a 3-channel RGB image.")
179
- lum = img_rgb.mean(axis=2).astype(np.float32)
180
183
 
184
+ H, W, _ = img_rgb.shape
185
+ box = int(box)
186
+
187
+ # Clamp box so it fits inside the image after margins.
188
+ # Ensure at least 10px and at least 1px interior.
189
+ max_box_w = max(10, W - 2 * margin - 2)
190
+ max_box_h = max(10, H - 2 * margin - 2)
191
+ max_box = max(10, min(max_box_w, max_box_h))
192
+ box = int(np.clip(box, 10, max_box))
193
+
194
+ half = box // 2
195
+
196
+ # Luminance proxy
197
+ lum = img_rgb.mean(axis=2).astype(np.float32, copy=False)
198
+
199
+ # Your existing routine (assumed to return (cy, cx) in image coords)
181
200
  cy, cx = _find_best_patch_center(lum)
182
201
 
183
- margin = 100
184
- half = 25
185
- min_cx, max_cx = margin + half, w - (margin + half)
186
- min_cy, max_cy = margin + half, h - (margin + half)
202
+ # Keep center far enough from edges so the full box fits
203
+ min_cx, max_cx = margin + half, W - (margin + half)
204
+ min_cy, max_cy = margin + half, H - (margin + half)
187
205
  cx = int(np.clip(cx, min_cx, max_cx))
188
206
  cy = int(np.clip(cy, min_cy, max_cy))
189
207
 
190
- # refine by ±half
208
+ # Refine around the center.
209
+ # Step scales with box so the search is meaningful at different sizes.
210
+ step = max(4, half // 2) # e.g. 50->12, 80->20, 30->7
191
211
  best_val = np.inf
192
212
  ty, tx = cy, cx
193
- for dy in (-half, 0, +half):
194
- for dx in (-half, 0, +half):
213
+
214
+ for dy in (-step, 0, +step):
215
+ for dx in (-step, 0, +step):
195
216
  y = int(np.clip(cy + dy, min_cy, max_cy))
196
217
  x = int(np.clip(cx + dx, min_cx, max_cx))
197
- y0, y1 = y - half, y + half
198
- x0, x1 = x - half, x + half
199
- m = np.median(lum[y0:y1, x0:x1])
218
+ y0, y1 = y - half, y - half + box
219
+ x0, x1 = x - half, x - half + box
220
+
221
+ # Safety (should already be safe due to clamping, but keep it robust)
222
+ if y0 < 0 or x0 < 0 or y1 > H or x1 > W:
223
+ continue
224
+
225
+ m = float(np.median(lum[y0:y1, x0:x1]))
200
226
  if m < best_val:
201
227
  best_val, ty, tx = m, y, x
202
228
 
203
- return (tx - half, ty - half, 50, 50)
229
+ # Top-left anchored rect, exact box size
230
+ x0 = int(tx - half)
231
+ y0 = int(ty - half)
232
+
233
+ # Final clamp (again, belt + suspenders)
234
+ x0 = int(np.clip(x0, margin, W - margin - box))
235
+ y0 = int(np.clip(y0, margin, H - margin - box))
204
236
 
237
+ return (x0, y0, box, box)
238
+
239
+
240
+ def auto_rect_50x50(img_rgb: np.ndarray) -> tuple[int, int, int, int]:
241
+ """Backward-compatible wrapper."""
242
+ return auto_rect_box(img_rgb, box=50, margin=100)
205
243
 
206
244
  # --------------------------------
207
245
  # Headless apply (doc + preset in)
@@ -658,16 +658,26 @@ def _place_label_inside(ax, x, y, text, *, dx=6, dy=4, fontsize=9,
658
658
 
659
659
  return t
660
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
+ }
661
678
 
662
679
  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"
680
+ return SURVEY_HIPS.get(label, "CDS/P/DSS2/color")
671
681
 
672
682
  def try_fetch_hips_cutout(center: "SkyCoord", fov_deg: float, out_px: int, survey_label: str):
673
683
  """
@@ -1149,7 +1159,8 @@ class FinderChartDialog(QDialog):
1149
1159
 
1150
1160
  row1.addWidget(QLabel("Survey:"))
1151
1161
  self.cmb_survey = QComboBox()
1152
- self.cmb_survey.addItems(["DSS2", "Pan-STARRS", "Gaia"])
1162
+ self.cmb_survey.clear()
1163
+ self.cmb_survey.addItems(list(SURVEY_HIPS.keys()))
1153
1164
  row1.addWidget(self.cmb_survey)
1154
1165
 
1155
1166
  row1.addSpacing(12)
@@ -186,7 +186,7 @@ from setiastro.saspro.resources import (
186
186
  colorwheel_path, font_path, csv_icon_path, spinner_path, wims_path, narrowbandnormalization_path,
187
187
  wimi_path, linearfit_path, debayer_path, aberration_path, acv_icon_path,
188
188
  functionbundles_path, viewbundles_path, selectivecolor_path, rgbalign_path, planetarystacker_path,
189
- background_path, script_icon_path, planetprojection_path,clonestampicon_path, finderchart_path,
189
+ background_path, script_icon_path, planetprojection_path,clonestampicon_path, finderchart_path,magnitude_path,
190
190
  )
191
191
 
192
192
  import faulthandler
@@ -3185,6 +3185,45 @@ class AstroSuiteProMainWindow(
3185
3185
  pass
3186
3186
  self.SFCC_window.show()
3187
3187
 
3188
+ def _open_magnitude_tool(self):
3189
+ import os
3190
+ from PyQt6.QtGui import QIcon
3191
+ from PyQt6.QtWidgets import QMessageBox
3192
+
3193
+ # Keep same window-singleton behavior as SFCC
3194
+ if getattr(self, "MAG_window", None) and self.MAG_window.isVisible():
3195
+ self.MAG_window.raise_()
3196
+ self.MAG_window.activateWindow()
3197
+ return
3198
+
3199
+ # ensure we have a DocManager (mirror SFCC pattern)
3200
+ from setiastro.saspro.doc_manager import DocManager
3201
+ if not hasattr(self, "doc_manager") or self.doc_manager is None:
3202
+ self.doc_manager = DocManager(image_manager=getattr(self, "image_manager", None), parent=self)
3203
+
3204
+ # import tool
3205
+ from setiastro.saspro.magnitude_tool import MagnitudeToolDialog
3206
+
3207
+ self.MAG_window = MagnitudeToolDialog(
3208
+ doc_manager=self.doc_manager,
3209
+ parent=self
3210
+ )
3211
+
3212
+ # optional icon
3213
+ try:
3214
+ self.MAG_window.setWindowIcon(QIcon(magnitude_path))
3215
+ except Exception:
3216
+ pass
3217
+
3218
+ # cleanup
3219
+ try:
3220
+ self.MAG_window.destroyed.connect(lambda _=None: setattr(self, "MAG_window", None))
3221
+ except Exception:
3222
+ pass
3223
+
3224
+ self.MAG_window.show()
3225
+
3226
+
3188
3227
  def show_convo_deconvo(self, doc=None):
3189
3228
  # Reuse existing dialog if it's already open
3190
3229
  sw = self.mdi.activeSubWindow()
@@ -3496,13 +3535,53 @@ class AstroSuiteProMainWindow(
3496
3535
  base = base_title or (getattr(doc, "display_name", lambda: None)() or "RGB")
3497
3536
 
3498
3537
  def _open(arr, suffix):
3499
- meta = {
3538
+ from astropy.io import fits
3539
+
3540
+ # ---- 1) start from source metadata (preserve headers/WCS) ----
3541
+ src_meta = getattr(doc, "metadata", {}) or {}
3542
+ meta = dict(src_meta) # shallow copy of top-level dict
3543
+
3544
+ # Preserve header objects safely
3545
+ fits_hdr = src_meta.get("fits_header")
3546
+ wcs_hdr = src_meta.get("wcs_header")
3547
+ orig_hdr = src_meta.get("original_header")
3548
+
3549
+ # Keep them if they are astropy Headers; otherwise fall back gracefully
3550
+ meta["fits_header"] = fits_hdr.copy() if isinstance(fits_hdr, fits.Header) else fits_hdr
3551
+ meta["wcs_header"] = wcs_hdr.copy() if isinstance(wcs_hdr, fits.Header) else wcs_hdr
3552
+ meta["original_header"] = orig_hdr.copy() if isinstance(orig_hdr, fits.Header) else orig_hdr
3553
+
3554
+ # Preserve WCS object + flags (ok if None)
3555
+ if "wcs" in src_meta:
3556
+ meta["wcs"] = src_meta.get("wcs")
3557
+ if "HasAstrometricSolution" in src_meta:
3558
+ meta["HasAstrometricSolution"] = src_meta.get("HasAstrometricSolution")
3559
+
3560
+ # Preserve image_meta mirror, but make it a copy so we don't mutate parent
3561
+ im = src_meta.get("image_meta")
3562
+ if isinstance(im, dict):
3563
+ meta["image_meta"] = dict(im)
3564
+
3565
+ # ---- 2) overwrite/extend with RGB-extract specific fields ----
3566
+ base = base_title or (getattr(doc, "display_name", lambda: None)() or "RGB")
3567
+ title = f"{base}_{suffix}"
3568
+ try:
3569
+ fh = meta.get("fits_header")
3570
+ if isinstance(fh, fits.Header):
3571
+ fh["NAXIS"] = 2
3572
+ if "NAXIS3" in fh:
3573
+ del fh["NAXIS3"]
3574
+ except Exception:
3575
+ pass
3576
+ meta.update({
3500
3577
  "source": "RGB Extract",
3501
3578
  "is_mono": True,
3502
3579
  "bit_depth": "32-bit floating point",
3503
3580
  "parent_title": base,
3504
- }
3505
- title = f"{base}_{suffix}"
3581
+ "channel": suffix, # handy for later
3582
+ })
3583
+
3584
+ # ---- 3) create doc ----
3506
3585
  try:
3507
3586
  if hasattr(dm, "open_array"):
3508
3587
  newdoc = dm.open_array(arr, metadata=meta, title=title)
@@ -3510,10 +3589,23 @@ class AstroSuiteProMainWindow(
3510
3589
  newdoc = dm.open_numpy(arr, metadata=meta, title=title)
3511
3590
  else:
3512
3591
  newdoc = dm.create_document(image=arr, metadata=meta, name=title)
3592
+
3593
+ # ---- 4) ensure WCS is internally consistent on the new doc ----
3594
+ # If source had a WCS solution, rebuild original_header+wcs using your canonical path.
3595
+ try:
3596
+ if meta.get("HasAstrometricSolution") or (meta.get("wcs_header") is not None):
3597
+ wcs_dict = self._extract_wcs_dict(doc) # from SOURCE doc
3598
+ if wcs_dict:
3599
+ self._apply_wcs_dict_to_doc(newdoc, dict(wcs_dict))
3600
+ except Exception:
3601
+ pass
3602
+
3513
3603
  self._spawn_subwindow_for(newdoc)
3604
+
3514
3605
  except Exception as ex:
3515
3606
  QMessageBox.critical(self, "RGB Extract", f"Failed to open '{title}':\n{ex}")
3516
3607
 
3608
+
3517
3609
  _open(r, "R")
3518
3610
  _open(g, "G")
3519
3611
  _open(b, "B")
@@ -36,16 +36,31 @@ class HeaderMixin:
36
36
  }
37
37
 
38
38
  def _ensure_header_map(self, doc):
39
- """Ensure doc has a header dictionary in metadata, return it."""
39
+ """Ensure doc has an original_header container; preserve fits.Header if present."""
40
40
  meta = getattr(doc, "metadata", None)
41
41
  if meta is None:
42
42
  return None
43
+
43
44
  hdr = meta.get("original_header")
44
- if not isinstance(hdr, dict):
45
- hdr = {}
46
- meta["original_header"] = hdr
45
+
46
+ # Preserve astropy Header
47
+ try:
48
+ from astropy.io.fits import Header
49
+ if isinstance(hdr, Header):
50
+ return hdr
51
+ except Exception:
52
+ pass
53
+
54
+ # Preserve dict
55
+ if isinstance(hdr, dict):
56
+ return hdr
57
+
58
+ # If missing/unknown, create dict (but do NOT overwrite a valid Header above)
59
+ hdr = {}
60
+ meta["original_header"] = hdr
47
61
  return hdr
48
62
 
63
+
49
64
  def _coerce_wcs_numbers(self, d: dict) -> dict:
50
65
  """Convert common WCS/SIP values to int/float where appropriate."""
51
66
  numeric = {
@@ -72,23 +87,33 @@ class HeaderMixin:
72
87
  return out
73
88
 
74
89
  def _extract_wcs_dict(self, doc) -> dict:
75
- """Collect a complete WCS/SIP dict from the doc's header/meta."""
76
90
  if doc is None:
77
91
  return {}
78
- src = (getattr(doc, "metadata", {}) or {}).get("original_header")
79
92
 
80
- wcs = {}
81
- if src is None:
93
+ meta = getattr(doc, "metadata", {}) or {}
94
+
95
+ # 0) Preferred: astropy WCS object in metadata
96
+ w = meta.get("wcs", None)
97
+ try:
98
+ from astropy.wcs import WCS as _AstroWCS
99
+ if isinstance(w, _AstroWCS):
100
+ h = w.to_header(relax=True)
101
+ return self._coerce_wcs_numbers({k.upper(): h[k] for k in h.keys()})
102
+ except Exception:
82
103
  pass
83
- else:
104
+
105
+ src = meta.get("original_header")
106
+
107
+ wcs = {}
108
+ src = meta.get("original_header")
109
+ if src is not None:
84
110
  try:
85
- for k, v in dict(src).items():
111
+ # fits.Header has .keys()
112
+ items = src.items() if hasattr(src, "items") else dict(src).items()
113
+ for k, v in items:
86
114
  K = str(k).upper()
87
- if (K.startswith(("CRPIX", "CRVAL", "CDELT", "CD", "PC", "CROTA", "CTYPE", "CUNIT",
88
- "WCSAXES", "LONPOLE", "LATPOLE", "EQUINOX", "PV")) or
89
- K in {"RADECSYS", "RADESYS", "NAXIS1", "NAXIS2"} or
90
- K.startswith(("A_", "B_", "AP_", "BP_"))):
91
- wcs[K] = v
115
+ ...
116
+ wcs[K] = v
92
117
  except Exception:
93
118
  pass
94
119
 
@@ -181,6 +181,7 @@ class MenuMixin:
181
181
  m_tools.addAction(self.act_ppp)
182
182
 
183
183
  m_tools.addAction(self.act_selective_color)
184
+ m_tools.addAction(self.act_magnitude)
184
185
  m_tools.addSeparator()
185
186
  m_tools.addAction(self.act_view_bundles)
186
187
  m_tools.addAction(self.act_function_bundles)
@@ -36,7 +36,7 @@ from setiastro.saspro.resources import (
36
36
  nbtorgb_path, freqsep_path, multiscale_decomp_path, contsub_path, halo_path, cosmic_path,
37
37
  satellite_path, imagecombine_path, wims_path, wimi_path, linearfit_path,
38
38
  debayer_path, aberration_path, functionbundles_path, viewbundles_path, planetarystacker_path,
39
- selectivecolor_path, rgbalign_path, planetprojection_path, clonestampicon_path, finderchart_path,
39
+ selectivecolor_path, rgbalign_path, planetprojection_path, clonestampicon_path, finderchart_path,magnitude_path,
40
40
  )
41
41
 
42
42
  # Import shortcuts module
@@ -254,6 +254,7 @@ class ToolbarMixin:
254
254
  tb_tl.addAction(self.act_multiscale_decomp)
255
255
  tb_tl.addAction(self.act_contsub)
256
256
  tb_tl.addAction(self.act_image_combine)
257
+ tb_tl.addAction(self.act_magnitude)
257
258
 
258
259
  self._restore_toolbar_order(tb_tl, "Toolbar/Tools")
259
260
  try:
@@ -1149,6 +1150,11 @@ class ToolbarMixin:
1149
1150
  self.act_selective_color.setStatusTip(self.tr("Adjust specific hue ranges with CMY/RGB controls"))
1150
1151
  self.act_selective_color.triggered.connect(self._open_selective_color_tool)
1151
1152
 
1153
+ self.act_magnitude = QAction(QIcon(magnitude_path), self.tr("Magnitude / Surface Brightness..."), self)
1154
+ self.act_magnitude.setStatusTip(self.tr("Measure magnitude and mag/arcsec² from the active linear view"))
1155
+ self.act_magnitude.setIconVisibleInMenu(True)
1156
+ self.act_magnitude.triggered.connect(self._open_magnitude_tool)
1157
+
1152
1158
  # NEW: Frequency Separation
1153
1159
  self.act_freqsep = QAction(QIcon(freqsep_path), self.tr("Frequency Separation..."), self)
1154
1160
  self.act_freqsep.setStatusTip(self.tr("Split into LF/HF and enhance HF (scale, wavelet, denoise)"))
@@ -1441,6 +1447,7 @@ class ToolbarMixin:
1441
1447
  reg("image_combine", self.act_image_combine)
1442
1448
  reg("psf_viewer", self.act_psf_viewer)
1443
1449
  reg("plate_solve", self.act_plate_solve)
1450
+ reg("magnitude_tool", self.act_magnitude)
1444
1451
  reg("star_align", self.act_star_align)
1445
1452
  reg("star_register", self.act_star_register)
1446
1453
  reg("mosaic_master", self.act_mosaic_master)
@@ -1,4 +1,4 @@
1
- # imageops/stretch.py
1
+ # src/setiastro/saspro/imageops/stretch.py
2
2
  from __future__ import annotations
3
3
  import numpy as np
4
4