setiastrosuitepro 1.8.0__py3-none-any.whl → 1.8.1.post2__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.

@@ -17,6 +17,24 @@ APP_FOLDER_NAME = "SetiAstroSuitePro" # keep stable
17
17
  ProgressCB = Optional[Callable[[str], None]]
18
18
 
19
19
 
20
+ def model_path(filename: str) -> Path:
21
+ p = Path(models_root()) / filename
22
+ return p
23
+
24
+ def require_model(filename: str) -> Path:
25
+ """
26
+ Return full path to a runtime-managed model file.
27
+ Raises FileNotFoundError with a helpful message if missing.
28
+ """
29
+ p = model_path(filename)
30
+ if not p.exists():
31
+ raise FileNotFoundError(
32
+ f"Model not found: {p}\n"
33
+ f"Expected models in: {models_root()}\n"
34
+ f"Please install/download the Cosmic Clarity models."
35
+ )
36
+ return p
37
+
20
38
  def app_data_root() -> str:
21
39
  """
22
40
  Frozen-safe persistent data root.
@@ -113,6 +131,71 @@ def _parse_gdrive_download_form(html: str) -> tuple[Optional[str], Optional[dict
113
131
 
114
132
  return action, params
115
133
 
134
+ def download_http_file(
135
+ url: str,
136
+ dst_path: str | os.PathLike,
137
+ *,
138
+ progress_cb: ProgressCB = None,
139
+ should_cancel=None,
140
+ timeout: int = 60,
141
+ chunk_size: int = 1024 * 1024,
142
+ ) -> Path:
143
+ import requests
144
+
145
+ dst = Path(dst_path)
146
+ tmp = dst.with_suffix(dst.suffix + ".part")
147
+ tmp.parent.mkdir(parents=True, exist_ok=True)
148
+
149
+ def log(msg: str):
150
+ if progress_cb:
151
+ progress_cb(msg)
152
+
153
+ try:
154
+ tmp.unlink(missing_ok=True)
155
+ except Exception:
156
+ pass
157
+
158
+ with requests.Session() as s:
159
+ log(f"Connecting… {url}")
160
+ r = s.get(url, stream=True, timeout=timeout, allow_redirects=True)
161
+ r.raise_for_status()
162
+
163
+ total = int(r.headers.get("Content-Length") or 0)
164
+ done = 0
165
+ t_last = time.time()
166
+ done_last = 0
167
+
168
+ with open(tmp, "wb") as f:
169
+ for chunk in r.iter_content(chunk_size=chunk_size):
170
+ if should_cancel and should_cancel():
171
+ try:
172
+ f.close()
173
+ tmp.unlink(missing_ok=True)
174
+ except Exception:
175
+ pass
176
+ raise RuntimeError("Download canceled.")
177
+
178
+ if not chunk:
179
+ continue
180
+
181
+ f.write(chunk)
182
+ done += len(chunk)
183
+
184
+ now = time.time()
185
+ if now - t_last >= 0.5:
186
+ if total > 0:
187
+ pct = (done * 100.0) / total
188
+ log(f"Downloading… {pct:5.1f}% ({done}/{total} bytes)")
189
+ else:
190
+ bps = (done - done_last) / max(now - t_last, 1e-9)
191
+ log(f"Downloading… {done} bytes ({bps/1024/1024:.1f} MB/s)")
192
+ t_last = now
193
+ done_last = done
194
+
195
+ os.replace(str(tmp), str(dst))
196
+ log(f"Download complete: {dst}")
197
+ return dst
198
+
116
199
 
117
200
  def download_google_drive_file(
118
201
  file_id: str,
@@ -9,57 +9,128 @@ import zipfile
9
9
  from setiastro.saspro.model_manager import (
10
10
  extract_drive_file_id,
11
11
  download_google_drive_file,
12
+ download_http_file,
12
13
  install_models_zip,
13
14
  sha256_file,
14
15
  )
15
16
 
17
+ class ModelsInstallZipWorker(QObject):
18
+ progress = pyqtSignal(str)
19
+ finished = pyqtSignal(bool, str)
20
+
21
+ def __init__(self, zip_path: str, should_cancel=None):
22
+ super().__init__()
23
+ self.zip_path = zip_path
24
+ self.should_cancel = should_cancel # optional callable
25
+
26
+ def run(self):
27
+ try:
28
+ from setiastro.saspro.model_manager import install_models_zip, sha256_file
29
+
30
+ if not self.zip_path or not os.path.exists(self.zip_path):
31
+ raise RuntimeError("ZIP file not found.")
32
+
33
+ self.progress.emit("Verifying ZIP…")
34
+ # quick hash (optional but helpful for support logs)
35
+ zhash = sha256_file(self.zip_path)
36
+
37
+ manifest = {
38
+ "source": "manual_zip",
39
+ "file": os.path.basename(self.zip_path),
40
+ "sha256": zhash,
41
+ }
42
+
43
+ install_models_zip(
44
+ self.zip_path,
45
+ progress_cb=lambda s: self.progress.emit(s),
46
+ manifest=manifest,
47
+ )
48
+
49
+ self.finished.emit(True, "Models installed successfully from ZIP.")
50
+ except Exception as e:
51
+ self.finished.emit(False, str(e))
52
+
53
+
16
54
  class ModelsDownloadWorker(QObject):
17
55
  progress = pyqtSignal(str)
18
56
  finished = pyqtSignal(bool, str)
19
57
 
20
- def __init__(self, primary: str, backup: str, expected_sha256: str | None = None, should_cancel=None):
58
+ def __init__(self, primary: str, backup: str, tertiary: str | None = None,
59
+ expected_sha256: str | None = None, should_cancel=None):
21
60
  super().__init__()
22
61
  self.primary = primary
23
62
  self.backup = backup
63
+ self.tertiary = (tertiary or "").strip() or None
24
64
  self.expected_sha256 = (expected_sha256 or "").strip() or None
25
65
  self.should_cancel = should_cancel # callable -> bool
26
66
 
27
67
  def run(self):
28
68
  try:
29
- # The inputs should be FILE links (or IDs), not folder links.
30
- fid = extract_drive_file_id(self.primary) or extract_drive_file_id(self.backup)
31
- if not fid:
32
- raise RuntimeError(
33
- "Models URL is not a Google Drive *file* link or id.\n"
34
- "Please provide a shared file link (…/file/d/<ID>/view) to the models zip."
35
- )
36
-
37
69
  tmp = os.path.join(tempfile.gettempdir(), "saspro_models_latest.zip")
38
- try:
39
- self.progress.emit("Downloading from primary…")
40
- download_google_drive_file(fid, tmp, progress_cb=lambda s: self.progress.emit(s), should_cancel=self.should_cancel)
41
- except Exception as e:
42
- # Try backup if primary fails AND backup has a different file id
43
- fid2 = extract_drive_file_id(self.backup)
44
- if fid2 and fid2 != fid:
45
- self.progress.emit("Primary failed. Trying backup…")
46
- download_google_drive_file(fid2, tmp, progress_cb=lambda s: self.progress.emit(s), should_cancel=self.should_cancel)
47
- else:
48
- raise
49
70
 
71
+ # 1) Try Google Drive primary/backup (by file id)
72
+ fid_primary = extract_drive_file_id(self.primary)
73
+ fid_backup = extract_drive_file_id(self.backup)
74
+
75
+ drive_ok = False
76
+ used_source = None
77
+
78
+ if fid_primary or fid_backup:
79
+ try:
80
+ if fid_primary:
81
+ self.progress.emit("Downloading from primary (Google Drive)…")
82
+ download_google_drive_file(
83
+ fid_primary, tmp,
84
+ progress_cb=lambda s: self.progress.emit(s),
85
+ should_cancel=self.should_cancel
86
+ )
87
+ drive_ok = True
88
+ used_source = ("google_drive", fid_primary)
89
+ else:
90
+ raise RuntimeError("Primary is not a valid Drive file link/id.")
91
+ except Exception:
92
+ # Try backup if different id
93
+ if fid_backup and fid_backup != fid_primary:
94
+ self.progress.emit("Primary failed. Trying backup (Google Drive)…")
95
+ download_google_drive_file(
96
+ fid_backup, tmp,
97
+ progress_cb=lambda s: self.progress.emit(s),
98
+ should_cancel=self.should_cancel
99
+ )
100
+ drive_ok = True
101
+ used_source = ("google_drive", fid_backup)
102
+
103
+ # 2) If Drive failed (or links weren’t Drive), try GitHub / HTTP tertiary
104
+ if not drive_ok:
105
+ if not self.tertiary:
106
+ raise RuntimeError(
107
+ "Google Drive download failed and no tertiary mirror URL was provided."
108
+ )
109
+ self.progress.emit("Google Drive failed. Trying GitHub mirror…")
110
+ download_http_file(
111
+ self.tertiary, tmp,
112
+ progress_cb=lambda s: self.progress.emit(s),
113
+ should_cancel=self.should_cancel
114
+ )
115
+ used_source = ("http", self.tertiary)
116
+
117
+ # 3) Optional checksum
50
118
  if self.expected_sha256:
51
119
  self.progress.emit("Verifying checksum…")
52
120
  got = sha256_file(tmp)
53
121
  if got.lower() != self.expected_sha256.lower():
54
122
  raise RuntimeError(f"SHA256 mismatch.\nExpected: {self.expected_sha256}\nGot: {got}")
55
123
 
124
+ # 4) Install + manifest
125
+ src_kind, src_val = used_source if used_source else ("unknown", "")
56
126
  manifest = {
57
- "source": "google_drive",
58
- "file_id": fid,
59
- "sha256": self.expected_sha256,
127
+ "source": src_kind,
128
+ "source_ref": src_val,
129
+ "sha256": self.expected_sha256 or "",
60
130
  }
61
- install_models_zip(tmp, progress_cb=lambda s: self.progress.emit(s), manifest=manifest)
62
131
 
132
+ install_models_zip(tmp, progress_cb=lambda s: self.progress.emit(s), manifest=manifest)
63
133
  self.finished.emit(True, "Models updated successfully.")
64
134
  except Exception as e:
65
135
  self.finished.emit(False, str(e))
136
+
@@ -1,8 +1,9 @@
1
1
  # ops.settings.py
2
2
  from PyQt6.QtWidgets import (
3
- QLineEdit, QDialogButtonBox, QFileDialog, QDialog, QPushButton, QFormLayout,QApplication,
3
+ QLineEdit, QDialogButtonBox, QFileDialog, QDialog, QPushButton, QFormLayout,QApplication, QMenu,
4
4
  QHBoxLayout, QVBoxLayout, QWidget, QCheckBox, QComboBox, QSpinBox, QDoubleSpinBox, QLabel, QColorDialog, QFontDialog, QSlider)
5
5
  from PyQt6.QtCore import QSettings, Qt
6
+ from PyQt6.QtGui import QAction
6
7
  import pytz # for timezone list
7
8
  from setiastro.saspro.accel_installer import current_backend
8
9
  import sys, platform
@@ -12,6 +13,7 @@ from PyQt6.QtCore import QThread
12
13
  from setiastro.saspro.i18n import get_available_languages, get_saved_language, save_language
13
14
  import importlib.util
14
15
  import importlib.metadata
16
+ import webbrowser
15
17
 
16
18
  class SettingsDialog(QDialog):
17
19
  """
@@ -240,7 +242,24 @@ class SettingsDialog(QDialog):
240
242
 
241
243
  self.btn_models_update = QPushButton(self.tr("Download/Update Models…"))
242
244
  self.btn_models_update.clicked.connect(self._models_update_clicked)
243
- right_col.addRow(self.btn_models_update)
245
+
246
+ self.btn_models_install_zip = QPushButton(self.tr("Install from ZIP…"))
247
+ self.btn_models_install_zip.setToolTip(self.tr("Use a manually downloaded models .zip file"))
248
+ self.btn_models_install_zip.clicked.connect(self._models_install_from_zip_clicked)
249
+
250
+ self.btn_models_open_drive = QPushButton(self.tr("Open Drive…"))
251
+ self.btn_models_open_drive.setToolTip(self.tr("Download models (Primary/Backup/GitHub mirror)"))
252
+
253
+ self.btn_models_open_drive.clicked.connect(self._models_open_drive_clicked)
254
+
255
+ row_models = QHBoxLayout()
256
+ row_models.addWidget(self.btn_models_update, 1)
257
+ row_models.addWidget(self.btn_models_install_zip)
258
+ row_models.addWidget(self.btn_models_open_drive)
259
+
260
+ w_models = QWidget()
261
+ w_models.setLayout(row_models)
262
+ right_col.addRow(w_models)
244
263
 
245
264
  # ---- Right column: WIMS + RA/Dec + Updates + Display ----
246
265
  right_col.addRow(QLabel(self.tr("<b>What's In My Sky — Defaults</b>")))
@@ -298,6 +317,92 @@ class SettingsDialog(QDialog):
298
317
  # Initial Load:
299
318
  self.refresh_ui()
300
319
 
320
+ def _models_open_drive_clicked(self):
321
+ PRIMARY_FOLDER = "https://drive.google.com/drive/folders/1-fktZb3I9l-mQimJX2fZAmJCBj_t0yAF?usp=drive_link"
322
+ BACKUP_FOLDER = "https://drive.google.com/drive/folders/1j46RV6touQtOmtxkhdFWGm_LQKwEpTl9?usp=drive_link"
323
+ GITHUB_ZIP = "https://github.com/setiastro/setiastrosuitepro/releases/download/benchmarkFIT/SASPro_Models_Latest.zip"
324
+
325
+ menu = QMenu(self)
326
+ act_primary = menu.addAction(self.tr("Primary (Google Drive)"))
327
+ act_backup = menu.addAction(self.tr("Backup (Google Drive)"))
328
+ menu.addSeparator()
329
+ act_gh = menu.addAction(self.tr("GitHub (no quota limit)"))
330
+
331
+ chosen = menu.exec(self.btn_models_open_drive.mapToGlobal(self.btn_models_open_drive.rect().bottomLeft()))
332
+ if chosen == act_primary:
333
+ webbrowser.open(PRIMARY_FOLDER)
334
+ elif chosen == act_backup:
335
+ webbrowser.open(BACKUP_FOLDER)
336
+ elif chosen == act_gh:
337
+ webbrowser.open(GITHUB_ZIP)
338
+
339
+
340
+
341
+ def _models_install_from_zip_clicked(self):
342
+ from PyQt6.QtWidgets import QFileDialog, QMessageBox, QProgressDialog
343
+ from PyQt6.QtCore import Qt, QThread
344
+ import os
345
+
346
+ zip_path, _ = QFileDialog.getOpenFileName(
347
+ self,
348
+ self.tr("Select models ZIP"),
349
+ "",
350
+ self.tr("ZIP files (*.zip);;All files (*)")
351
+ )
352
+ if not zip_path:
353
+ return
354
+
355
+ if not os.path.exists(zip_path):
356
+ QMessageBox.warning(self, self.tr("Models"), self.tr("File not found."))
357
+ return
358
+
359
+ self.btn_models_update.setEnabled(False)
360
+ self.btn_models_install_zip.setEnabled(False)
361
+
362
+ pd = QProgressDialog(self.tr("Preparing…"), self.tr("Cancel"), 0, 0, self)
363
+ pd.setWindowTitle(self.tr("Installing Models"))
364
+ pd.setWindowModality(Qt.WindowModality.ApplicationModal)
365
+ pd.setAutoClose(True)
366
+ pd.setMinimumDuration(0)
367
+ pd.show()
368
+
369
+ from setiastro.saspro.model_workers import ModelsInstallZipWorker
370
+
371
+ self._models_thread = QThread(self)
372
+ self._models_worker = ModelsInstallZipWorker(zip_path)
373
+ self._models_worker.moveToThread(self._models_thread)
374
+
375
+ self._models_thread.started.connect(self._models_worker.run, Qt.ConnectionType.QueuedConnection)
376
+ self._models_worker.progress.connect(pd.setLabelText, Qt.ConnectionType.QueuedConnection)
377
+
378
+ def _cancel():
379
+ if self._models_thread.isRunning():
380
+ self._models_thread.requestInterruption()
381
+ pd.canceled.connect(_cancel, Qt.ConnectionType.QueuedConnection)
382
+
383
+ def _done(ok: bool, msg: str):
384
+ pd.reset()
385
+ pd.deleteLater()
386
+
387
+ self._models_thread.quit()
388
+ self._models_thread.wait()
389
+
390
+ self.btn_models_update.setEnabled(True)
391
+ self.btn_models_install_zip.setEnabled(True)
392
+ self._refresh_models_status()
393
+
394
+ if ok:
395
+ QMessageBox.information(self, self.tr("Models"), self.tr("✅ {0}").format(msg))
396
+ else:
397
+ QMessageBox.warning(self, self.tr("Models"), self.tr("❌ {0}").format(msg))
398
+
399
+ self._models_worker.finished.connect(_done, Qt.ConnectionType.QueuedConnection)
400
+ self._models_thread.finished.connect(self._models_worker.deleteLater, Qt.ConnectionType.QueuedConnection)
401
+ self._models_thread.finished.connect(self._models_thread.deleteLater, Qt.ConnectionType.QueuedConnection)
402
+
403
+ self._models_thread.start()
404
+
405
+
301
406
  def _models_update_clicked(self):
302
407
  from PyQt6.QtWidgets import QMessageBox, QProgressDialog
303
408
  from PyQt6.QtCore import Qt, QThread
@@ -306,6 +411,7 @@ class SettingsDialog(QDialog):
306
411
  # Put your actual *zip file* share links here once you create them.
307
412
  PRIMARY = "https://drive.google.com/file/d/1n4p0grtNpfllalMqtgaEmsTYaFhT5u7Y/view?usp=drive_link"
308
413
  BACKUP = "https://drive.google.com/file/d/1uRGJCITlfMMN89ZkOO5ICWEKMH24KGit/view?usp=drive_link"
414
+ TERTIARY = "https://github.com/setiastro/setiastrosuitepro/releases/download/benchmarkFIT/SASPro_Models_Latest.zip"
309
415
 
310
416
 
311
417
  self.btn_models_update.setEnabled(False)
@@ -320,7 +426,7 @@ class SettingsDialog(QDialog):
320
426
  from setiastro.saspro.model_workers import ModelsDownloadWorker
321
427
 
322
428
  self._models_thread = QThread(self)
323
- self._models_worker = ModelsDownloadWorker(PRIMARY, BACKUP, expected_sha256=None)
429
+ self._models_worker = ModelsDownloadWorker(PRIMARY, BACKUP, TERTIARY, expected_sha256=None)
324
430
  self._models_worker.moveToThread(self._models_thread)
325
431
 
326
432
  self._models_thread.started.connect(self._models_worker.run, Qt.ConnectionType.QueuedConnection)
@@ -359,10 +465,39 @@ class SettingsDialog(QDialog):
359
465
  if not m:
360
466
  self.lbl_models_status.setText(self.tr("Status: not installed"))
361
467
  return
362
- fid = m.get("file_id", "")
363
- self.lbl_models_status.setText(
364
- self.tr("Status: installed\nLocation: {0}").format(models_root())
365
- )
468
+
469
+ # New fields (preferred)
470
+ src = (m.get("source") or "").strip()
471
+ ref = (m.get("source_ref") or "").strip()
472
+ sha = (m.get("sha256") or "").strip()
473
+
474
+ # Back-compat with older manifests
475
+ if not src:
476
+ # old versions used google_drive + file_id
477
+ src = "google_drive" if m.get("file_id") else (m.get("source") or "unknown")
478
+ if not ref:
479
+ ref = (m.get("file_id") or m.get("file") or "").strip()
480
+
481
+ # Keep it short + readable
482
+ src_label = {
483
+ "google_drive": "Google Drive",
484
+ "http": "GitHub/HTTP",
485
+ "manual_zip": "Manual ZIP",
486
+ }.get(src, src or "Unknown")
487
+
488
+ lines = [self.tr("Status: installed"),
489
+ self.tr("Location: {0}").format(models_root())]
490
+
491
+ if ref:
492
+ # show just a compact hint (id or url or filename)
493
+ lines.append(self.tr("Source: {0}").format(src_label))
494
+ lines.append(self.tr("Ref: {0}").format(ref))
495
+
496
+ if sha:
497
+ lines.append(self.tr("SHA256: {0}").format(sha[:12] + "…"))
498
+
499
+ self.lbl_models_status.setText("\n".join(lines))
500
+ self.lbl_models_status.setStyleSheet("color:#888;")
366
501
 
367
502
 
368
503
  def _accel_pref_changed(self, idx: int):
@@ -2175,6 +2175,52 @@ class PlanetProjectionDialog(QDialog):
2175
2175
  self._show_stereo_pair(cross_eye=cross_eye)
2176
2176
  return
2177
2177
 
2178
+ # ---- GALAXY TOP-DOWN (early exit) ----
2179
+ # IMPORTANT: do this BEFORE any ROI crop, otherwise huge galaxies get clipped by ROI sizing.
2180
+ is_galaxy = (ptype == 3) or (mode == 5) # planet_type==Galaxy OR output==Galaxy Polar View
2181
+ if is_galaxy:
2182
+ def to01(x):
2183
+ if x.dtype == np.uint8:
2184
+ return x.astype(np.float32) / 255.0
2185
+ if x.dtype == np.uint16:
2186
+ return x.astype(np.float32) / 65535.0
2187
+ return x.astype(np.float32, copy=False)
2188
+
2189
+ roi = img[..., :3] # FULL IMAGE
2190
+ cx0 = float(cx) # FULL-IMAGE coords
2191
+ cy0 = float(cy)
2192
+ roi01 = to01(roi)
2193
+
2194
+ pa = float(self.spin_ring_pa.value()) # reuse ring PA widget as galaxy PA
2195
+ tilt = float(self.spin_ring_tilt.value()) # reuse ring tilt widget as galaxy b/a
2196
+
2197
+ # output size: tied to full image (clamped)
2198
+ out_size = int(max(256, min(2400, max(roi.shape[0], roi.shape[1]))))
2199
+
2200
+ try:
2201
+ top8 = deproject_galaxy_topdown_u8(
2202
+ roi01,
2203
+ cx0=cx0, cy0=cy0,
2204
+ rpx=float(r),
2205
+ pa_deg=pa,
2206
+ tilt=tilt,
2207
+ out_size=out_size,
2208
+ )
2209
+ except Exception as e:
2210
+ QMessageBox.warning(self, "Galaxy Polar View", f"Failed to deproject galaxy:\n{e}")
2211
+ return
2212
+
2213
+ # push single-frame output
2214
+ self._left = None
2215
+ self._right = None
2216
+ self._wiggle_frames = None
2217
+ self._wiggle_state = False
2218
+
2219
+ self._last_preview_u8 = top8
2220
+ self._push_preview_u8(top8)
2221
+ return
2222
+
2223
+
2178
2224
  # ---- Saturn rings ROI expansion (only increases s) ----
2179
2225
  is_saturn = (self.cmb_planet_type.currentIndex() == 1)
2180
2226
  rings_on = bool(is_saturn and getattr(self, "chk_rings", None) is not None and self.chk_rings.isChecked())
@@ -2209,6 +2255,28 @@ class PlanetProjectionDialog(QDialog):
2209
2255
  y1 = min(Hfull, y0 + s)
2210
2256
 
2211
2257
  roi = img[y0:y1, x0:x1, :3]
2258
+ # ---- GALAXY ROI expansion (ensure full projected ellipse fits) ----
2259
+ if ptype == 3:
2260
+ tilt = float(self.spin_ring_tilt.value())
2261
+ pa = float(self.spin_ring_pa.value())
2262
+
2263
+ tilt = float(np.clip(tilt, 0.02, 1.0))
2264
+
2265
+ # ellipse in SOURCE pixels
2266
+ a = float(r) # semi-major
2267
+ b = max(1.0, a * tilt) # semi-minor
2268
+
2269
+ th = np.deg2rad(pa)
2270
+ cth, sth = np.cos(th), np.sin(th)
2271
+
2272
+ # bounding half-extents of rotated ellipse
2273
+ dx = np.sqrt((a * cth) ** 2 + (b * sth) ** 2)
2274
+ dy = np.sqrt((a * sth) ** 2 + (b * cth) ** 2)
2275
+ need_half = float(max(dx, dy))
2276
+
2277
+ margin = 12.0
2278
+ s_need = int(np.ceil(2.0 * (need_half + margin)))
2279
+ s = max(s, s_need)
2212
2280
 
2213
2281
  # ---- disk mask (ROI coords) ----
2214
2282
  H0, W0 = roi.shape[:2]
@@ -2226,42 +2294,6 @@ class PlanetProjectionDialog(QDialog):
2226
2294
 
2227
2295
  theta = float(self.spin_theta.value())
2228
2296
 
2229
- # ---- GALAXY TOP-DOWN (early exit) ----
2230
- is_galaxy = (ptype == 3) or (mode == 5) # planet_type==Galaxy OR output==Galaxy Polar View
2231
-
2232
- if is_galaxy:
2233
- # Galaxy wants the ROI disk params (cx0, cy0, r) + PA/tilt
2234
- roi01 = to01(roi)
2235
-
2236
- pa = float(self.spin_ring_pa.value()) # reuse ring PA widget as galaxy PA
2237
- tilt = float(self.spin_ring_tilt.value()) # reuse ring tilt widget as galaxy b/a
2238
-
2239
- # choose output size: use ROI size or clamp to something reasonable
2240
- out_size = int(max(256, min(2000, max(roi.shape[0], roi.shape[1]))))
2241
-
2242
- try:
2243
- top8 = deproject_galaxy_topdown_u8(
2244
- roi01,
2245
- cx0=float(cx0), cy0=float(cy0),
2246
- rpx=float(r),
2247
- pa_deg=pa,
2248
- tilt=tilt,
2249
- out_size=out_size,
2250
- )
2251
- except Exception as e:
2252
- QMessageBox.warning(self, "Galaxy Polar View", f"Failed to deproject galaxy:\n{e}")
2253
- return
2254
-
2255
- # push single-frame output
2256
- self._left = None
2257
- self._right = None
2258
- self._wiggle_frames = None
2259
- self._wiggle_state = False
2260
-
2261
- self._last_preview_u8 = top8
2262
- self._push_preview_u8(top8)
2263
- return
2264
-
2265
2297
  # ---- BODY (sphere reprojection) ----
2266
2298
  interp = cv2.INTER_LANCZOS4
2267
2299
  left_w, right_w, maskL, maskR = make_stereo_pair(
@@ -603,23 +603,27 @@ class Resources:
603
603
 
604
604
  @lru_cache(maxsize=8)
605
605
  def get_models_dir() -> str:
606
- # Prefer user-installed models
607
- try:
608
- from setiastro.saspro.model_manager import models_root
609
- p = Path(models_root())
610
- if p.is_dir():
611
- if any(p.rglob("*.pth")) or any(p.rglob("*.onnx")):
612
- return str(p)
613
- except Exception:
614
- pass
606
+ """
607
+ Models are NOT packaged resources. They must be installed via the model manager.
608
+ This returns the user models root and never falls back to _internal/data/models.
609
+ """
610
+ from setiastro.saspro.model_manager import models_root
611
+ p = Path(models_root())
612
+ # Ensure dir exists (models_root should already do this, but harmless)
613
+ p.mkdir(parents=True, exist_ok=True)
614
+ return str(p)
615
+
615
616
 
616
- # Fallback to packaged (dev/frozen) path
617
- return _resource_path('data/models')
617
+ def _assert_not_internal_models_path(p: str):
618
+ s = str(p).lower().replace("/", "\\")
619
+ if "\\_internal\\" in s and "\\data\\models\\" in s:
620
+ raise RuntimeError(f"Legacy internal model path detected: {p}")
618
621
 
619
622
  def model_path(filename: str) -> str:
620
- base = Path(get_models_dir())
621
- path = base / filename
622
- return str(path)
623
+ from setiastro.saspro.model_manager import require_model
624
+ p = str(require_model(filename))
625
+ _assert_not_internal_models_path(p)
626
+ return p
623
627
 
624
628
  # Export all legacy paths as module-level variables
625
629
  _legacy = _init_legacy_paths()