setiastrosuitepro 1.6.7__py3-none-any.whl → 1.6.10__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/images/abeicon.svg +16 -0
- setiastro/images/cosmic.svg +40 -0
- setiastro/images/cosmicsat.svg +24 -0
- setiastro/images/graxpert.svg +19 -0
- setiastro/images/linearfit.svg +32 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/add_stars.py +29 -5
- setiastro/saspro/blink_comparator_pro.py +74 -24
- setiastro/saspro/cosmicclarity.py +125 -18
- setiastro/saspro/crop_dialog_pro.py +96 -2
- setiastro/saspro/curve_editor_pro.py +60 -39
- setiastro/saspro/frequency_separation.py +1159 -208
- setiastro/saspro/gui/main_window.py +131 -31
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/update_mixin.py +121 -33
- setiastro/saspro/imageops/stretch.py +531 -62
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/numba_utils.py +43 -0
- setiastro/saspro/live_stacking.py +158 -70
- setiastro/saspro/multiscale_decomp.py +47 -12
- setiastro/saspro/numba_utils.py +72 -2
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/shortcuts.py +122 -12
- setiastro/saspro/signature_insert.py +688 -33
- setiastro/saspro/stacking_suite.py +523 -316
- setiastro/saspro/stat_stretch.py +688 -130
- setiastro/saspro/subwindow.py +302 -71
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/resource_monitor.py +7 -7
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/METADATA +1 -1
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/RECORD +37 -31
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/license.txt +0 -0
|
@@ -6,7 +6,7 @@ import tempfile
|
|
|
6
6
|
import datetime as _dt
|
|
7
7
|
import numpy as np
|
|
8
8
|
import time
|
|
9
|
-
|
|
9
|
+
import threading
|
|
10
10
|
from PyQt6.QtCore import Qt, QTimer, QSettings, pyqtSignal
|
|
11
11
|
from PyQt6.QtGui import QIcon, QImage, QPixmap, QAction, QIntValidator, QDoubleValidator
|
|
12
12
|
from PyQt6.QtWidgets import (QDialog, QWidget, QLabel, QPushButton, QVBoxLayout, QHBoxLayout, QLineEdit,
|
|
@@ -19,14 +19,10 @@ from astropy.io import fits
|
|
|
19
19
|
from astropy.stats import sigma_clipped_stats
|
|
20
20
|
|
|
21
21
|
# optional deps used in your code; guard if not installed
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
try:
|
|
27
|
-
import exifread
|
|
28
|
-
except Exception:
|
|
29
|
-
exifread = None
|
|
22
|
+
import rawpy
|
|
23
|
+
|
|
24
|
+
import exifread
|
|
25
|
+
|
|
30
26
|
|
|
31
27
|
import sep
|
|
32
28
|
import exifread
|
|
@@ -354,7 +350,7 @@ def estimate_global_snr(
|
|
|
354
350
|
if sigma_central <= 0.0:
|
|
355
351
|
return 0.0
|
|
356
352
|
|
|
357
|
-
nu = med_patch - 3.0 * sigma_central
|
|
353
|
+
nu = med_patch - 3.0 * sigma_central
|
|
358
354
|
|
|
359
355
|
# 6) Return (mean − nu) / σ
|
|
360
356
|
return (mu_patch - nu) / sigma_central
|
|
@@ -395,6 +391,8 @@ class LiveStackWindow(QDialog):
|
|
|
395
391
|
self.OPEN_RETRY_PENALTY_SECS = 10.0 # cool-down after a read/permission failure
|
|
396
392
|
self.MAX_FILE_WAIT_SECS = 600.0 # optional safety cap (unused by default logic)
|
|
397
393
|
|
|
394
|
+
self._stop_event = threading.Event()
|
|
395
|
+
|
|
398
396
|
# ── Load persisted settings ───────────────────────────────
|
|
399
397
|
s = QSettings()
|
|
400
398
|
self.bootstrap_frames = _qget(s, "LiveStack/bootstrap_frames", 24, int)
|
|
@@ -611,6 +609,17 @@ class LiveStackWindow(QDialog):
|
|
|
611
609
|
self.poll_timer.timeout.connect(self.check_for_new_frames)
|
|
612
610
|
self._on_mono_color_toggled(self.mono_color_checkbox.isChecked())
|
|
613
611
|
|
|
612
|
+
app = QApplication.instance()
|
|
613
|
+
if app is not None:
|
|
614
|
+
try:
|
|
615
|
+
app.aboutToQuit.connect(self.stop_live)
|
|
616
|
+
except Exception:
|
|
617
|
+
pass
|
|
618
|
+
|
|
619
|
+
def _should_stop(self) -> bool:
|
|
620
|
+
# stop requested or not running anymore
|
|
621
|
+
return self._stop_event.is_set() or (not self.is_running)
|
|
622
|
+
|
|
614
623
|
|
|
615
624
|
# ─────────────────────────────────────────────────────────────────────────
|
|
616
625
|
def _on_star_trail_toggled(self, checked: bool):
|
|
@@ -938,8 +947,9 @@ class LiveStackWindow(QDialog):
|
|
|
938
947
|
# direct mapping: e.g. "SHO" → R=S, G=H, B=O
|
|
939
948
|
letters = list(mode)
|
|
940
949
|
if len(letters) != 3 or any(l not in ("S","H","O") for l in letters):
|
|
941
|
-
#
|
|
942
|
-
|
|
950
|
+
# fallback to natural
|
|
951
|
+
self.narrowband_mapping = "Natural"
|
|
952
|
+
return self._build_color_composite()
|
|
943
953
|
|
|
944
954
|
R = getf(letters[0])
|
|
945
955
|
G = getf(letters[1])
|
|
@@ -1089,12 +1099,10 @@ class LiveStackWindow(QDialog):
|
|
|
1089
1099
|
if not self.watch_folder:
|
|
1090
1100
|
self.status_label.setText("❗ No folder selected")
|
|
1091
1101
|
return
|
|
1092
|
-
|
|
1102
|
+
self._stop_event.clear()
|
|
1103
|
+
self.is_running = True
|
|
1093
1104
|
self.processed_files.clear()
|
|
1094
|
-
# Process all current files once
|
|
1095
1105
|
self.check_for_new_frames()
|
|
1096
|
-
# Now start monitoring
|
|
1097
|
-
self.is_running = True
|
|
1098
1106
|
self.poll_timer.start()
|
|
1099
1107
|
self.status_label.setText(f"▶ Processing & Monitoring: {os.path.basename(self.watch_folder)}")
|
|
1100
1108
|
|
|
@@ -1103,6 +1111,7 @@ class LiveStackWindow(QDialog):
|
|
|
1103
1111
|
if not self.watch_folder:
|
|
1104
1112
|
self.status_label.setText("❗ No folder selected")
|
|
1105
1113
|
return
|
|
1114
|
+
self._stop_event.clear()
|
|
1106
1115
|
# Populate processed_files with all existing files so they won't be re-processed
|
|
1107
1116
|
exts = (
|
|
1108
1117
|
"*.fit", "*.fits", "*.tif", "*.tiff",
|
|
@@ -1123,21 +1132,23 @@ class LiveStackWindow(QDialog):
|
|
|
1123
1132
|
if not self.watch_folder:
|
|
1124
1133
|
self.status_label.setText("❗ No folder selected")
|
|
1125
1134
|
return
|
|
1135
|
+
self._stop_event.clear()
|
|
1126
1136
|
self.is_running = True
|
|
1127
1137
|
self.poll_timer.start()
|
|
1128
1138
|
self.status_label.setText(f"▶ Monitoring: {os.path.basename(self.watch_folder)}")
|
|
1129
1139
|
self.mode_label.setText("Mode: Linear Average")
|
|
1130
1140
|
|
|
1131
1141
|
def stop_live(self):
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1142
|
+
self._stop_event.set() # <-- NEW: request cancellation immediately
|
|
1143
|
+
self.is_running = False
|
|
1144
|
+
self.poll_timer.stop()
|
|
1145
|
+
self.status_label.setText("■ Stopped")
|
|
1146
|
+
QApplication.processEvents()
|
|
1147
|
+
|
|
1138
1148
|
|
|
1139
1149
|
def reset_live(self):
|
|
1140
1150
|
if self.is_running:
|
|
1151
|
+
self._stop_event.set()
|
|
1141
1152
|
self.is_running = False
|
|
1142
1153
|
self.poll_timer.stop()
|
|
1143
1154
|
self.status_label.setText("■ Stopped")
|
|
@@ -1251,65 +1262,76 @@ class LiveStackWindow(QDialog):
|
|
|
1251
1262
|
|
|
1252
1263
|
|
|
1253
1264
|
def check_for_new_frames(self):
|
|
1254
|
-
if
|
|
1265
|
+
if self._should_stop() or not self.watch_folder:
|
|
1266
|
+
return
|
|
1267
|
+
if getattr(self, "_poll_busy", False):
|
|
1255
1268
|
return
|
|
1269
|
+
self._poll_busy = True
|
|
1270
|
+
try:
|
|
1271
|
+
# Gather candidates
|
|
1272
|
+
exts = (
|
|
1273
|
+
"*.fit", "*.fits", "*.tif", "*.tiff",
|
|
1274
|
+
"*.cr2", "*.cr3", "*.nef", "*.arw",
|
|
1275
|
+
"*.dng", "*.raf", "*.orf", "*.rw2", "*.pef", "*.xisf",
|
|
1276
|
+
"*.png", "*.jpg", "*.jpeg"
|
|
1277
|
+
)
|
|
1278
|
+
all_paths = []
|
|
1279
|
+
for ext in exts:
|
|
1280
|
+
all_paths += glob.glob(os.path.join(self.watch_folder, '**', ext), recursive=True)
|
|
1256
1281
|
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
"*.dng", "*.raf", "*.orf", "*.rw2", "*.pef", "*.xisf",
|
|
1262
|
-
"*.png", "*.jpg", "*.jpeg"
|
|
1263
|
-
)
|
|
1264
|
-
all_paths = []
|
|
1265
|
-
for ext in exts:
|
|
1266
|
-
all_paths += glob.glob(os.path.join(self.watch_folder, '**', ext), recursive=True)
|
|
1282
|
+
# Only consider paths not yet processed
|
|
1283
|
+
candidates = [p for p in sorted(all_paths) if p not in self.processed_files]
|
|
1284
|
+
if not candidates:
|
|
1285
|
+
return
|
|
1267
1286
|
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
if not candidates:
|
|
1271
|
-
return
|
|
1287
|
+
self.status_label.setText(f"➜ New/updated files: {len(candidates)}")
|
|
1288
|
+
QApplication.processEvents()
|
|
1272
1289
|
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1290
|
+
processed_now = 0
|
|
1291
|
+
for path in candidates:
|
|
1292
|
+
if self._should_stop(): # <-- NEW
|
|
1293
|
+
self.status_label.setText("■ Stopped")
|
|
1294
|
+
QApplication.processEvents()
|
|
1295
|
+
break
|
|
1276
1296
|
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
# Skip if we recently penalized this path
|
|
1281
|
-
info = self._probe.get(path)
|
|
1282
|
-
if info and time.time() < info.get("penalty_until", 0.0):
|
|
1283
|
-
continue
|
|
1297
|
+
info = self._probe.get(path)
|
|
1298
|
+
if info and time.time() < info.get("penalty_until", 0.0):
|
|
1299
|
+
continue
|
|
1284
1300
|
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
continue # not yet ready; we'll see it again on the next tick
|
|
1301
|
+
if not self._file_ready(path):
|
|
1302
|
+
continue
|
|
1288
1303
|
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
self.status_label.setText(f"→ Processing: {base}")
|
|
1293
|
-
QApplication.processEvents()
|
|
1304
|
+
# IMPORTANT: still allow stop before committing the path as processed
|
|
1305
|
+
if self._should_stop(): # <-- NEW
|
|
1306
|
+
break
|
|
1294
1307
|
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
except Exception as e:
|
|
1299
|
-
# If anything unexpected happens, clear 'processed' so we can retry later
|
|
1300
|
-
# but add a penalty to avoid tight loops.
|
|
1301
|
-
self.processed_files.discard(path)
|
|
1302
|
-
info = self._probe.get(path) or self._update_probe(path)
|
|
1303
|
-
if info:
|
|
1304
|
-
info["penalty_until"] = time.time() + self.OPEN_RETRY_PENALTY_SECS
|
|
1305
|
-
self.status_label.setText(f"⚠ Error on {base}: {e}")
|
|
1308
|
+
self.processed_files.add(path)
|
|
1309
|
+
base = os.path.basename(path)
|
|
1310
|
+
self.status_label.setText(f"→ Processing: {base}")
|
|
1306
1311
|
QApplication.processEvents()
|
|
1307
1312
|
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1313
|
+
try:
|
|
1314
|
+
self.process_frame(path)
|
|
1315
|
+
processed_now += 1
|
|
1316
|
+
except Exception as e:
|
|
1317
|
+
# If anything unexpected happens, clear 'processed' so we can retry later
|
|
1318
|
+
# but add a penalty to avoid tight loops.
|
|
1319
|
+
self.processed_files.discard(path)
|
|
1320
|
+
info = self._probe.get(path) or self._update_probe(path)
|
|
1321
|
+
if info:
|
|
1322
|
+
info["penalty_until"] = time.time() + self.OPEN_RETRY_PENALTY_SECS
|
|
1323
|
+
self.status_label.setText(f"⚠ Error on {base}: {e}")
|
|
1324
|
+
QApplication.processEvents()
|
|
1311
1325
|
|
|
1326
|
+
if processed_now > 0:
|
|
1327
|
+
self.status_label.setText(f"✔ Processed {processed_now} file(s)")
|
|
1328
|
+
QApplication.processEvents()
|
|
1329
|
+
finally:
|
|
1330
|
+
self._poll_busy = False
|
|
1331
|
+
|
|
1312
1332
|
def process_frame(self, path):
|
|
1333
|
+
if self._should_stop():
|
|
1334
|
+
return
|
|
1313
1335
|
if not self._file_ready(path):
|
|
1314
1336
|
# do not mark as processed here; monitor will retry after cool-down
|
|
1315
1337
|
return
|
|
@@ -1389,6 +1411,9 @@ class LiveStackWindow(QDialog):
|
|
|
1389
1411
|
QApplication.processEvents()
|
|
1390
1412
|
return
|
|
1391
1413
|
|
|
1414
|
+
if self._should_stop():
|
|
1415
|
+
return
|
|
1416
|
+
|
|
1392
1417
|
# ——— 2) CALIBRATION (once) ————————————————————————
|
|
1393
1418
|
# ——— 2a) DETECT MONO→COLOR MODE ————————————————————
|
|
1394
1419
|
mono_key = None
|
|
@@ -1404,12 +1429,18 @@ class LiveStackWindow(QDialog):
|
|
|
1404
1429
|
elif self.master_flat is not None:
|
|
1405
1430
|
img = apply_flat_division_numba(img, self.master_flat)
|
|
1406
1431
|
|
|
1432
|
+
if self._should_stop():
|
|
1433
|
+
return
|
|
1434
|
+
|
|
1407
1435
|
# ——— 3) DEBAYER if BAYERPAT ——————————————————————
|
|
1408
1436
|
if is_mono and header.get('BAYERPAT'):
|
|
1409
1437
|
pat = header['BAYERPAT'][0] if isinstance(header['BAYERPAT'], tuple) else header['BAYERPAT']
|
|
1410
1438
|
img = debayer_fits_fast(img, pat)
|
|
1411
1439
|
is_mono = False
|
|
1412
1440
|
|
|
1441
|
+
if self._should_stop():
|
|
1442
|
+
return
|
|
1443
|
+
|
|
1413
1444
|
# ——— 5) PROMOTION TO 3-CHANNEL if NOT in mono-mode —————
|
|
1414
1445
|
if mono_key is None and img.ndim == 2:
|
|
1415
1446
|
img = np.stack([img, img, img], axis=2)
|
|
@@ -1431,6 +1462,9 @@ class LiveStackWindow(QDialog):
|
|
|
1431
1462
|
plane if plane.ndim == 2 else plane[:, :, None], delta
|
|
1432
1463
|
).squeeze()
|
|
1433
1464
|
|
|
1465
|
+
if self._should_stop():
|
|
1466
|
+
return
|
|
1467
|
+
|
|
1434
1468
|
# ——— 8) NORMALIZE —————————————————————————————
|
|
1435
1469
|
if mono_key:
|
|
1436
1470
|
norm_plane = stretch_mono_image(plane, target_median=0.3)
|
|
@@ -1439,6 +1473,9 @@ class LiveStackWindow(QDialog):
|
|
|
1439
1473
|
norm_color = stretch_color_image(img, target_median=0.3, linked=False)
|
|
1440
1474
|
norm_plane = np.mean(norm_color, axis=2)
|
|
1441
1475
|
|
|
1476
|
+
if self._should_stop():
|
|
1477
|
+
return
|
|
1478
|
+
|
|
1442
1479
|
# ——— 9) METRICS & SNR —————————————————————————
|
|
1443
1480
|
sc, fwhm, ecc = compute_frame_star_metrics(norm_plane)
|
|
1444
1481
|
# instead, use the cumulative stack (or composite) for SNR:
|
|
@@ -1458,6 +1495,9 @@ class LiveStackWindow(QDialog):
|
|
|
1458
1495
|
stack_img = norm_color
|
|
1459
1496
|
snr_val = estimate_global_snr(stack_img)
|
|
1460
1497
|
|
|
1498
|
+
if self._should_stop():
|
|
1499
|
+
return
|
|
1500
|
+
|
|
1461
1501
|
# ——— 10) CULLING? ————————————————————————————
|
|
1462
1502
|
flagged = (
|
|
1463
1503
|
(fwhm > self.max_fwhm) or
|
|
@@ -1486,6 +1526,9 @@ class LiveStackWindow(QDialog):
|
|
|
1486
1526
|
self.status_label.setText("Started linear stack")
|
|
1487
1527
|
QApplication.processEvents()
|
|
1488
1528
|
|
|
1529
|
+
if self._should_stop():
|
|
1530
|
+
return
|
|
1531
|
+
|
|
1489
1532
|
if mono_key:
|
|
1490
1533
|
# start the filter stack
|
|
1491
1534
|
self.filter_stacks[mono_key] = norm_plane.copy()
|
|
@@ -1525,6 +1568,9 @@ class LiveStackWindow(QDialog):
|
|
|
1525
1568
|
)
|
|
1526
1569
|
self._buffer.append(norm_color.copy())
|
|
1527
1570
|
|
|
1571
|
+
if self._should_stop():
|
|
1572
|
+
return
|
|
1573
|
+
|
|
1528
1574
|
# hit the bootstrap threshold?
|
|
1529
1575
|
if n == self.bootstrap_frames:
|
|
1530
1576
|
# init Welford stats
|
|
@@ -1555,6 +1601,9 @@ class LiveStackWindow(QDialog):
|
|
|
1555
1601
|
+ (1.0 / n) * clipped
|
|
1556
1602
|
)
|
|
1557
1603
|
|
|
1604
|
+
if self._should_stop():
|
|
1605
|
+
return
|
|
1606
|
+
|
|
1558
1607
|
# Welford update
|
|
1559
1608
|
delta_mu = clipped - self._mu
|
|
1560
1609
|
self._mu += delta_mu / n
|
|
@@ -1603,6 +1652,9 @@ class LiveStackWindow(QDialog):
|
|
|
1603
1652
|
buf.append(norm_plane.copy())
|
|
1604
1653
|
self.filter_counts[mono_key] = new_count
|
|
1605
1654
|
|
|
1655
|
+
if self._should_stop():
|
|
1656
|
+
return
|
|
1657
|
+
|
|
1606
1658
|
if new_count == self.bootstrap_frames:
|
|
1607
1659
|
# init Welford
|
|
1608
1660
|
stacked = np.stack(buf, axis=0)
|
|
@@ -1636,7 +1688,8 @@ class LiveStackWindow(QDialog):
|
|
|
1636
1688
|
(count / new_count) * self.filter_stacks[mono_key]
|
|
1637
1689
|
+ (1.0 / new_count) * clipped
|
|
1638
1690
|
)
|
|
1639
|
-
|
|
1691
|
+
if self._should_stop():
|
|
1692
|
+
return
|
|
1640
1693
|
# Welford update on µ and m2
|
|
1641
1694
|
delta = clipped - mu
|
|
1642
1695
|
new_mu = mu + delta / new_count
|
|
@@ -1671,6 +1724,9 @@ class LiveStackWindow(QDialog):
|
|
|
1671
1724
|
pass # Ignore exposure parsing errors
|
|
1672
1725
|
QApplication.processEvents()
|
|
1673
1726
|
|
|
1727
|
+
if self._should_stop():
|
|
1728
|
+
return
|
|
1729
|
+
|
|
1674
1730
|
# ─── 13) Update UI ─────────────────────────────────────────
|
|
1675
1731
|
self.frame_count_label.setText(f"Frames: {self.frame_count}")
|
|
1676
1732
|
QApplication.processEvents()
|
|
@@ -1680,6 +1736,9 @@ class LiveStackWindow(QDialog):
|
|
|
1680
1736
|
self.frame_count, fwhm, ecc, sc, snr_val, False
|
|
1681
1737
|
)
|
|
1682
1738
|
|
|
1739
|
+
if self._should_stop():
|
|
1740
|
+
return
|
|
1741
|
+
|
|
1683
1742
|
# ——— 14) PREVIEW & STATUS LABEL —————————————————————
|
|
1684
1743
|
if mono_key:
|
|
1685
1744
|
preview = self._build_color_composite()
|
|
@@ -1698,6 +1757,8 @@ class LiveStackWindow(QDialog):
|
|
|
1698
1757
|
Load/calibrate a single frame (RAW or FITS/TIFF), debayer if needed,
|
|
1699
1758
|
normalize, then build a max‐value “star trail” in self.current_stack.
|
|
1700
1759
|
"""
|
|
1760
|
+
if self._should_stop():
|
|
1761
|
+
return
|
|
1701
1762
|
# ─── 1) Load (RAW vs FITS) ─────────────────────────────
|
|
1702
1763
|
lower = path.lower()
|
|
1703
1764
|
raw_exts = ('.cr2', '.cr3', '.nef', '.arw', '.dng', '.raf',
|
|
@@ -1750,6 +1811,9 @@ class LiveStackWindow(QDialog):
|
|
|
1750
1811
|
QApplication.processEvents()
|
|
1751
1812
|
return
|
|
1752
1813
|
|
|
1814
|
+
if self._should_stop():
|
|
1815
|
+
return
|
|
1816
|
+
|
|
1753
1817
|
# ─── 2) Calibration ─────────────────────────────────────
|
|
1754
1818
|
mono_key = None
|
|
1755
1819
|
if (self.mono_color_mode
|
|
@@ -1768,6 +1832,9 @@ class LiveStackWindow(QDialog):
|
|
|
1768
1832
|
img = apply_flat_division_numba(img,
|
|
1769
1833
|
self.master_flat)
|
|
1770
1834
|
|
|
1835
|
+
if self._should_stop():
|
|
1836
|
+
return
|
|
1837
|
+
|
|
1771
1838
|
# ─── 3) Debayer ─────────────────────────────────────────
|
|
1772
1839
|
if is_mono and header.get('BAYERPAT'):
|
|
1773
1840
|
pat = (header['BAYERPAT'][0]
|
|
@@ -1780,6 +1847,9 @@ class LiveStackWindow(QDialog):
|
|
|
1780
1847
|
if not mono_key and img.ndim == 2:
|
|
1781
1848
|
img = np.stack([img, img, img], axis=2)
|
|
1782
1849
|
|
|
1850
|
+
if self._should_stop():
|
|
1851
|
+
return
|
|
1852
|
+
|
|
1783
1853
|
# ─── 5) Normalize ───────────────────────────────────────
|
|
1784
1854
|
# for star-trail we want a visible, stretched version:
|
|
1785
1855
|
if img.ndim == 2:
|
|
@@ -1790,6 +1860,9 @@ class LiveStackWindow(QDialog):
|
|
|
1790
1860
|
target_median=0.3,
|
|
1791
1861
|
linked=False)
|
|
1792
1862
|
|
|
1863
|
+
if self._should_stop():
|
|
1864
|
+
return
|
|
1865
|
+
|
|
1793
1866
|
# ─── 6) Build max-value stack ───────────────────────────
|
|
1794
1867
|
if self.frame_count == 0:
|
|
1795
1868
|
self.current_stack = norm_color.copy()
|
|
@@ -1798,6 +1871,9 @@ class LiveStackWindow(QDialog):
|
|
|
1798
1871
|
self.current_stack = np.maximum(self.current_stack,
|
|
1799
1872
|
norm_color)
|
|
1800
1873
|
|
|
1874
|
+
if self._should_stop():
|
|
1875
|
+
return
|
|
1876
|
+
|
|
1801
1877
|
# ─── 7) Update counters and labels ──────────────────────
|
|
1802
1878
|
self.frame_count += 1
|
|
1803
1879
|
self.frame_count_label.setText(f"Frames: {self.frame_count}")
|
|
@@ -1822,6 +1898,18 @@ class LiveStackWindow(QDialog):
|
|
|
1822
1898
|
self.update_preview(self.current_stack)
|
|
1823
1899
|
QApplication.processEvents()
|
|
1824
1900
|
|
|
1901
|
+
def closeEvent(self, event):
|
|
1902
|
+
# request stop + stop timer
|
|
1903
|
+
self.stop_live()
|
|
1904
|
+
|
|
1905
|
+
# also close the metrics window so it doesn't keep stuff alive
|
|
1906
|
+
try:
|
|
1907
|
+
if self.metrics_window is not None:
|
|
1908
|
+
self.metrics_window.close()
|
|
1909
|
+
except Exception:
|
|
1910
|
+
pass
|
|
1911
|
+
|
|
1912
|
+
event.accept()
|
|
1825
1913
|
|
|
1826
1914
|
|
|
1827
1915
|
def update_preview(self, array: np.ndarray):
|
|
@@ -579,19 +579,29 @@ class MultiscaleDecompDialog(QDialog):
|
|
|
579
579
|
|
|
580
580
|
# ---------- Preview plumbing ----------
|
|
581
581
|
def _spinner_on(self):
|
|
582
|
-
if getattr(self, "
|
|
582
|
+
if getattr(self, "_closing", False):
|
|
583
|
+
return
|
|
584
|
+
try:
|
|
585
|
+
sp = getattr(self, "busy_spinner", None)
|
|
586
|
+
if sp is None:
|
|
587
|
+
return
|
|
588
|
+
sp.setVisible(True)
|
|
589
|
+
mv = getattr(self, "_busy_movie", None)
|
|
590
|
+
if mv is not None and mv.state() != QMovie.MovieState.Running:
|
|
591
|
+
mv.start()
|
|
592
|
+
except RuntimeError:
|
|
583
593
|
return
|
|
584
|
-
self.busy_spinner.setVisible(True)
|
|
585
|
-
if getattr(self, "_busy_movie", None) is not None:
|
|
586
|
-
if self._busy_movie.state() != QMovie.MovieState.Running:
|
|
587
|
-
self._busy_movie.start()
|
|
588
594
|
|
|
589
595
|
def _spinner_off(self):
|
|
590
|
-
|
|
596
|
+
try:
|
|
597
|
+
sp = getattr(self, "busy_spinner", None)
|
|
598
|
+
mv = getattr(self, "_busy_movie", None)
|
|
599
|
+
if mv is not None:
|
|
600
|
+
mv.stop()
|
|
601
|
+
if sp is not None:
|
|
602
|
+
sp.setVisible(False)
|
|
603
|
+
except RuntimeError:
|
|
591
604
|
return
|
|
592
|
-
if getattr(self, "_busy_movie", None) is not None:
|
|
593
|
-
self._busy_movie.stop()
|
|
594
|
-
self.busy_spinner.setVisible(False)
|
|
595
605
|
|
|
596
606
|
|
|
597
607
|
def _show_busy_overlay(self):
|
|
@@ -623,11 +633,13 @@ class MultiscaleDecompDialog(QDialog):
|
|
|
623
633
|
self._schedule_preview()
|
|
624
634
|
|
|
625
635
|
def _schedule_preview(self):
|
|
626
|
-
|
|
636
|
+
if getattr(self, "_closing", False):
|
|
637
|
+
return
|
|
627
638
|
self._preview_timer.start(60)
|
|
628
639
|
|
|
629
640
|
def _schedule_roi_preview(self):
|
|
630
|
-
|
|
641
|
+
if getattr(self, "_closing", False):
|
|
642
|
+
return
|
|
631
643
|
self._preview_timer.start(60)
|
|
632
644
|
|
|
633
645
|
def _connect_viewport_signals(self):
|
|
@@ -764,8 +776,15 @@ class MultiscaleDecompDialog(QDialog):
|
|
|
764
776
|
return tuned, residual
|
|
765
777
|
|
|
766
778
|
def _rebuild_preview(self):
|
|
779
|
+
if getattr(self, "_closing", False):
|
|
780
|
+
return
|
|
767
781
|
self._spinner_on()
|
|
768
|
-
|
|
782
|
+
QTimer.singleShot(0, self._rebuild_preview_impl)
|
|
783
|
+
|
|
784
|
+
def _rebuild_preview_impl(self):
|
|
785
|
+
if getattr(self, "_closing", False):
|
|
786
|
+
return
|
|
787
|
+
|
|
769
788
|
#self._begin_busy()
|
|
770
789
|
try:
|
|
771
790
|
# ROI preview can't work until we have *some* pixmap in the scene to derive visible rects from.
|
|
@@ -1749,3 +1768,19 @@ class _MultiScaleDecompPresetDialog(QDialog):
|
|
|
1749
1768
|
"linked_rgb": bool(self.cb_linked.isChecked()),
|
|
1750
1769
|
"layers_cfg": out_layers,
|
|
1751
1770
|
}
|
|
1771
|
+
def closeEvent(self, ev):
|
|
1772
|
+
self._closing = True
|
|
1773
|
+
try:
|
|
1774
|
+
if hasattr(self, "_preview_timer"):
|
|
1775
|
+
self._preview_timer.stop()
|
|
1776
|
+
if hasattr(self, "_busy_show_timer"):
|
|
1777
|
+
self._busy_show_timer.stop()
|
|
1778
|
+
# Optional: disconnect scrollbars to stop ROI scheduling
|
|
1779
|
+
try:
|
|
1780
|
+
self.view.horizontalScrollBar().valueChanged.disconnect(self._schedule_roi_preview)
|
|
1781
|
+
self.view.verticalScrollBar().valueChanged.disconnect(self._schedule_roi_preview)
|
|
1782
|
+
except Exception:
|
|
1783
|
+
pass
|
|
1784
|
+
except Exception:
|
|
1785
|
+
pass
|
|
1786
|
+
super().closeEvent(ev)
|
setiastro/saspro/numba_utils.py
CHANGED
|
@@ -2495,7 +2495,77 @@ def drizzle_deposit_color_naive(image_data, affine_2x3, drizzle_buffer, coverage
|
|
|
2495
2495
|
|
|
2496
2496
|
return drizzle_buffer, coverage_buffer
|
|
2497
2497
|
|
|
2498
|
-
@njit(parallel=True, fastmath=True
|
|
2498
|
+
@njit(parallel=True, fastmath=True)
|
|
2499
|
+
def numba_mono_from_img(img, bp, denom, median_rescaled, target_median):
|
|
2500
|
+
H, W = img.shape
|
|
2501
|
+
out = np.empty_like(img)
|
|
2502
|
+
for y in prange(H):
|
|
2503
|
+
for x in range(W):
|
|
2504
|
+
r = (img[y, x] - bp) / denom
|
|
2505
|
+
numer = (median_rescaled - 1.0) * target_median * r
|
|
2506
|
+
denom2 = median_rescaled * (target_median + r - 1.0) - target_median * r
|
|
2507
|
+
if abs(denom2) < 1e-12:
|
|
2508
|
+
denom2 = 1e-12
|
|
2509
|
+
out[y, x] = numer / denom2
|
|
2510
|
+
return out
|
|
2511
|
+
|
|
2512
|
+
@njit(parallel=True, fastmath=True)
|
|
2513
|
+
def numba_color_linked_from_img(img, bp, denom, median_rescaled, target_median):
|
|
2514
|
+
H, W, C = img.shape
|
|
2515
|
+
out = np.empty_like(img)
|
|
2516
|
+
for y in prange(H):
|
|
2517
|
+
for x in range(W):
|
|
2518
|
+
for c in range(C):
|
|
2519
|
+
r = (img[y, x, c] - bp) / denom
|
|
2520
|
+
numer = (median_rescaled - 1.0) * target_median * r
|
|
2521
|
+
denom2 = median_rescaled * (target_median + r - 1.0) - target_median * r
|
|
2522
|
+
if abs(denom2) < 1e-12:
|
|
2523
|
+
denom2 = 1e-12
|
|
2524
|
+
out[y, x, c] = numer / denom2
|
|
2525
|
+
return out
|
|
2526
|
+
|
|
2527
|
+
@njit(parallel=True, fastmath=True)
|
|
2528
|
+
def numba_color_unlinked_from_img(img, bp3, denom3, meds_rescaled3, target_median):
|
|
2529
|
+
H, W, C = img.shape
|
|
2530
|
+
out = np.empty_like(img)
|
|
2531
|
+
for y in prange(H):
|
|
2532
|
+
for x in range(W):
|
|
2533
|
+
for c in range(C):
|
|
2534
|
+
r = (img[y, x, c] - bp3[c]) / denom3[c]
|
|
2535
|
+
med = meds_rescaled3[c]
|
|
2536
|
+
numer = (med - 1.0) * target_median * r
|
|
2537
|
+
denom2 = med * (target_median + r - 1.0) - target_median * r
|
|
2538
|
+
if abs(denom2) < 1e-12:
|
|
2539
|
+
denom2 = 1e-12
|
|
2540
|
+
out[y, x, c] = numer / denom2
|
|
2541
|
+
return out
|
|
2542
|
+
|
|
2543
|
+
@njit(parallel=True, fastmath=True)
|
|
2544
|
+
def numba_mono_final_formula(rescaled, median_rescaled, target_median):
|
|
2545
|
+
"""
|
|
2546
|
+
Applies the final formula *after* we already have the rescaled values.
|
|
2547
|
+
|
|
2548
|
+
rescaled[y,x] = (original[y,x] - black_point) / (1 - black_point)
|
|
2549
|
+
median_rescaled = median(rescaled)
|
|
2550
|
+
|
|
2551
|
+
out_val = ((median_rescaled - 1) * target_median * r) /
|
|
2552
|
+
( median_rescaled*(target_median + r -1) - target_median*r )
|
|
2553
|
+
"""
|
|
2554
|
+
H, W = rescaled.shape
|
|
2555
|
+
out = np.empty_like(rescaled)
|
|
2556
|
+
|
|
2557
|
+
for y in prange(H):
|
|
2558
|
+
for x in range(W):
|
|
2559
|
+
r = rescaled[y, x]
|
|
2560
|
+
numer = (median_rescaled - 1.0) * target_median * r
|
|
2561
|
+
denom = median_rescaled * (target_median + r - 1.0) - target_median * r
|
|
2562
|
+
if np.abs(denom) < 1e-12:
|
|
2563
|
+
denom = 1e-12
|
|
2564
|
+
out[y, x] = numer / denom
|
|
2565
|
+
|
|
2566
|
+
return out
|
|
2567
|
+
|
|
2568
|
+
@njit(parallel=True, fastmath=True)
|
|
2499
2569
|
def numba_color_final_formula_linked(rescaled, median_rescaled, target_median):
|
|
2500
2570
|
"""
|
|
2501
2571
|
Linked color transform: we use one median_rescaled for all channels.
|
|
@@ -2517,7 +2587,7 @@ def numba_color_final_formula_linked(rescaled, median_rescaled, target_median):
|
|
|
2517
2587
|
|
|
2518
2588
|
return out
|
|
2519
2589
|
|
|
2520
|
-
@njit(parallel=True, fastmath=True
|
|
2590
|
+
@njit(parallel=True, fastmath=True)
|
|
2521
2591
|
def numba_color_final_formula_unlinked(rescaled, medians_rescaled, target_median):
|
|
2522
2592
|
"""
|
|
2523
2593
|
Unlinked color transform: a separate median_rescaled per channel.
|