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.

Files changed (37) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/cosmic.svg +40 -0
  3. setiastro/images/cosmicsat.svg +24 -0
  4. setiastro/images/graxpert.svg +19 -0
  5. setiastro/images/linearfit.svg +32 -0
  6. setiastro/images/pixelmath.svg +42 -0
  7. setiastro/saspro/_generated/build_info.py +2 -2
  8. setiastro/saspro/add_stars.py +29 -5
  9. setiastro/saspro/blink_comparator_pro.py +74 -24
  10. setiastro/saspro/cosmicclarity.py +125 -18
  11. setiastro/saspro/crop_dialog_pro.py +96 -2
  12. setiastro/saspro/curve_editor_pro.py +60 -39
  13. setiastro/saspro/frequency_separation.py +1159 -208
  14. setiastro/saspro/gui/main_window.py +131 -31
  15. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  16. setiastro/saspro/gui/mixins/update_mixin.py +121 -33
  17. setiastro/saspro/imageops/stretch.py +531 -62
  18. setiastro/saspro/layers.py +13 -9
  19. setiastro/saspro/layers_dock.py +183 -3
  20. setiastro/saspro/legacy/numba_utils.py +43 -0
  21. setiastro/saspro/live_stacking.py +158 -70
  22. setiastro/saspro/multiscale_decomp.py +47 -12
  23. setiastro/saspro/numba_utils.py +72 -2
  24. setiastro/saspro/ops/commands.py +18 -18
  25. setiastro/saspro/shortcuts.py +122 -12
  26. setiastro/saspro/signature_insert.py +688 -33
  27. setiastro/saspro/stacking_suite.py +523 -316
  28. setiastro/saspro/stat_stretch.py +688 -130
  29. setiastro/saspro/subwindow.py +302 -71
  30. setiastro/saspro/widgets/common_utilities.py +28 -21
  31. setiastro/saspro/widgets/resource_monitor.py +7 -7
  32. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/METADATA +1 -1
  33. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/RECORD +37 -31
  34. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/WHEEL +0 -0
  35. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/entry_points.txt +0 -0
  36. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/LICENSE +0 -0
  37. {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
- 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,76 @@ 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()
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, "busy_spinner", None) is None:
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
- if getattr(self, "busy_spinner", None) is None:
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
- # generic “something changed” entry point
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
- # view changed (scroll/zoom/pan) — still debounced
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
- QApplication.processEvents()
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)
@@ -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, cache=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, cache=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.