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.
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/accel_installer.py +21 -8
- setiastro/saspro/accel_workers.py +11 -12
- setiastro/saspro/comet_stacking.py +113 -85
- setiastro/saspro/cosmicclarity.py +604 -826
- setiastro/saspro/cosmicclarity_engines/benchmark_engine.py +732 -0
- setiastro/saspro/cosmicclarity_engines/darkstar_engine.py +576 -0
- setiastro/saspro/cosmicclarity_engines/denoise_engine.py +567 -0
- setiastro/saspro/cosmicclarity_engines/satellite_engine.py +620 -0
- setiastro/saspro/cosmicclarity_engines/sharpen_engine.py +587 -0
- setiastro/saspro/cosmicclarity_engines/superres_engine.py +412 -0
- setiastro/saspro/gui/main_window.py +14 -0
- setiastro/saspro/gui/mixins/menu_mixin.py +2 -0
- setiastro/saspro/model_manager.py +324 -0
- setiastro/saspro/model_workers.py +102 -0
- setiastro/saspro/ops/benchmark.py +320 -0
- setiastro/saspro/ops/settings.py +407 -10
- setiastro/saspro/remove_stars.py +424 -442
- setiastro/saspro/resources.py +73 -10
- setiastro/saspro/runtime_torch.py +107 -22
- setiastro/saspro/signature_insert.py +14 -3
- {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/METADATA +2 -1
- {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/RECORD +27 -18
- {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.7.5.post1.dist-info → setiastrosuitepro-1.8.0.post3.dist-info}/licenses/LICENSE +0 -0
- {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))
|