setiastrosuitepro 1.7.5.post1__py3-none-any.whl → 1.8.0.post3__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 (27) hide show
  1. setiastro/saspro/_generated/build_info.py +2 -2
  2. setiastro/saspro/accel_installer.py +21 -8
  3. setiastro/saspro/accel_workers.py +11 -12
  4. setiastro/saspro/comet_stacking.py +113 -85
  5. setiastro/saspro/cosmicclarity.py +604 -826
  6. setiastro/saspro/cosmicclarity_engines/benchmark_engine.py +732 -0
  7. setiastro/saspro/cosmicclarity_engines/darkstar_engine.py +576 -0
  8. setiastro/saspro/cosmicclarity_engines/denoise_engine.py +567 -0
  9. setiastro/saspro/cosmicclarity_engines/satellite_engine.py +620 -0
  10. setiastro/saspro/cosmicclarity_engines/sharpen_engine.py +587 -0
  11. setiastro/saspro/cosmicclarity_engines/superres_engine.py +412 -0
  12. setiastro/saspro/gui/main_window.py +14 -0
  13. setiastro/saspro/gui/mixins/menu_mixin.py +2 -0
  14. setiastro/saspro/model_manager.py +324 -0
  15. setiastro/saspro/model_workers.py +102 -0
  16. setiastro/saspro/ops/benchmark.py +320 -0
  17. setiastro/saspro/ops/settings.py +407 -10
  18. setiastro/saspro/remove_stars.py +424 -442
  19. setiastro/saspro/resources.py +73 -10
  20. setiastro/saspro/runtime_torch.py +107 -22
  21. setiastro/saspro/signature_insert.py +14 -3
  22. {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/METADATA +2 -1
  23. {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/RECORD +27 -18
  24. {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/WHEEL +0 -0
  25. {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/entry_points.txt +0 -0
  26. {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/licenses/LICENSE +0 -0
  27. {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/licenses/license.txt +0 -0
@@ -0,0 +1,324 @@
1
+ # src/setiastro/saspro/model_manager.py
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ import json
7
+ import time
8
+ import shutil
9
+ import hashlib
10
+ import zipfile
11
+ import tempfile
12
+ from typing import Optional, Callable
13
+ from urllib.parse import urlparse, parse_qs
14
+ from pathlib import Path
15
+
16
+ APP_FOLDER_NAME = "SetiAstroSuitePro" # keep stable
17
+ ProgressCB = Optional[Callable[[str], None]]
18
+
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
+
38
+ def app_data_root() -> str:
39
+ """
40
+ Frozen-safe persistent data root.
41
+ MUST match the benchmark cache dir base (runtime_torch._user_runtime_dir()).
42
+ Example on Windows:
43
+ C:\\Users\\YOU\\AppData\\Local\\SASpro
44
+ """
45
+ from setiastro.saspro.runtime_torch import _user_runtime_dir
46
+ root = Path(_user_runtime_dir()) # this is what benchmark_cache_dir() uses
47
+ root.mkdir(parents=True, exist_ok=True)
48
+ return str(root)
49
+
50
+
51
+ def models_root() -> str:
52
+ p = Path(app_data_root()) / "models"
53
+ p.mkdir(parents=True, exist_ok=True)
54
+ return str(p)
55
+
56
+
57
+ def installed_manifest_path() -> str:
58
+ return str(Path(models_root()) / "manifest.json")
59
+
60
+
61
+ def read_installed_manifest() -> dict:
62
+ try:
63
+ with open(installed_manifest_path(), "r", encoding="utf-8") as f:
64
+ return json.load(f)
65
+ except Exception:
66
+ return {}
67
+
68
+
69
+ def write_installed_manifest(d: dict) -> None:
70
+ try:
71
+ with open(installed_manifest_path(), "w", encoding="utf-8") as f:
72
+ json.dump(d, f, indent=2)
73
+ except Exception:
74
+ pass
75
+
76
+
77
+ # ---------------- Google Drive helpers ----------------
78
+
79
+ _DRIVE_FILE_RE = re.compile(r"/file/d/([a-zA-Z0-9_-]+)")
80
+ _DRIVE_ID_RE = re.compile(r"[?&]id=([a-zA-Z0-9_-]+)")
81
+
82
+
83
+ def extract_drive_file_id(url_or_id: str) -> Optional[str]:
84
+ s = (url_or_id or "").strip()
85
+ if not s:
86
+ return None
87
+
88
+ # raw id
89
+ if re.fullmatch(r"[0-9A-Za-z_-]{10,}", s):
90
+ return s
91
+
92
+ try:
93
+ u = urlparse(s)
94
+ if "drive.google.com" not in (u.netloc or "") and "docs.google.com" not in (u.netloc or ""):
95
+ return None
96
+
97
+ m = re.search(r"/file/d/([^/]+)", u.path or "")
98
+ if m:
99
+ return m.group(1)
100
+
101
+ qs = parse_qs(u.query or "")
102
+ if "id" in qs and qs["id"]:
103
+ return qs["id"][0]
104
+ except Exception:
105
+ return None
106
+
107
+ return None
108
+
109
+
110
+ def _looks_like_html_prefix(b: bytes) -> bool:
111
+ head = (b or b"").lstrip()[:256].lower()
112
+ return head.startswith(b"<!doctype html") or head.startswith(b"<html") or (b"<html" in head)
113
+
114
+
115
+ def _parse_gdrive_download_form(html: str) -> tuple[Optional[str], Optional[dict]]:
116
+ m = re.search(r'<form[^>]+id="download-form"[^>]+action="([^"]+)"', html)
117
+ if not m:
118
+ return None, None
119
+ action = m.group(1)
120
+ params: dict[str, str] = {}
121
+
122
+ for name, val in re.findall(
123
+ r'<input[^>]+type="hidden"[^>]+name="([^"]+)"[^>]*value="([^"]*)"', html
124
+ ):
125
+ params[name] = val
126
+
127
+ for name in re.findall(
128
+ r'<input[^>]+type="hidden"[^>]+name="([^"]+)"(?![^>]*value=)', html
129
+ ):
130
+ params.setdefault(name, "")
131
+
132
+ return action, params
133
+
134
+
135
+ def download_google_drive_file(
136
+ file_id: str,
137
+ dst_path: str | os.PathLike,
138
+ *,
139
+ progress_cb: ProgressCB = None,
140
+ should_cancel=None, # callable -> bool
141
+ timeout: int = 60,
142
+ chunk_size: int = 1024 * 1024,
143
+ ) -> Path:
144
+ """
145
+ Downloads a Google Drive file by ID, handling virus-scan interstitial HTML.
146
+ Writes atomically (dst.part -> dst).
147
+ """
148
+ import requests # local import to keep import cost down
149
+
150
+ fid = extract_drive_file_id(file_id) or file_id
151
+ if not fid:
152
+ raise RuntimeError("No Google Drive file id provided.")
153
+
154
+ dst = Path(dst_path)
155
+ tmp = dst.with_suffix(dst.suffix + ".part")
156
+ tmp.parent.mkdir(parents=True, exist_ok=True)
157
+
158
+ # The “uc” endpoint is best for download
159
+ url = f"https://drive.google.com/uc?export=download&id={fid}"
160
+
161
+ def log(msg: str):
162
+ if progress_cb:
163
+ progress_cb(msg)
164
+
165
+ # Clean any old partial
166
+ try:
167
+ tmp.unlink(missing_ok=True)
168
+ except Exception:
169
+ pass
170
+
171
+ with requests.Session() as s:
172
+ log("Connecting to Google Drive…")
173
+ r = s.get(url, stream=True, timeout=timeout, allow_redirects=True)
174
+
175
+ ctype = (r.headers.get("Content-Type") or "").lower()
176
+
177
+ # If HTML, parse the interstitial "download anyway" form and re-request.
178
+ if "text/html" in ctype:
179
+ html = r.text
180
+ r.close()
181
+ action, params = _parse_gdrive_download_form(html)
182
+ if not action or not params:
183
+ raise RuntimeError(
184
+ "Google Drive returned an interstitial HTML page, but the download form could not be parsed."
185
+ )
186
+ log("Google Drive interstitial detected — confirming download…")
187
+ r = s.get(action, params=params, stream=True, timeout=timeout, allow_redirects=True)
188
+
189
+ r.raise_for_status()
190
+
191
+ total = int(r.headers.get("Content-Length") or 0)
192
+ done = 0
193
+ t_last = time.time()
194
+ done_last = 0
195
+
196
+ first = True
197
+ with open(tmp, "wb") as f:
198
+ for chunk in r.iter_content(chunk_size=chunk_size):
199
+ if should_cancel and should_cancel():
200
+ try:
201
+ f.close()
202
+ tmp.unlink(missing_ok=True)
203
+ except Exception:
204
+ pass
205
+ raise RuntimeError("Download canceled.")
206
+
207
+ if not chunk:
208
+ continue
209
+
210
+ if first:
211
+ first = False
212
+ # extra safety: even if content-type lies
213
+ if _looks_like_html_prefix(chunk[:256]):
214
+ raise RuntimeError(
215
+ "Google Drive returned HTML instead of the file (permission/confirm issue)."
216
+ )
217
+
218
+ f.write(chunk)
219
+ done += len(chunk)
220
+
221
+ now = time.time()
222
+ if now - t_last >= 0.5:
223
+ if total > 0:
224
+ pct = (done * 100.0) / total
225
+ log(f"Downloading… {pct:5.1f}% ({done}/{total} bytes)")
226
+ else:
227
+ bps = (done - done_last) / max(now - t_last, 1e-9)
228
+ log(f"Downloading… {done} bytes ({bps/1024/1024:.1f} MB/s)")
229
+ t_last = now
230
+ done_last = done
231
+
232
+ os.replace(str(tmp), str(dst))
233
+ log(f"Download complete: {dst}")
234
+ return dst
235
+
236
+
237
+ def install_models_zip(
238
+ zip_path: str | os.PathLike,
239
+ *,
240
+ progress_cb: ProgressCB = None,
241
+ manifest: dict | None = None,
242
+ ) -> None:
243
+ """
244
+ Extracts a models zip and installs it into models_root(), replacing previous contents.
245
+ Writes manifest.json if provided.
246
+ """
247
+ dst = Path(models_root())
248
+
249
+ # Use unique temp dirs per install to avoid collisions
250
+ tmp_extract = Path(tempfile.gettempdir()) / f"saspro_models_extract_{os.getpid()}_{int(time.time())}"
251
+ tmp_stage = Path(tempfile.gettempdir()) / f"saspro_models_stage_{os.getpid()}_{int(time.time())}"
252
+
253
+ def log(msg: str):
254
+ if progress_cb:
255
+ progress_cb(msg)
256
+
257
+ # clean temp (best-effort)
258
+ try:
259
+ shutil.rmtree(tmp_extract, ignore_errors=True)
260
+ shutil.rmtree(tmp_stage, ignore_errors=True)
261
+ except Exception:
262
+ pass
263
+
264
+ try:
265
+ log("Extracting models zip…")
266
+ tmp_extract.mkdir(parents=True, exist_ok=True)
267
+ with zipfile.ZipFile(str(zip_path), "r") as z:
268
+ z.extractall(tmp_extract)
269
+
270
+ # Some zips contain a top-level folder; normalize:
271
+ root = tmp_extract
272
+ kids = list(root.iterdir())
273
+ if len(kids) == 1 and kids[0].is_dir():
274
+ root = kids[0]
275
+
276
+ # sanity: must contain at least one model file
277
+ any_model = any(p.suffix.lower() in (".pth", ".onnx") for p in root.rglob("*"))
278
+ if not any_model:
279
+ raise RuntimeError("Models zip did not contain any .pth/.onnx files.")
280
+
281
+ log(f"Installing to: {dst}")
282
+
283
+ # Stage copy
284
+ shutil.copytree(root, tmp_stage)
285
+
286
+ # Clear destination contents (keep dst folder stable)
287
+ dst.mkdir(parents=True, exist_ok=True)
288
+ for item in dst.iterdir():
289
+ try:
290
+ if item.is_dir():
291
+ shutil.rmtree(item, ignore_errors=True)
292
+ else:
293
+ item.unlink(missing_ok=True)
294
+ except Exception:
295
+ pass
296
+
297
+ # Copy staged contents into dst
298
+ for item in tmp_stage.iterdir():
299
+ target = dst / item.name
300
+ if item.is_dir():
301
+ # dirs_exist_ok requires Python 3.8+, you're on 3.12 so OK
302
+ shutil.copytree(item, target, dirs_exist_ok=True)
303
+ else:
304
+ shutil.copy2(item, target)
305
+
306
+ if manifest:
307
+ log("Writing manifest…")
308
+ write_installed_manifest(manifest)
309
+
310
+ log("Models installed.")
311
+ finally:
312
+ shutil.rmtree(tmp_extract, ignore_errors=True)
313
+ shutil.rmtree(tmp_stage, ignore_errors=True)
314
+
315
+
316
+ def sha256_file(path: str | os.PathLike, *, chunk_size: int = 1024 * 1024) -> str:
317
+ h = hashlib.sha256()
318
+ with open(path, "rb") as f:
319
+ while True:
320
+ b = f.read(chunk_size)
321
+ if not b:
322
+ break
323
+ h.update(b)
324
+ return h.hexdigest()
@@ -0,0 +1,102 @@
1
+ # src/setiastro/saspro/model_workers.py
2
+ from __future__ import annotations
3
+ from PyQt6.QtCore import QObject, pyqtSignal
4
+
5
+ import os
6
+ import tempfile
7
+ import zipfile
8
+
9
+ from setiastro.saspro.model_manager import (
10
+ extract_drive_file_id,
11
+ download_google_drive_file,
12
+ install_models_zip,
13
+ sha256_file,
14
+ )
15
+
16
+ class ModelsInstallZipWorker(QObject):
17
+ progress = pyqtSignal(str)
18
+ finished = pyqtSignal(bool, str)
19
+
20
+ def __init__(self, zip_path: str, should_cancel=None):
21
+ super().__init__()
22
+ self.zip_path = zip_path
23
+ self.should_cancel = should_cancel # optional callable
24
+
25
+ def run(self):
26
+ try:
27
+ from setiastro.saspro.model_manager import install_models_zip, sha256_file
28
+
29
+ if not self.zip_path or not os.path.exists(self.zip_path):
30
+ raise RuntimeError("ZIP file not found.")
31
+
32
+ self.progress.emit("Verifying ZIP…")
33
+ # quick hash (optional but helpful for support logs)
34
+ zhash = sha256_file(self.zip_path)
35
+
36
+ manifest = {
37
+ "source": "manual_zip",
38
+ "file": os.path.basename(self.zip_path),
39
+ "sha256": zhash,
40
+ }
41
+
42
+ install_models_zip(
43
+ self.zip_path,
44
+ progress_cb=lambda s: self.progress.emit(s),
45
+ manifest=manifest,
46
+ )
47
+
48
+ self.finished.emit(True, "Models installed successfully from ZIP.")
49
+ except Exception as e:
50
+ self.finished.emit(False, str(e))
51
+
52
+
53
+ class ModelsDownloadWorker(QObject):
54
+ progress = pyqtSignal(str)
55
+ finished = pyqtSignal(bool, str)
56
+
57
+ def __init__(self, primary: str, backup: str, expected_sha256: str | None = None, should_cancel=None):
58
+ super().__init__()
59
+ self.primary = primary
60
+ self.backup = backup
61
+ self.expected_sha256 = (expected_sha256 or "").strip() or None
62
+ self.should_cancel = should_cancel # callable -> bool
63
+
64
+ def run(self):
65
+ try:
66
+ # The inputs should be FILE links (or IDs), not folder links.
67
+ fid = extract_drive_file_id(self.primary) or extract_drive_file_id(self.backup)
68
+ if not fid:
69
+ raise RuntimeError(
70
+ "Models URL is not a Google Drive *file* link or id.\n"
71
+ "Please provide a shared file link (…/file/d/<ID>/view) to the models zip."
72
+ )
73
+
74
+ tmp = os.path.join(tempfile.gettempdir(), "saspro_models_latest.zip")
75
+ try:
76
+ self.progress.emit("Downloading from primary…")
77
+ download_google_drive_file(fid, tmp, progress_cb=lambda s: self.progress.emit(s), should_cancel=self.should_cancel)
78
+ except Exception as e:
79
+ # Try backup if primary fails AND backup has a different file id
80
+ fid2 = extract_drive_file_id(self.backup)
81
+ if fid2 and fid2 != fid:
82
+ self.progress.emit("Primary failed. Trying backup…")
83
+ download_google_drive_file(fid2, tmp, progress_cb=lambda s: self.progress.emit(s), should_cancel=self.should_cancel)
84
+ else:
85
+ raise
86
+
87
+ if self.expected_sha256:
88
+ self.progress.emit("Verifying checksum…")
89
+ got = sha256_file(tmp)
90
+ if got.lower() != self.expected_sha256.lower():
91
+ raise RuntimeError(f"SHA256 mismatch.\nExpected: {self.expected_sha256}\nGot: {got}")
92
+
93
+ manifest = {
94
+ "source": "google_drive",
95
+ "file_id": fid,
96
+ "sha256": self.expected_sha256,
97
+ }
98
+ install_models_zip(tmp, progress_cb=lambda s: self.progress.emit(s), manifest=manifest)
99
+
100
+ self.finished.emit(True, "Models updated successfully.")
101
+ except Exception as e:
102
+ self.finished.emit(False, str(e))