setiastrosuitepro 1.6.4__py3-none-any.whl → 1.6.12__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 (115) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/colorwheel.svg +97 -0
  4. setiastro/images/cosmic.svg +40 -0
  5. setiastro/images/cosmicsat.svg +24 -0
  6. setiastro/images/first_quarter.png +0 -0
  7. setiastro/images/full_moon.png +0 -0
  8. setiastro/images/graxpert.svg +19 -0
  9. setiastro/images/last_quarter.png +0 -0
  10. setiastro/images/linearfit.svg +32 -0
  11. setiastro/images/new_moon.png +0 -0
  12. setiastro/images/pixelmath.svg +42 -0
  13. setiastro/images/waning_crescent_1.png +0 -0
  14. setiastro/images/waning_crescent_2.png +0 -0
  15. setiastro/images/waning_crescent_3.png +0 -0
  16. setiastro/images/waning_crescent_4.png +0 -0
  17. setiastro/images/waning_crescent_5.png +0 -0
  18. setiastro/images/waning_gibbous_1.png +0 -0
  19. setiastro/images/waning_gibbous_2.png +0 -0
  20. setiastro/images/waning_gibbous_3.png +0 -0
  21. setiastro/images/waning_gibbous_4.png +0 -0
  22. setiastro/images/waning_gibbous_5.png +0 -0
  23. setiastro/images/waxing_crescent_1.png +0 -0
  24. setiastro/images/waxing_crescent_2.png +0 -0
  25. setiastro/images/waxing_crescent_3.png +0 -0
  26. setiastro/images/waxing_crescent_4.png +0 -0
  27. setiastro/images/waxing_crescent_5.png +0 -0
  28. setiastro/images/waxing_gibbous_1.png +0 -0
  29. setiastro/images/waxing_gibbous_2.png +0 -0
  30. setiastro/images/waxing_gibbous_3.png +0 -0
  31. setiastro/images/waxing_gibbous_4.png +0 -0
  32. setiastro/images/waxing_gibbous_5.png +0 -0
  33. setiastro/qml/ResourceMonitor.qml +84 -82
  34. setiastro/saspro/__main__.py +20 -1
  35. setiastro/saspro/_generated/build_info.py +2 -2
  36. setiastro/saspro/abe.py +37 -4
  37. setiastro/saspro/aberration_ai.py +237 -21
  38. setiastro/saspro/acv_exporter.py +379 -0
  39. setiastro/saspro/add_stars.py +33 -6
  40. setiastro/saspro/backgroundneutral.py +108 -40
  41. setiastro/saspro/blemish_blaster.py +4 -1
  42. setiastro/saspro/blink_comparator_pro.py +74 -24
  43. setiastro/saspro/clahe.py +4 -1
  44. setiastro/saspro/continuum_subtract.py +4 -1
  45. setiastro/saspro/convo.py +13 -7
  46. setiastro/saspro/cosmicclarity.py +129 -18
  47. setiastro/saspro/crop_dialog_pro.py +123 -7
  48. setiastro/saspro/curve_editor_pro.py +109 -42
  49. setiastro/saspro/doc_manager.py +245 -15
  50. setiastro/saspro/exoplanet_detector.py +120 -28
  51. setiastro/saspro/frequency_separation.py +1158 -204
  52. setiastro/saspro/ghs_dialog_pro.py +81 -16
  53. setiastro/saspro/graxpert.py +1 -0
  54. setiastro/saspro/gui/main_window.py +429 -228
  55. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  56. setiastro/saspro/gui/mixins/menu_mixin.py +27 -1
  57. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  58. setiastro/saspro/gui/mixins/toolbar_mixin.py +384 -18
  59. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  60. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  61. setiastro/saspro/halobgon.py +4 -0
  62. setiastro/saspro/histogram.py +5 -1
  63. setiastro/saspro/image_combine.py +4 -0
  64. setiastro/saspro/image_peeker_pro.py +4 -0
  65. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  66. setiastro/saspro/imageops/stretch.py +582 -62
  67. setiastro/saspro/isophote.py +4 -0
  68. setiastro/saspro/layers.py +13 -9
  69. setiastro/saspro/layers_dock.py +183 -3
  70. setiastro/saspro/legacy/image_manager.py +154 -20
  71. setiastro/saspro/legacy/numba_utils.py +67 -47
  72. setiastro/saspro/legacy/xisf.py +240 -98
  73. setiastro/saspro/live_stacking.py +180 -79
  74. setiastro/saspro/luminancerecombine.py +228 -27
  75. setiastro/saspro/mask_creation.py +174 -15
  76. setiastro/saspro/mfdeconv.py +113 -35
  77. setiastro/saspro/mfdeconvcudnn.py +119 -70
  78. setiastro/saspro/mfdeconvsport.py +112 -35
  79. setiastro/saspro/morphology.py +4 -0
  80. setiastro/saspro/multiscale_decomp.py +51 -12
  81. setiastro/saspro/numba_utils.py +72 -57
  82. setiastro/saspro/ops/commands.py +18 -18
  83. setiastro/saspro/ops/script_editor.py +10 -2
  84. setiastro/saspro/ops/scripts.py +122 -0
  85. setiastro/saspro/perfect_palette_picker.py +37 -3
  86. setiastro/saspro/plate_solver.py +84 -49
  87. setiastro/saspro/psf_viewer.py +119 -37
  88. setiastro/saspro/resources.py +67 -0
  89. setiastro/saspro/rgbalign.py +4 -0
  90. setiastro/saspro/selective_color.py +4 -1
  91. setiastro/saspro/sfcc.py +364 -152
  92. setiastro/saspro/shortcuts.py +160 -29
  93. setiastro/saspro/signature_insert.py +692 -33
  94. setiastro/saspro/stacking_suite.py +1331 -484
  95. setiastro/saspro/star_alignment.py +247 -123
  96. setiastro/saspro/star_spikes.py +4 -0
  97. setiastro/saspro/star_stretch.py +38 -3
  98. setiastro/saspro/stat_stretch.py +743 -128
  99. setiastro/saspro/subwindow.py +786 -360
  100. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  101. setiastro/saspro/wavescale_hdr.py +4 -1
  102. setiastro/saspro/wavescalede.py +4 -1
  103. setiastro/saspro/whitebalance.py +84 -12
  104. setiastro/saspro/widgets/common_utilities.py +28 -21
  105. setiastro/saspro/widgets/resource_monitor.py +109 -59
  106. setiastro/saspro/widgets/spinboxes.py +10 -13
  107. setiastro/saspro/wimi.py +27 -656
  108. setiastro/saspro/wims.py +13 -3
  109. setiastro/saspro/xisf.py +101 -11
  110. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +2 -1
  111. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +115 -82
  112. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
  113. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
  114. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
  115. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.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
- try:
23
- import rawpy
24
- except Exception:
25
- rawpy = None
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
@@ -147,15 +143,27 @@ class LiveStackSettingsDialog(QDialog):
147
143
  (bootstrap_frames, clip_threshold,
148
144
  max_fwhm, max_ecc, min_star_count, delay)
149
145
  """
150
- bs = self.bs_spin.value
146
+ bs = int(self.bs_spin.value())
151
147
  sigma = self.sigma_spin.value()
152
148
  fwhm = self.fwhm_spin.value()
153
149
  ecc = self.ecc_spin.value()
154
- stars = self.star_spin.value
150
+ stars = int(self.star_spin.value())
155
151
  mapping = self.mapping_combo.currentText()
156
152
  delay = self.delay_spin.value()
157
153
  return bs, sigma, fwhm, ecc, stars, mapping, delay
158
154
 
155
+ def _qget(settings: QSettings, key: str, default, typ):
156
+ try:
157
+ return settings.value(key, default, type=typ)
158
+ except TypeError:
159
+ # Key contains junk (likely from earlier method-object save). Reset it.
160
+ try:
161
+ settings.remove(key)
162
+ except Exception:
163
+ pass
164
+ settings.setValue(key, default)
165
+ return default
166
+
159
167
 
160
168
 
161
169
  class LiveMetricsPanel(QWidget):
@@ -342,7 +350,7 @@ def estimate_global_snr(
342
350
  if sigma_central <= 0.0:
343
351
  return 0.0
344
352
 
345
- nu = med_patch - 3.0 * sigma_central * med_patch
353
+ nu = med_patch - 3.0 * sigma_central
346
354
 
347
355
  # 6) Return (mean − nu) / σ
348
356
  return (mu_patch - nu) / sigma_central
@@ -383,15 +391,18 @@ class LiveStackWindow(QDialog):
383
391
  self.OPEN_RETRY_PENALTY_SECS = 10.0 # cool-down after a read/permission failure
384
392
  self.MAX_FILE_WAIT_SECS = 600.0 # optional safety cap (unused by default logic)
385
393
 
394
+ self._stop_event = threading.Event()
395
+
386
396
  # ── Load persisted settings ───────────────────────────────
387
397
  s = QSettings()
388
- self.bootstrap_frames = s.value("LiveStack/bootstrap_frames", 24, type=int)
389
- self.clip_threshold = s.value("LiveStack/clip_threshold", 3.5, type=float)
390
- self.max_fwhm = s.value("LiveStack/max_fwhm", 15.0, type=float)
391
- self.max_ecc = s.value("LiveStack/max_ecc", 0.9, type=float)
392
- self.min_star_count = s.value("LiveStack/min_star_count", 5, type=int)
393
- self.narrowband_mapping = s.value("LiveStack/narrowband_mapping", "Natural", type=str)
394
- self.star_trail_mode = s.value("LiveStack/star_trail_mode", False, type=bool)
398
+ self.bootstrap_frames = _qget(s, "LiveStack/bootstrap_frames", 24, int)
399
+ self.clip_threshold = _qget(s, "LiveStack/clip_threshold", 3.5, float)
400
+ self.max_fwhm = _qget(s, "LiveStack/max_fwhm", 15.0, float)
401
+ self.max_ecc = _qget(s, "LiveStack/max_ecc", 0.9, float)
402
+ self.min_star_count = _qget(s, "LiveStack/min_star_count", 5, int)
403
+ self.narrowband_mapping = _qget(s, "LiveStack/narrowband_mapping", "Natural", str)
404
+ self.star_trail_mode = _qget(s, "LiveStack/star_trail_mode", False, bool)
405
+ self.FILE_STABLE_SECS = _qget(s, "LiveStack/file_stable_secs", 3.0, float)
395
406
 
396
407
 
397
408
  self.total_exposure = 0.0 # seconds
@@ -598,6 +609,17 @@ class LiveStackWindow(QDialog):
598
609
  self.poll_timer.timeout.connect(self.check_for_new_frames)
599
610
  self._on_mono_color_toggled(self.mono_color_checkbox.isChecked())
600
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
+
601
623
 
602
624
  # ─────────────────────────────────────────────────────────────────────────
603
625
  def _on_star_trail_toggled(self, checked: bool):
@@ -925,8 +947,9 @@ class LiveStackWindow(QDialog):
925
947
  # direct mapping: e.g. "SHO" → R=S, G=H, B=O
926
948
  letters = list(mode)
927
949
  if len(letters) != 3 or any(l not in ("S","H","O") for l in letters):
928
- # invalid code → fallback to natural
929
- return self._build_color_composite.__wrapped__(self)
950
+ # fallback to natural
951
+ self.narrowband_mapping = "Natural"
952
+ return self._build_color_composite()
930
953
 
931
954
  R = getf(letters[0])
932
955
  G = getf(letters[1])
@@ -1076,12 +1099,10 @@ class LiveStackWindow(QDialog):
1076
1099
  if not self.watch_folder:
1077
1100
  self.status_label.setText("❗ No folder selected")
1078
1101
  return
1079
- # Clear any old record so existing files are re-processed
1102
+ self._stop_event.clear()
1103
+ self.is_running = True
1080
1104
  self.processed_files.clear()
1081
- # Process all current files once
1082
1105
  self.check_for_new_frames()
1083
- # Now start monitoring
1084
- self.is_running = True
1085
1106
  self.poll_timer.start()
1086
1107
  self.status_label.setText(f"▶ Processing & Monitoring: {os.path.basename(self.watch_folder)}")
1087
1108
 
@@ -1090,6 +1111,7 @@ class LiveStackWindow(QDialog):
1090
1111
  if not self.watch_folder:
1091
1112
  self.status_label.setText("❗ No folder selected")
1092
1113
  return
1114
+ self._stop_event.clear()
1093
1115
  # Populate processed_files with all existing files so they won't be re-processed
1094
1116
  exts = (
1095
1117
  "*.fit", "*.fits", "*.tif", "*.tiff",
@@ -1110,21 +1132,23 @@ class LiveStackWindow(QDialog):
1110
1132
  if not self.watch_folder:
1111
1133
  self.status_label.setText("❗ No folder selected")
1112
1134
  return
1135
+ self._stop_event.clear()
1113
1136
  self.is_running = True
1114
1137
  self.poll_timer.start()
1115
1138
  self.status_label.setText(f"▶ Monitoring: {os.path.basename(self.watch_folder)}")
1116
1139
  self.mode_label.setText("Mode: Linear Average")
1117
1140
 
1118
1141
  def stop_live(self):
1119
- if self.is_running:
1120
- self.is_running = False
1121
- self.poll_timer.stop()
1122
- self.status_label.setText("■ Stopped")
1123
- else:
1124
- self.status_label.setText("■ Already stopped")
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
+
1125
1148
 
1126
1149
  def reset_live(self):
1127
1150
  if self.is_running:
1151
+ self._stop_event.set()
1128
1152
  self.is_running = False
1129
1153
  self.poll_timer.stop()
1130
1154
  self.status_label.setText("■ Stopped")
@@ -1238,65 +1262,76 @@ class LiveStackWindow(QDialog):
1238
1262
 
1239
1263
 
1240
1264
  def check_for_new_frames(self):
1241
- if not self.is_running or not self.watch_folder:
1265
+ if self._should_stop() or not self.watch_folder:
1266
+ return
1267
+ if getattr(self, "_poll_busy", False):
1242
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)
1243
1281
 
1244
- # Gather candidates
1245
- exts = (
1246
- "*.fit", "*.fits", "*.tif", "*.tiff",
1247
- "*.cr2", "*.cr3", "*.nef", "*.arw",
1248
- "*.dng", "*.raf", "*.orf", "*.rw2", "*.pef", "*.xisf",
1249
- "*.png", "*.jpg", "*.jpeg"
1250
- )
1251
- all_paths = []
1252
- for ext in exts:
1253
- 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
1254
1286
 
1255
- # Only consider paths not yet processed
1256
- candidates = [p for p in sorted(all_paths) if p not in self.processed_files]
1257
- if not candidates:
1258
- return
1287
+ self.status_label.setText(f"➜ New/updated files: {len(candidates)}")
1288
+ QApplication.processEvents()
1259
1289
 
1260
- # Show first new file name (status only)
1261
- self.status_label.setText(f"➜ New/updated files: {len(candidates)}")
1262
- QApplication.processEvents()
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
1263
1296
 
1264
- # Probe each candidate: only process when 'ready'
1265
- processed_now = 0
1266
- for path in candidates:
1267
- # Skip if we recently penalized this path
1268
- info = self._probe.get(path)
1269
- if info and time.time() < info.get("penalty_until", 0.0):
1270
- continue
1297
+ info = self._probe.get(path)
1298
+ if info and time.time() < info.get("penalty_until", 0.0):
1299
+ continue
1271
1300
 
1272
- # Check readiness: stable size/mtime and can open-for-read
1273
- if not self._file_ready(path):
1274
- continue # not yet ready; we'll see it again on the next tick
1301
+ if not self._file_ready(path):
1302
+ continue
1275
1303
 
1276
- # Only *now* do we mark as processed and actually process the frame
1277
- self.processed_files.add(path)
1278
- base = os.path.basename(path)
1279
- self.status_label.setText(f"→ Processing: {base}")
1280
- QApplication.processEvents()
1304
+ # IMPORTANT: still allow stop before committing the path as processed
1305
+ if self._should_stop(): # <-- NEW
1306
+ break
1281
1307
 
1282
- try:
1283
- self.process_frame(path)
1284
- processed_now += 1
1285
- except Exception as e:
1286
- # If anything unexpected happens, clear 'processed' so we can retry later
1287
- # but add a penalty to avoid tight loops.
1288
- self.processed_files.discard(path)
1289
- info = self._probe.get(path) or self._update_probe(path)
1290
- if info:
1291
- info["penalty_until"] = time.time() + self.OPEN_RETRY_PENALTY_SECS
1292
- 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}")
1293
1311
  QApplication.processEvents()
1294
1312
 
1295
- if processed_now > 0:
1296
- self.status_label.setText(f"✔ Processed {processed_now} file(s)")
1297
- QApplication.processEvents()
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()
1298
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
+
1299
1332
  def process_frame(self, path):
1333
+ if self._should_stop():
1334
+ return
1300
1335
  if not self._file_ready(path):
1301
1336
  # do not mark as processed here; monitor will retry after cool-down
1302
1337
  return
@@ -1376,6 +1411,9 @@ class LiveStackWindow(QDialog):
1376
1411
  QApplication.processEvents()
1377
1412
  return
1378
1413
 
1414
+ if self._should_stop():
1415
+ return
1416
+
1379
1417
  # ——— 2) CALIBRATION (once) ————————————————————————
1380
1418
  # ——— 2a) DETECT MONO→COLOR MODE ————————————————————
1381
1419
  mono_key = None
@@ -1391,12 +1429,18 @@ class LiveStackWindow(QDialog):
1391
1429
  elif self.master_flat is not None:
1392
1430
  img = apply_flat_division_numba(img, self.master_flat)
1393
1431
 
1432
+ if self._should_stop():
1433
+ return
1434
+
1394
1435
  # ——— 3) DEBAYER if BAYERPAT ——————————————————————
1395
1436
  if is_mono and header.get('BAYERPAT'):
1396
1437
  pat = header['BAYERPAT'][0] if isinstance(header['BAYERPAT'], tuple) else header['BAYERPAT']
1397
1438
  img = debayer_fits_fast(img, pat)
1398
1439
  is_mono = False
1399
1440
 
1441
+ if self._should_stop():
1442
+ return
1443
+
1400
1444
  # ——— 5) PROMOTION TO 3-CHANNEL if NOT in mono-mode —————
1401
1445
  if mono_key is None and img.ndim == 2:
1402
1446
  img = np.stack([img, img, img], axis=2)
@@ -1418,6 +1462,9 @@ class LiveStackWindow(QDialog):
1418
1462
  plane if plane.ndim == 2 else plane[:, :, None], delta
1419
1463
  ).squeeze()
1420
1464
 
1465
+ if self._should_stop():
1466
+ return
1467
+
1421
1468
  # ——— 8) NORMALIZE —————————————————————————————
1422
1469
  if mono_key:
1423
1470
  norm_plane = stretch_mono_image(plane, target_median=0.3)
@@ -1426,6 +1473,9 @@ class LiveStackWindow(QDialog):
1426
1473
  norm_color = stretch_color_image(img, target_median=0.3, linked=False)
1427
1474
  norm_plane = np.mean(norm_color, axis=2)
1428
1475
 
1476
+ if self._should_stop():
1477
+ return
1478
+
1429
1479
  # ——— 9) METRICS & SNR —————————————————————————
1430
1480
  sc, fwhm, ecc = compute_frame_star_metrics(norm_plane)
1431
1481
  # instead, use the cumulative stack (or composite) for SNR:
@@ -1445,6 +1495,9 @@ class LiveStackWindow(QDialog):
1445
1495
  stack_img = norm_color
1446
1496
  snr_val = estimate_global_snr(stack_img)
1447
1497
 
1498
+ if self._should_stop():
1499
+ return
1500
+
1448
1501
  # ——— 10) CULLING? ————————————————————————————
1449
1502
  flagged = (
1450
1503
  (fwhm > self.max_fwhm) or
@@ -1473,6 +1526,9 @@ class LiveStackWindow(QDialog):
1473
1526
  self.status_label.setText("Started linear stack")
1474
1527
  QApplication.processEvents()
1475
1528
 
1529
+ if self._should_stop():
1530
+ return
1531
+
1476
1532
  if mono_key:
1477
1533
  # start the filter stack
1478
1534
  self.filter_stacks[mono_key] = norm_plane.copy()
@@ -1512,6 +1568,9 @@ class LiveStackWindow(QDialog):
1512
1568
  )
1513
1569
  self._buffer.append(norm_color.copy())
1514
1570
 
1571
+ if self._should_stop():
1572
+ return
1573
+
1515
1574
  # hit the bootstrap threshold?
1516
1575
  if n == self.bootstrap_frames:
1517
1576
  # init Welford stats
@@ -1542,6 +1601,9 @@ class LiveStackWindow(QDialog):
1542
1601
  + (1.0 / n) * clipped
1543
1602
  )
1544
1603
 
1604
+ if self._should_stop():
1605
+ return
1606
+
1545
1607
  # Welford update
1546
1608
  delta_mu = clipped - self._mu
1547
1609
  self._mu += delta_mu / n
@@ -1590,6 +1652,9 @@ class LiveStackWindow(QDialog):
1590
1652
  buf.append(norm_plane.copy())
1591
1653
  self.filter_counts[mono_key] = new_count
1592
1654
 
1655
+ if self._should_stop():
1656
+ return
1657
+
1593
1658
  if new_count == self.bootstrap_frames:
1594
1659
  # init Welford
1595
1660
  stacked = np.stack(buf, axis=0)
@@ -1623,7 +1688,8 @@ class LiveStackWindow(QDialog):
1623
1688
  (count / new_count) * self.filter_stacks[mono_key]
1624
1689
  + (1.0 / new_count) * clipped
1625
1690
  )
1626
-
1691
+ if self._should_stop():
1692
+ return
1627
1693
  # Welford update on µ and m2
1628
1694
  delta = clipped - mu
1629
1695
  new_mu = mu + delta / new_count
@@ -1658,6 +1724,9 @@ class LiveStackWindow(QDialog):
1658
1724
  pass # Ignore exposure parsing errors
1659
1725
  QApplication.processEvents()
1660
1726
 
1727
+ if self._should_stop():
1728
+ return
1729
+
1661
1730
  # ─── 13) Update UI ─────────────────────────────────────────
1662
1731
  self.frame_count_label.setText(f"Frames: {self.frame_count}")
1663
1732
  QApplication.processEvents()
@@ -1667,6 +1736,9 @@ class LiveStackWindow(QDialog):
1667
1736
  self.frame_count, fwhm, ecc, sc, snr_val, False
1668
1737
  )
1669
1738
 
1739
+ if self._should_stop():
1740
+ return
1741
+
1670
1742
  # ——— 14) PREVIEW & STATUS LABEL —————————————————————
1671
1743
  if mono_key:
1672
1744
  preview = self._build_color_composite()
@@ -1685,6 +1757,8 @@ class LiveStackWindow(QDialog):
1685
1757
  Load/calibrate a single frame (RAW or FITS/TIFF), debayer if needed,
1686
1758
  normalize, then build a max‐value “star trail” in self.current_stack.
1687
1759
  """
1760
+ if self._should_stop():
1761
+ return
1688
1762
  # ─── 1) Load (RAW vs FITS) ─────────────────────────────
1689
1763
  lower = path.lower()
1690
1764
  raw_exts = ('.cr2', '.cr3', '.nef', '.arw', '.dng', '.raf',
@@ -1737,6 +1811,9 @@ class LiveStackWindow(QDialog):
1737
1811
  QApplication.processEvents()
1738
1812
  return
1739
1813
 
1814
+ if self._should_stop():
1815
+ return
1816
+
1740
1817
  # ─── 2) Calibration ─────────────────────────────────────
1741
1818
  mono_key = None
1742
1819
  if (self.mono_color_mode
@@ -1755,6 +1832,9 @@ class LiveStackWindow(QDialog):
1755
1832
  img = apply_flat_division_numba(img,
1756
1833
  self.master_flat)
1757
1834
 
1835
+ if self._should_stop():
1836
+ return
1837
+
1758
1838
  # ─── 3) Debayer ─────────────────────────────────────────
1759
1839
  if is_mono and header.get('BAYERPAT'):
1760
1840
  pat = (header['BAYERPAT'][0]
@@ -1767,6 +1847,9 @@ class LiveStackWindow(QDialog):
1767
1847
  if not mono_key and img.ndim == 2:
1768
1848
  img = np.stack([img, img, img], axis=2)
1769
1849
 
1850
+ if self._should_stop():
1851
+ return
1852
+
1770
1853
  # ─── 5) Normalize ───────────────────────────────────────
1771
1854
  # for star-trail we want a visible, stretched version:
1772
1855
  if img.ndim == 2:
@@ -1777,6 +1860,9 @@ class LiveStackWindow(QDialog):
1777
1860
  target_median=0.3,
1778
1861
  linked=False)
1779
1862
 
1863
+ if self._should_stop():
1864
+ return
1865
+
1780
1866
  # ─── 6) Build max-value stack ───────────────────────────
1781
1867
  if self.frame_count == 0:
1782
1868
  self.current_stack = norm_color.copy()
@@ -1785,6 +1871,9 @@ class LiveStackWindow(QDialog):
1785
1871
  self.current_stack = np.maximum(self.current_stack,
1786
1872
  norm_color)
1787
1873
 
1874
+ if self._should_stop():
1875
+ return
1876
+
1788
1877
  # ─── 7) Update counters and labels ──────────────────────
1789
1878
  self.frame_count += 1
1790
1879
  self.frame_count_label.setText(f"Frames: {self.frame_count}")
@@ -1809,6 +1898,18 @@ class LiveStackWindow(QDialog):
1809
1898
  self.update_preview(self.current_stack)
1810
1899
  QApplication.processEvents()
1811
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()
1812
1913
 
1813
1914
 
1814
1915
  def update_preview(self, array: np.ndarray):