setiastrosuitepro 1.6.7__py3-none-any.whl → 1.7.0__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 (68) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/colorwheel.svg +97 -0
  3. setiastro/images/cosmic.svg +40 -0
  4. setiastro/images/cosmicsat.svg +24 -0
  5. setiastro/images/graxpert.svg +19 -0
  6. setiastro/images/linearfit.svg +32 -0
  7. setiastro/images/narrowbandnormalization.png +0 -0
  8. setiastro/images/pixelmath.svg +42 -0
  9. setiastro/images/planetarystacker.png +0 -0
  10. setiastro/saspro/__main__.py +1 -1
  11. setiastro/saspro/_generated/build_info.py +2 -2
  12. setiastro/saspro/aberration_ai.py +49 -11
  13. setiastro/saspro/aberration_ai_preset.py +29 -3
  14. setiastro/saspro/add_stars.py +29 -5
  15. setiastro/saspro/backgroundneutral.py +73 -33
  16. setiastro/saspro/blink_comparator_pro.py +150 -55
  17. setiastro/saspro/convo.py +9 -6
  18. setiastro/saspro/cosmicclarity.py +125 -18
  19. setiastro/saspro/crop_dialog_pro.py +96 -2
  20. setiastro/saspro/curve_editor_pro.py +132 -61
  21. setiastro/saspro/curves_preset.py +249 -47
  22. setiastro/saspro/doc_manager.py +178 -11
  23. setiastro/saspro/frequency_separation.py +1159 -208
  24. setiastro/saspro/gui/main_window.py +340 -88
  25. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  26. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  27. setiastro/saspro/gui/mixins/menu_mixin.py +31 -1
  28. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  29. setiastro/saspro/gui/mixins/toolbar_mixin.py +132 -10
  30. setiastro/saspro/gui/mixins/update_mixin.py +121 -33
  31. setiastro/saspro/histogram.py +179 -7
  32. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  33. setiastro/saspro/imageops/serloader.py +769 -0
  34. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  35. setiastro/saspro/imageops/stretch.py +582 -62
  36. setiastro/saspro/layers.py +13 -9
  37. setiastro/saspro/layers_dock.py +183 -3
  38. setiastro/saspro/legacy/numba_utils.py +68 -48
  39. setiastro/saspro/live_stacking.py +181 -73
  40. setiastro/saspro/multiscale_decomp.py +77 -29
  41. setiastro/saspro/narrowband_normalization.py +1618 -0
  42. setiastro/saspro/numba_utils.py +72 -57
  43. setiastro/saspro/ops/commands.py +18 -18
  44. setiastro/saspro/ops/script_editor.py +5 -0
  45. setiastro/saspro/ops/scripts.py +119 -0
  46. setiastro/saspro/remove_green.py +1 -1
  47. setiastro/saspro/resources.py +4 -0
  48. setiastro/saspro/ser_stack_config.py +68 -0
  49. setiastro/saspro/ser_stacker.py +2245 -0
  50. setiastro/saspro/ser_stacker_dialog.py +1481 -0
  51. setiastro/saspro/ser_tracking.py +206 -0
  52. setiastro/saspro/serviewer.py +1242 -0
  53. setiastro/saspro/sfcc.py +602 -214
  54. setiastro/saspro/shortcuts.py +154 -25
  55. setiastro/saspro/signature_insert.py +688 -33
  56. setiastro/saspro/stacking_suite.py +853 -401
  57. setiastro/saspro/star_alignment.py +243 -122
  58. setiastro/saspro/stat_stretch.py +878 -131
  59. setiastro/saspro/subwindow.py +303 -74
  60. setiastro/saspro/whitebalance.py +24 -0
  61. setiastro/saspro/widgets/common_utilities.py +28 -21
  62. setiastro/saspro/widgets/resource_monitor.py +128 -80
  63. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/METADATA +2 -2
  64. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +68 -51
  65. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
  66. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
  67. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
  68. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.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
@@ -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 * med_patch
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
- # invalid code → fallback to natural
942
- return self._build_color_composite.__wrapped__(self)
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
- # Clear any old record so existing files are re-processed
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
- if self.is_running:
1133
- self.is_running = False
1134
- self.poll_timer.stop()
1135
- self.status_label.setText("■ Stopped")
1136
- else:
1137
- 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
+
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,92 @@ class LiveStackWindow(QDialog):
1251
1262
 
1252
1263
 
1253
1264
  def check_for_new_frames(self):
1254
- 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):
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
- # Gather candidates
1258
- exts = (
1259
- "*.fit", "*.fits", "*.tif", "*.tiff",
1260
- "*.cr2", "*.cr3", "*.nef", "*.arw",
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
- # Only consider paths not yet processed
1269
- candidates = [p for p in sorted(all_paths) if p not in self.processed_files]
1270
- if not candidates:
1271
- return
1287
+ self.status_label.setText(f"➜ New/updated files: {len(candidates)}")
1288
+ QApplication.processEvents()
1272
1289
 
1273
- # Show first new file name (status only)
1274
- self.status_label.setText(f"➜ New/updated files: {len(candidates)}")
1275
- 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
1276
1296
 
1277
- # Probe each candidate: only process when 'ready'
1278
- processed_now = 0
1279
- for path in candidates:
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
- # Check readiness: stable size/mtime and can open-for-read
1286
- if not self._file_ready(path):
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
- # Only *now* do we mark as processed and actually process the frame
1290
- self.processed_files.add(path)
1291
- base = os.path.basename(path)
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
- try:
1296
- self.process_frame(path)
1297
- processed_now += 1
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
- if processed_now > 0:
1309
- self.status_label.setText(f"✔ Processed {processed_now} file(s)")
1310
- 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()
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
+
1332
+ def _match_master_to_image(self, master: np.ndarray, img: np.ndarray) -> np.ndarray:
1333
+ """
1334
+ Coerce master (dark/flat) to match img dimensionality.
1335
+ - If img is RGB (H,W,3) and master is mono (H,W), expand to (H,W,1).
1336
+ - If img is mono (H,W) and master is RGB (H,W,3), collapse to mono via mean.
1337
+ """
1338
+ if master is None:
1339
+ return None
1340
+
1341
+ if img.ndim == 3 and master.ndim == 2:
1342
+ return master[..., None] # (H,W,1) broadcasts to (H,W,3)
1343
+ if img.ndim == 2 and master.ndim == 3:
1344
+ return master.mean(axis=2) # (H,W)
1345
+ return master
1346
+
1311
1347
 
1312
1348
  def process_frame(self, path):
1349
+ if self._should_stop():
1350
+ return
1313
1351
  if not self._file_ready(path):
1314
1352
  # do not mark as processed here; monitor will retry after cool-down
1315
1353
  return
@@ -1389,6 +1427,9 @@ class LiveStackWindow(QDialog):
1389
1427
  QApplication.processEvents()
1390
1428
  return
1391
1429
 
1430
+ if self._should_stop():
1431
+ return
1432
+
1392
1433
  # ——— 2) CALIBRATION (once) ————————————————————————
1393
1434
  # ——— 2a) DETECT MONO→COLOR MODE ————————————————————
1394
1435
  mono_key = None
@@ -1397,12 +1438,19 @@ class LiveStackWindow(QDialog):
1397
1438
 
1398
1439
  # ——— 2b) CALIBRATION (once) ————————————————————————
1399
1440
  if self.master_dark is not None:
1400
- img = img.astype(np.float32) - self.master_dark
1441
+ md = self._match_master_to_image(self.master_dark, img).astype(np.float32, copy=False)
1442
+ img = img.astype(np.float32, copy=False) - md
1401
1443
  # prefer per-filter flat if we’re in mono→color and have one
1402
1444
  if mono_key and mono_key in self.master_flats:
1403
- img = apply_flat_division_numba(img, self.master_flats[mono_key])
1445
+ mf = self._match_master_to_image(self.master_flats[mono_key], img).astype(np.float32, copy=False)
1446
+ img = apply_flat_division_numba(img, mf)
1404
1447
  elif self.master_flat is not None:
1405
- img = apply_flat_division_numba(img, self.master_flat)
1448
+ mf = self._match_master_to_image(self.master_flat, img).astype(np.float32, copy=False)
1449
+ img = apply_flat_division_numba(img, mf)
1450
+
1451
+
1452
+ if self._should_stop():
1453
+ return
1406
1454
 
1407
1455
  # ——— 3) DEBAYER if BAYERPAT ——————————————————————
1408
1456
  if is_mono and header.get('BAYERPAT'):
@@ -1410,6 +1458,9 @@ class LiveStackWindow(QDialog):
1410
1458
  img = debayer_fits_fast(img, pat)
1411
1459
  is_mono = False
1412
1460
 
1461
+ if self._should_stop():
1462
+ return
1463
+
1413
1464
  # ——— 5) PROMOTION TO 3-CHANNEL if NOT in mono-mode —————
1414
1465
  if mono_key is None and img.ndim == 2:
1415
1466
  img = np.stack([img, img, img], axis=2)
@@ -1431,6 +1482,9 @@ class LiveStackWindow(QDialog):
1431
1482
  plane if plane.ndim == 2 else plane[:, :, None], delta
1432
1483
  ).squeeze()
1433
1484
 
1485
+ if self._should_stop():
1486
+ return
1487
+
1434
1488
  # ——— 8) NORMALIZE —————————————————————————————
1435
1489
  if mono_key:
1436
1490
  norm_plane = stretch_mono_image(plane, target_median=0.3)
@@ -1439,6 +1493,9 @@ class LiveStackWindow(QDialog):
1439
1493
  norm_color = stretch_color_image(img, target_median=0.3, linked=False)
1440
1494
  norm_plane = np.mean(norm_color, axis=2)
1441
1495
 
1496
+ if self._should_stop():
1497
+ return
1498
+
1442
1499
  # ——— 9) METRICS & SNR —————————————————————————
1443
1500
  sc, fwhm, ecc = compute_frame_star_metrics(norm_plane)
1444
1501
  # instead, use the cumulative stack (or composite) for SNR:
@@ -1458,6 +1515,9 @@ class LiveStackWindow(QDialog):
1458
1515
  stack_img = norm_color
1459
1516
  snr_val = estimate_global_snr(stack_img)
1460
1517
 
1518
+ if self._should_stop():
1519
+ return
1520
+
1461
1521
  # ——— 10) CULLING? ————————————————————————————
1462
1522
  flagged = (
1463
1523
  (fwhm > self.max_fwhm) or
@@ -1486,6 +1546,9 @@ class LiveStackWindow(QDialog):
1486
1546
  self.status_label.setText("Started linear stack")
1487
1547
  QApplication.processEvents()
1488
1548
 
1549
+ if self._should_stop():
1550
+ return
1551
+
1489
1552
  if mono_key:
1490
1553
  # start the filter stack
1491
1554
  self.filter_stacks[mono_key] = norm_plane.copy()
@@ -1525,6 +1588,9 @@ class LiveStackWindow(QDialog):
1525
1588
  )
1526
1589
  self._buffer.append(norm_color.copy())
1527
1590
 
1591
+ if self._should_stop():
1592
+ return
1593
+
1528
1594
  # hit the bootstrap threshold?
1529
1595
  if n == self.bootstrap_frames:
1530
1596
  # init Welford stats
@@ -1555,6 +1621,9 @@ class LiveStackWindow(QDialog):
1555
1621
  + (1.0 / n) * clipped
1556
1622
  )
1557
1623
 
1624
+ if self._should_stop():
1625
+ return
1626
+
1558
1627
  # Welford update
1559
1628
  delta_mu = clipped - self._mu
1560
1629
  self._mu += delta_mu / n
@@ -1603,6 +1672,9 @@ class LiveStackWindow(QDialog):
1603
1672
  buf.append(norm_plane.copy())
1604
1673
  self.filter_counts[mono_key] = new_count
1605
1674
 
1675
+ if self._should_stop():
1676
+ return
1677
+
1606
1678
  if new_count == self.bootstrap_frames:
1607
1679
  # init Welford
1608
1680
  stacked = np.stack(buf, axis=0)
@@ -1636,7 +1708,8 @@ class LiveStackWindow(QDialog):
1636
1708
  (count / new_count) * self.filter_stacks[mono_key]
1637
1709
  + (1.0 / new_count) * clipped
1638
1710
  )
1639
-
1711
+ if self._should_stop():
1712
+ return
1640
1713
  # Welford update on µ and m2
1641
1714
  delta = clipped - mu
1642
1715
  new_mu = mu + delta / new_count
@@ -1671,6 +1744,9 @@ class LiveStackWindow(QDialog):
1671
1744
  pass # Ignore exposure parsing errors
1672
1745
  QApplication.processEvents()
1673
1746
 
1747
+ if self._should_stop():
1748
+ return
1749
+
1674
1750
  # ─── 13) Update UI ─────────────────────────────────────────
1675
1751
  self.frame_count_label.setText(f"Frames: {self.frame_count}")
1676
1752
  QApplication.processEvents()
@@ -1680,6 +1756,9 @@ class LiveStackWindow(QDialog):
1680
1756
  self.frame_count, fwhm, ecc, sc, snr_val, False
1681
1757
  )
1682
1758
 
1759
+ if self._should_stop():
1760
+ return
1761
+
1683
1762
  # ——— 14) PREVIEW & STATUS LABEL —————————————————————
1684
1763
  if mono_key:
1685
1764
  preview = self._build_color_composite()
@@ -1698,6 +1777,8 @@ class LiveStackWindow(QDialog):
1698
1777
  Load/calibrate a single frame (RAW or FITS/TIFF), debayer if needed,
1699
1778
  normalize, then build a max‐value “star trail” in self.current_stack.
1700
1779
  """
1780
+ if self._should_stop():
1781
+ return
1701
1782
  # ─── 1) Load (RAW vs FITS) ─────────────────────────────
1702
1783
  lower = path.lower()
1703
1784
  raw_exts = ('.cr2', '.cr3', '.nef', '.arw', '.dng', '.raf',
@@ -1750,6 +1831,9 @@ class LiveStackWindow(QDialog):
1750
1831
  QApplication.processEvents()
1751
1832
  return
1752
1833
 
1834
+ if self._should_stop():
1835
+ return
1836
+
1753
1837
  # ─── 2) Calibration ─────────────────────────────────────
1754
1838
  mono_key = None
1755
1839
  if (self.mono_color_mode
@@ -1768,6 +1852,9 @@ class LiveStackWindow(QDialog):
1768
1852
  img = apply_flat_division_numba(img,
1769
1853
  self.master_flat)
1770
1854
 
1855
+ if self._should_stop():
1856
+ return
1857
+
1771
1858
  # ─── 3) Debayer ─────────────────────────────────────────
1772
1859
  if is_mono and header.get('BAYERPAT'):
1773
1860
  pat = (header['BAYERPAT'][0]
@@ -1780,6 +1867,9 @@ class LiveStackWindow(QDialog):
1780
1867
  if not mono_key and img.ndim == 2:
1781
1868
  img = np.stack([img, img, img], axis=2)
1782
1869
 
1870
+ if self._should_stop():
1871
+ return
1872
+
1783
1873
  # ─── 5) Normalize ───────────────────────────────────────
1784
1874
  # for star-trail we want a visible, stretched version:
1785
1875
  if img.ndim == 2:
@@ -1790,6 +1880,9 @@ class LiveStackWindow(QDialog):
1790
1880
  target_median=0.3,
1791
1881
  linked=False)
1792
1882
 
1883
+ if self._should_stop():
1884
+ return
1885
+
1793
1886
  # ─── 6) Build max-value stack ───────────────────────────
1794
1887
  if self.frame_count == 0:
1795
1888
  self.current_stack = norm_color.copy()
@@ -1798,6 +1891,9 @@ class LiveStackWindow(QDialog):
1798
1891
  self.current_stack = np.maximum(self.current_stack,
1799
1892
  norm_color)
1800
1893
 
1894
+ if self._should_stop():
1895
+ return
1896
+
1801
1897
  # ─── 7) Update counters and labels ──────────────────────
1802
1898
  self.frame_count += 1
1803
1899
  self.frame_count_label.setText(f"Frames: {self.frame_count}")
@@ -1822,6 +1918,18 @@ class LiveStackWindow(QDialog):
1822
1918
  self.update_preview(self.current_stack)
1823
1919
  QApplication.processEvents()
1824
1920
 
1921
+ def closeEvent(self, event):
1922
+ # request stop + stop timer
1923
+ self.stop_live()
1924
+
1925
+ # also close the metrics window so it doesn't keep stuff alive
1926
+ try:
1927
+ if self.metrics_window is not None:
1928
+ self.metrics_window.close()
1929
+ except Exception:
1930
+ pass
1931
+
1932
+ event.accept()
1825
1933
 
1826
1934
 
1827
1935
  def update_preview(self, array: np.ndarray):