setiastrosuitepro 1.6.4__py3-none-any.whl → 1.7.1.post2__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 (132) hide show
  1. setiastro/images/TextureClarity.svg +56 -0
  2. setiastro/images/abeicon.svg +16 -0
  3. setiastro/images/acv_icon.png +0 -0
  4. setiastro/images/colorwheel.svg +97 -0
  5. setiastro/images/cosmic.svg +40 -0
  6. setiastro/images/cosmicsat.svg +24 -0
  7. setiastro/images/first_quarter.png +0 -0
  8. setiastro/images/full_moon.png +0 -0
  9. setiastro/images/graxpert.svg +19 -0
  10. setiastro/images/last_quarter.png +0 -0
  11. setiastro/images/linearfit.svg +32 -0
  12. setiastro/images/narrowbandnormalization.png +0 -0
  13. setiastro/images/new_moon.png +0 -0
  14. setiastro/images/pixelmath.svg +42 -0
  15. setiastro/images/planetarystacker.png +0 -0
  16. setiastro/images/waning_crescent_1.png +0 -0
  17. setiastro/images/waning_crescent_2.png +0 -0
  18. setiastro/images/waning_crescent_3.png +0 -0
  19. setiastro/images/waning_crescent_4.png +0 -0
  20. setiastro/images/waning_crescent_5.png +0 -0
  21. setiastro/images/waning_gibbous_1.png +0 -0
  22. setiastro/images/waning_gibbous_2.png +0 -0
  23. setiastro/images/waning_gibbous_3.png +0 -0
  24. setiastro/images/waning_gibbous_4.png +0 -0
  25. setiastro/images/waning_gibbous_5.png +0 -0
  26. setiastro/images/waxing_crescent_1.png +0 -0
  27. setiastro/images/waxing_crescent_2.png +0 -0
  28. setiastro/images/waxing_crescent_3.png +0 -0
  29. setiastro/images/waxing_crescent_4.png +0 -0
  30. setiastro/images/waxing_crescent_5.png +0 -0
  31. setiastro/images/waxing_gibbous_1.png +0 -0
  32. setiastro/images/waxing_gibbous_2.png +0 -0
  33. setiastro/images/waxing_gibbous_3.png +0 -0
  34. setiastro/images/waxing_gibbous_4.png +0 -0
  35. setiastro/images/waxing_gibbous_5.png +0 -0
  36. setiastro/qml/ResourceMonitor.qml +84 -82
  37. setiastro/saspro/__main__.py +20 -1
  38. setiastro/saspro/_generated/build_info.py +2 -2
  39. setiastro/saspro/abe.py +37 -4
  40. setiastro/saspro/aberration_ai.py +364 -33
  41. setiastro/saspro/aberration_ai_preset.py +29 -3
  42. setiastro/saspro/acv_exporter.py +379 -0
  43. setiastro/saspro/add_stars.py +33 -6
  44. setiastro/saspro/astrospike_python.py +45 -3
  45. setiastro/saspro/backgroundneutral.py +108 -40
  46. setiastro/saspro/blemish_blaster.py +4 -1
  47. setiastro/saspro/blink_comparator_pro.py +150 -55
  48. setiastro/saspro/clahe.py +4 -1
  49. setiastro/saspro/continuum_subtract.py +4 -1
  50. setiastro/saspro/convo.py +13 -7
  51. setiastro/saspro/cosmicclarity.py +129 -18
  52. setiastro/saspro/crop_dialog_pro.py +123 -7
  53. setiastro/saspro/curve_editor_pro.py +181 -64
  54. setiastro/saspro/curves_preset.py +249 -47
  55. setiastro/saspro/doc_manager.py +245 -15
  56. setiastro/saspro/exoplanet_detector.py +120 -28
  57. setiastro/saspro/frequency_separation.py +1158 -204
  58. setiastro/saspro/ghs_dialog_pro.py +81 -16
  59. setiastro/saspro/graxpert.py +1 -0
  60. setiastro/saspro/gui/main_window.py +706 -264
  61. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  62. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  63. setiastro/saspro/gui/mixins/menu_mixin.py +35 -1
  64. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  65. setiastro/saspro/gui/mixins/toolbar_mixin.py +499 -24
  66. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  67. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  68. setiastro/saspro/halobgon.py +4 -0
  69. setiastro/saspro/histogram.py +184 -8
  70. setiastro/saspro/image_combine.py +4 -0
  71. setiastro/saspro/image_peeker_pro.py +4 -0
  72. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  73. setiastro/saspro/imageops/serloader.py +1345 -0
  74. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  75. setiastro/saspro/imageops/stretch.py +582 -62
  76. setiastro/saspro/isophote.py +4 -0
  77. setiastro/saspro/layers.py +13 -9
  78. setiastro/saspro/layers_dock.py +183 -3
  79. setiastro/saspro/legacy/image_manager.py +154 -20
  80. setiastro/saspro/legacy/numba_utils.py +68 -48
  81. setiastro/saspro/legacy/xisf.py +240 -98
  82. setiastro/saspro/live_stacking.py +203 -82
  83. setiastro/saspro/luminancerecombine.py +228 -27
  84. setiastro/saspro/mask_creation.py +174 -15
  85. setiastro/saspro/mfdeconv.py +113 -35
  86. setiastro/saspro/mfdeconvcudnn.py +119 -70
  87. setiastro/saspro/mfdeconvsport.py +112 -35
  88. setiastro/saspro/morphology.py +4 -0
  89. setiastro/saspro/multiscale_decomp.py +81 -29
  90. setiastro/saspro/narrowband_normalization.py +1618 -0
  91. setiastro/saspro/numba_utils.py +72 -57
  92. setiastro/saspro/ops/commands.py +18 -18
  93. setiastro/saspro/ops/script_editor.py +10 -2
  94. setiastro/saspro/ops/scripts.py +122 -0
  95. setiastro/saspro/perfect_palette_picker.py +37 -3
  96. setiastro/saspro/plate_solver.py +84 -49
  97. setiastro/saspro/psf_viewer.py +119 -37
  98. setiastro/saspro/remove_green.py +1 -1
  99. setiastro/saspro/resources.py +73 -0
  100. setiastro/saspro/rgbalign.py +460 -12
  101. setiastro/saspro/selective_color.py +4 -1
  102. setiastro/saspro/ser_stack_config.py +82 -0
  103. setiastro/saspro/ser_stacker.py +2321 -0
  104. setiastro/saspro/ser_stacker_dialog.py +1838 -0
  105. setiastro/saspro/ser_tracking.py +206 -0
  106. setiastro/saspro/serviewer.py +1625 -0
  107. setiastro/saspro/sfcc.py +662 -216
  108. setiastro/saspro/shortcuts.py +171 -33
  109. setiastro/saspro/signature_insert.py +692 -33
  110. setiastro/saspro/stacking_suite.py +1347 -485
  111. setiastro/saspro/star_alignment.py +247 -123
  112. setiastro/saspro/star_spikes.py +4 -0
  113. setiastro/saspro/star_stretch.py +38 -3
  114. setiastro/saspro/stat_stretch.py +892 -129
  115. setiastro/saspro/subwindow.py +787 -363
  116. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  117. setiastro/saspro/texture_clarity.py +593 -0
  118. setiastro/saspro/wavescale_hdr.py +4 -1
  119. setiastro/saspro/wavescalede.py +4 -1
  120. setiastro/saspro/whitebalance.py +84 -12
  121. setiastro/saspro/widgets/common_utilities.py +28 -21
  122. setiastro/saspro/widgets/resource_monitor.py +209 -111
  123. setiastro/saspro/widgets/spinboxes.py +10 -13
  124. setiastro/saspro/wimi.py +27 -656
  125. setiastro/saspro/wims.py +13 -3
  126. setiastro/saspro/xisf.py +101 -11
  127. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +4 -2
  128. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +132 -87
  129. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
  130. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
  131. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
  132. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.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,92 @@ 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()
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
+
1298
1347
 
1299
1348
  def process_frame(self, path):
1349
+ if self._should_stop():
1350
+ return
1300
1351
  if not self._file_ready(path):
1301
1352
  # do not mark as processed here; monitor will retry after cool-down
1302
1353
  return
@@ -1376,6 +1427,9 @@ class LiveStackWindow(QDialog):
1376
1427
  QApplication.processEvents()
1377
1428
  return
1378
1429
 
1430
+ if self._should_stop():
1431
+ return
1432
+
1379
1433
  # ——— 2) CALIBRATION (once) ————————————————————————
1380
1434
  # ——— 2a) DETECT MONO→COLOR MODE ————————————————————
1381
1435
  mono_key = None
@@ -1384,12 +1438,19 @@ class LiveStackWindow(QDialog):
1384
1438
 
1385
1439
  # ——— 2b) CALIBRATION (once) ————————————————————————
1386
1440
  if self.master_dark is not None:
1387
- 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
1388
1443
  # prefer per-filter flat if we’re in mono→color and have one
1389
1444
  if mono_key and mono_key in self.master_flats:
1390
- 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)
1391
1447
  elif self.master_flat is not None:
1392
- 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
1393
1454
 
1394
1455
  # ——— 3) DEBAYER if BAYERPAT ——————————————————————
1395
1456
  if is_mono and header.get('BAYERPAT'):
@@ -1397,6 +1458,9 @@ class LiveStackWindow(QDialog):
1397
1458
  img = debayer_fits_fast(img, pat)
1398
1459
  is_mono = False
1399
1460
 
1461
+ if self._should_stop():
1462
+ return
1463
+
1400
1464
  # ——— 5) PROMOTION TO 3-CHANNEL if NOT in mono-mode —————
1401
1465
  if mono_key is None and img.ndim == 2:
1402
1466
  img = np.stack([img, img, img], axis=2)
@@ -1418,6 +1482,9 @@ class LiveStackWindow(QDialog):
1418
1482
  plane if plane.ndim == 2 else plane[:, :, None], delta
1419
1483
  ).squeeze()
1420
1484
 
1485
+ if self._should_stop():
1486
+ return
1487
+
1421
1488
  # ——— 8) NORMALIZE —————————————————————————————
1422
1489
  if mono_key:
1423
1490
  norm_plane = stretch_mono_image(plane, target_median=0.3)
@@ -1426,6 +1493,9 @@ class LiveStackWindow(QDialog):
1426
1493
  norm_color = stretch_color_image(img, target_median=0.3, linked=False)
1427
1494
  norm_plane = np.mean(norm_color, axis=2)
1428
1495
 
1496
+ if self._should_stop():
1497
+ return
1498
+
1429
1499
  # ——— 9) METRICS & SNR —————————————————————————
1430
1500
  sc, fwhm, ecc = compute_frame_star_metrics(norm_plane)
1431
1501
  # instead, use the cumulative stack (or composite) for SNR:
@@ -1445,6 +1515,9 @@ class LiveStackWindow(QDialog):
1445
1515
  stack_img = norm_color
1446
1516
  snr_val = estimate_global_snr(stack_img)
1447
1517
 
1518
+ if self._should_stop():
1519
+ return
1520
+
1448
1521
  # ——— 10) CULLING? ————————————————————————————
1449
1522
  flagged = (
1450
1523
  (fwhm > self.max_fwhm) or
@@ -1473,6 +1546,9 @@ class LiveStackWindow(QDialog):
1473
1546
  self.status_label.setText("Started linear stack")
1474
1547
  QApplication.processEvents()
1475
1548
 
1549
+ if self._should_stop():
1550
+ return
1551
+
1476
1552
  if mono_key:
1477
1553
  # start the filter stack
1478
1554
  self.filter_stacks[mono_key] = norm_plane.copy()
@@ -1512,6 +1588,9 @@ class LiveStackWindow(QDialog):
1512
1588
  )
1513
1589
  self._buffer.append(norm_color.copy())
1514
1590
 
1591
+ if self._should_stop():
1592
+ return
1593
+
1515
1594
  # hit the bootstrap threshold?
1516
1595
  if n == self.bootstrap_frames:
1517
1596
  # init Welford stats
@@ -1542,6 +1621,9 @@ class LiveStackWindow(QDialog):
1542
1621
  + (1.0 / n) * clipped
1543
1622
  )
1544
1623
 
1624
+ if self._should_stop():
1625
+ return
1626
+
1545
1627
  # Welford update
1546
1628
  delta_mu = clipped - self._mu
1547
1629
  self._mu += delta_mu / n
@@ -1590,6 +1672,9 @@ class LiveStackWindow(QDialog):
1590
1672
  buf.append(norm_plane.copy())
1591
1673
  self.filter_counts[mono_key] = new_count
1592
1674
 
1675
+ if self._should_stop():
1676
+ return
1677
+
1593
1678
  if new_count == self.bootstrap_frames:
1594
1679
  # init Welford
1595
1680
  stacked = np.stack(buf, axis=0)
@@ -1623,7 +1708,8 @@ class LiveStackWindow(QDialog):
1623
1708
  (count / new_count) * self.filter_stacks[mono_key]
1624
1709
  + (1.0 / new_count) * clipped
1625
1710
  )
1626
-
1711
+ if self._should_stop():
1712
+ return
1627
1713
  # Welford update on µ and m2
1628
1714
  delta = clipped - mu
1629
1715
  new_mu = mu + delta / new_count
@@ -1658,6 +1744,9 @@ class LiveStackWindow(QDialog):
1658
1744
  pass # Ignore exposure parsing errors
1659
1745
  QApplication.processEvents()
1660
1746
 
1747
+ if self._should_stop():
1748
+ return
1749
+
1661
1750
  # ─── 13) Update UI ─────────────────────────────────────────
1662
1751
  self.frame_count_label.setText(f"Frames: {self.frame_count}")
1663
1752
  QApplication.processEvents()
@@ -1667,6 +1756,9 @@ class LiveStackWindow(QDialog):
1667
1756
  self.frame_count, fwhm, ecc, sc, snr_val, False
1668
1757
  )
1669
1758
 
1759
+ if self._should_stop():
1760
+ return
1761
+
1670
1762
  # ——— 14) PREVIEW & STATUS LABEL —————————————————————
1671
1763
  if mono_key:
1672
1764
  preview = self._build_color_composite()
@@ -1685,6 +1777,8 @@ class LiveStackWindow(QDialog):
1685
1777
  Load/calibrate a single frame (RAW or FITS/TIFF), debayer if needed,
1686
1778
  normalize, then build a max‐value “star trail” in self.current_stack.
1687
1779
  """
1780
+ if self._should_stop():
1781
+ return
1688
1782
  # ─── 1) Load (RAW vs FITS) ─────────────────────────────
1689
1783
  lower = path.lower()
1690
1784
  raw_exts = ('.cr2', '.cr3', '.nef', '.arw', '.dng', '.raf',
@@ -1737,6 +1831,9 @@ class LiveStackWindow(QDialog):
1737
1831
  QApplication.processEvents()
1738
1832
  return
1739
1833
 
1834
+ if self._should_stop():
1835
+ return
1836
+
1740
1837
  # ─── 2) Calibration ─────────────────────────────────────
1741
1838
  mono_key = None
1742
1839
  if (self.mono_color_mode
@@ -1755,6 +1852,9 @@ class LiveStackWindow(QDialog):
1755
1852
  img = apply_flat_division_numba(img,
1756
1853
  self.master_flat)
1757
1854
 
1855
+ if self._should_stop():
1856
+ return
1857
+
1758
1858
  # ─── 3) Debayer ─────────────────────────────────────────
1759
1859
  if is_mono and header.get('BAYERPAT'):
1760
1860
  pat = (header['BAYERPAT'][0]
@@ -1767,6 +1867,9 @@ class LiveStackWindow(QDialog):
1767
1867
  if not mono_key and img.ndim == 2:
1768
1868
  img = np.stack([img, img, img], axis=2)
1769
1869
 
1870
+ if self._should_stop():
1871
+ return
1872
+
1770
1873
  # ─── 5) Normalize ───────────────────────────────────────
1771
1874
  # for star-trail we want a visible, stretched version:
1772
1875
  if img.ndim == 2:
@@ -1777,6 +1880,9 @@ class LiveStackWindow(QDialog):
1777
1880
  target_median=0.3,
1778
1881
  linked=False)
1779
1882
 
1883
+ if self._should_stop():
1884
+ return
1885
+
1780
1886
  # ─── 6) Build max-value stack ───────────────────────────
1781
1887
  if self.frame_count == 0:
1782
1888
  self.current_stack = norm_color.copy()
@@ -1785,6 +1891,9 @@ class LiveStackWindow(QDialog):
1785
1891
  self.current_stack = np.maximum(self.current_stack,
1786
1892
  norm_color)
1787
1893
 
1894
+ if self._should_stop():
1895
+ return
1896
+
1788
1897
  # ─── 7) Update counters and labels ──────────────────────
1789
1898
  self.frame_count += 1
1790
1899
  self.frame_count_label.setText(f"Frames: {self.frame_count}")
@@ -1809,6 +1918,18 @@ class LiveStackWindow(QDialog):
1809
1918
  self.update_preview(self.current_stack)
1810
1919
  QApplication.processEvents()
1811
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()
1812
1933
 
1813
1934
 
1814
1935
  def update_preview(self, array: np.ndarray):