setiastrosuitepro 1.6.4__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 (112) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/cosmic.svg +40 -0
  4. setiastro/images/cosmicsat.svg +24 -0
  5. setiastro/images/first_quarter.png +0 -0
  6. setiastro/images/full_moon.png +0 -0
  7. setiastro/images/graxpert.svg +19 -0
  8. setiastro/images/last_quarter.png +0 -0
  9. setiastro/images/linearfit.svg +32 -0
  10. setiastro/images/new_moon.png +0 -0
  11. setiastro/images/pixelmath.svg +42 -0
  12. setiastro/images/waning_crescent_1.png +0 -0
  13. setiastro/images/waning_crescent_2.png +0 -0
  14. setiastro/images/waning_crescent_3.png +0 -0
  15. setiastro/images/waning_crescent_4.png +0 -0
  16. setiastro/images/waning_crescent_5.png +0 -0
  17. setiastro/images/waning_gibbous_1.png +0 -0
  18. setiastro/images/waning_gibbous_2.png +0 -0
  19. setiastro/images/waning_gibbous_3.png +0 -0
  20. setiastro/images/waning_gibbous_4.png +0 -0
  21. setiastro/images/waning_gibbous_5.png +0 -0
  22. setiastro/images/waxing_crescent_1.png +0 -0
  23. setiastro/images/waxing_crescent_2.png +0 -0
  24. setiastro/images/waxing_crescent_3.png +0 -0
  25. setiastro/images/waxing_crescent_4.png +0 -0
  26. setiastro/images/waxing_crescent_5.png +0 -0
  27. setiastro/images/waxing_gibbous_1.png +0 -0
  28. setiastro/images/waxing_gibbous_2.png +0 -0
  29. setiastro/images/waxing_gibbous_3.png +0 -0
  30. setiastro/images/waxing_gibbous_4.png +0 -0
  31. setiastro/images/waxing_gibbous_5.png +0 -0
  32. setiastro/qml/ResourceMonitor.qml +84 -82
  33. setiastro/saspro/__main__.py +19 -0
  34. setiastro/saspro/_generated/build_info.py +2 -2
  35. setiastro/saspro/abe.py +37 -4
  36. setiastro/saspro/aberration_ai.py +237 -21
  37. setiastro/saspro/acv_exporter.py +379 -0
  38. setiastro/saspro/add_stars.py +33 -6
  39. setiastro/saspro/backgroundneutral.py +35 -7
  40. setiastro/saspro/blemish_blaster.py +4 -1
  41. setiastro/saspro/blink_comparator_pro.py +74 -24
  42. setiastro/saspro/clahe.py +4 -1
  43. setiastro/saspro/continuum_subtract.py +4 -1
  44. setiastro/saspro/convo.py +4 -1
  45. setiastro/saspro/cosmicclarity.py +129 -18
  46. setiastro/saspro/crop_dialog_pro.py +123 -7
  47. setiastro/saspro/curve_editor_pro.py +109 -42
  48. setiastro/saspro/doc_manager.py +67 -4
  49. setiastro/saspro/exoplanet_detector.py +120 -28
  50. setiastro/saspro/frequency_separation.py +1158 -204
  51. setiastro/saspro/ghs_dialog_pro.py +81 -16
  52. setiastro/saspro/graxpert.py +1 -0
  53. setiastro/saspro/gui/main_window.py +393 -204
  54. setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
  55. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  56. setiastro/saspro/gui/mixins/toolbar_mixin.py +356 -12
  57. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  58. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  59. setiastro/saspro/halobgon.py +4 -0
  60. setiastro/saspro/histogram.py +5 -1
  61. setiastro/saspro/image_combine.py +4 -0
  62. setiastro/saspro/image_peeker_pro.py +4 -0
  63. setiastro/saspro/imageops/stretch.py +531 -62
  64. setiastro/saspro/isophote.py +4 -0
  65. setiastro/saspro/layers.py +13 -9
  66. setiastro/saspro/layers_dock.py +183 -3
  67. setiastro/saspro/legacy/image_manager.py +154 -20
  68. setiastro/saspro/legacy/numba_utils.py +43 -0
  69. setiastro/saspro/legacy/xisf.py +240 -98
  70. setiastro/saspro/live_stacking.py +180 -79
  71. setiastro/saspro/luminancerecombine.py +228 -27
  72. setiastro/saspro/mask_creation.py +174 -15
  73. setiastro/saspro/mfdeconv.py +113 -35
  74. setiastro/saspro/mfdeconvcudnn.py +119 -70
  75. setiastro/saspro/mfdeconvsport.py +112 -35
  76. setiastro/saspro/morphology.py +4 -0
  77. setiastro/saspro/multiscale_decomp.py +51 -12
  78. setiastro/saspro/numba_utils.py +72 -2
  79. setiastro/saspro/ops/commands.py +18 -18
  80. setiastro/saspro/ops/script_editor.py +5 -2
  81. setiastro/saspro/ops/scripts.py +3 -0
  82. setiastro/saspro/perfect_palette_picker.py +37 -3
  83. setiastro/saspro/plate_solver.py +84 -49
  84. setiastro/saspro/psf_viewer.py +119 -37
  85. setiastro/saspro/resources.py +67 -0
  86. setiastro/saspro/rgbalign.py +4 -0
  87. setiastro/saspro/selective_color.py +4 -1
  88. setiastro/saspro/sfcc.py +60 -2
  89. setiastro/saspro/shortcuts.py +142 -23
  90. setiastro/saspro/signature_insert.py +692 -33
  91. setiastro/saspro/stacking_suite.py +1017 -400
  92. setiastro/saspro/star_alignment.py +4 -1
  93. setiastro/saspro/star_spikes.py +4 -0
  94. setiastro/saspro/star_stretch.py +38 -3
  95. setiastro/saspro/stat_stretch.py +702 -128
  96. setiastro/saspro/subwindow.py +786 -360
  97. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  98. setiastro/saspro/wavescale_hdr.py +4 -1
  99. setiastro/saspro/wavescalede.py +4 -1
  100. setiastro/saspro/whitebalance.py +60 -12
  101. setiastro/saspro/widgets/common_utilities.py +28 -21
  102. setiastro/saspro/widgets/resource_monitor.py +109 -59
  103. setiastro/saspro/widgets/spinboxes.py +10 -13
  104. setiastro/saspro/wimi.py +27 -656
  105. setiastro/saspro/wims.py +13 -3
  106. setiastro/saspro/xisf.py +101 -11
  107. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/METADATA +2 -1
  108. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/RECORD +112 -80
  109. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/WHEEL +0 -0
  110. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/entry_points.txt +0 -0
  111. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/LICENSE +0 -0
  112. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/license.txt +0 -0
@@ -13,7 +13,7 @@ from PyQt6.QtWidgets import (
13
13
  )
14
14
  from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
15
15
 
16
- from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal, QObject
16
+ from PyQt6.QtCore import QThread, pyqtSignal, QObject
17
17
  from PyQt6.QtWidgets import QWidget
18
18
 
19
19
  class _ProcessingOverlay(QWidget):
@@ -108,7 +108,10 @@ class PSFViewer(QDialog):
108
108
  # Accept either a view (with .document) or a doc directly
109
109
  doc = getattr(view_or_doc, "document", None)
110
110
  self.doc = doc if doc is not None else view_or_doc
111
-
111
+ try:
112
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
113
+ except Exception:
114
+ pass # older PyQt6 versions
112
115
  # Image + state
113
116
  self.image = self._grab_image()
114
117
  self.zoom_factor = 1.0
@@ -124,12 +127,19 @@ class PSFViewer(QDialog):
124
127
  self.threshold_timer.timeout.connect(self._applyThreshold)
125
128
 
126
129
  # Auto-update when the document changes
130
+
131
+ self._psf_thread = None
132
+ self._psf_worker = None
133
+ self._doc_conn = False
127
134
  if hasattr(self.doc, "changed"):
128
135
  try:
129
136
  self.doc.changed.connect(self._on_doc_changed)
137
+ self._doc_conn = True
130
138
  except Exception:
131
- pass
139
+ self._doc_conn = False
132
140
 
141
+ # cleanup no matter how the dialog is dismissed (accept/reject/done)
142
+ self.finished.connect(self._cleanup)
133
143
  self._build_ui()
134
144
  # Defer first compute until after the dialog is shown/layouted
135
145
  QTimer.singleShot(0, self._applyThreshold)
@@ -235,7 +245,7 @@ class PSFViewer(QDialog):
235
245
 
236
246
  # Close
237
247
  close_btn = QPushButton("Close", self)
238
- close_btn.clicked.connect(self.accept)
248
+ close_btn.clicked.connect(self.close)
239
249
  main_layout.addWidget(close_btn)
240
250
 
241
251
  self.setLayout(main_layout)
@@ -279,7 +289,6 @@ class PSFViewer(QDialog):
279
289
  self.hist_label.resize(scaled.size())
280
290
 
281
291
  def _applyThreshold(self):
282
- # kick off worker
283
292
  if self.image is None:
284
293
  self.star_list = None
285
294
  self.status_label.setText("Status: No image.")
@@ -288,46 +297,72 @@ class PSFViewer(QDialog):
288
297
 
289
298
  self._show_processing("Processing… extracting stars / PSFs")
290
299
 
291
- # kill previous run if any
292
- if hasattr(self, "_psf_thread") and self._psf_thread is not None:
293
- try:
294
- self._psf_thread.quit()
295
- self._psf_thread.wait(50)
296
- except Exception:
297
- pass
300
+ # stop any previous run cleanly
301
+ self._stop_psf_worker()
298
302
 
299
303
  self._psf_thread = QThread(self)
300
304
  self._psf_worker = _PSFWorker(self.image, self.detection_threshold)
301
305
  self._psf_worker.moveToThread(self._psf_thread)
302
306
 
303
307
  self._psf_thread.started.connect(self._psf_worker.run)
308
+ self._psf_worker.finished.connect(self._on_psf_done)
309
+ self._psf_worker.failed.connect(self._on_psf_fail)
304
310
 
305
- def _done(tbl, status):
306
- self.star_list = tbl
307
- self.status_label.setText(status)
308
- self._hide_processing()
309
- self.drawHistogram()
310
- self._psf_thread.quit()
311
- self._psf_thread.wait(100)
312
-
313
- def _fail(msg):
314
- self.star_list = None
315
- self.status_label.setText(f"Status: {msg}")
316
- self._hide_processing()
317
- self.drawHistogram()
318
- self._psf_thread.quit()
319
- self._psf_thread.wait(100)
311
+ # ensure thread quits once worker reports anything
312
+ self._psf_worker.finished.connect(lambda *_: self._stop_psf_worker(quit_only=False))
313
+ self._psf_worker.failed.connect(lambda *_: self._stop_psf_worker(quit_only=False))
320
314
 
321
- self._psf_worker.finished.connect(_done)
322
- self._psf_worker.failed.connect(_fail)
323
315
 
324
316
  self._psf_thread.start()
325
317
 
318
+ def _stop_psf_worker(self, quit_only: bool = False):
319
+ thr = getattr(self, "_psf_thread", None)
320
+ wkr = getattr(self, "_psf_worker", None)
321
+
322
+ if thr is None:
323
+ return
324
+
325
+ try:
326
+ thr.quit()
327
+ except Exception:
328
+ pass
329
+ try:
330
+ thr.wait(250)
331
+ except Exception:
332
+ pass
333
+
334
+ if not quit_only:
335
+ try:
336
+ if wkr is not None:
337
+ wkr.deleteLater()
338
+ except Exception:
339
+ pass
340
+ try:
341
+ thr.deleteLater()
342
+ except Exception:
343
+ pass
344
+ self._psf_worker = None
345
+ self._psf_thread = None
346
+
347
+ def _on_psf_done(self, tbl, status: str):
348
+ # tbl is an astropy Table or None
349
+ self.star_list = tbl
350
+ self.status_label.setText(status)
351
+ self._hide_processing()
352
+ self.drawHistogram()
353
+
354
+ def _on_psf_fail(self, msg: str):
355
+ self.star_list = None
356
+ self.status_label.setText(f"Status: {msg}")
357
+ self._hide_processing()
358
+ self.drawHistogram()
359
+
326
360
 
327
361
  def updateImage(self, new_image):
328
362
  self.image = np.asarray(new_image) if new_image is not None else None
329
- self.compute_star_list()
330
- self.drawHistogram()
363
+ if self.threshold_timer.isActive():
364
+ self.threshold_timer.stop()
365
+ self.threshold_timer.start()
331
366
 
332
367
  def updateZoom(self, _=None):
333
368
  self._apply_hist_zoom()
@@ -538,12 +573,59 @@ class PSFViewer(QDialog):
538
573
  it.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
539
574
  self.stats_table.setItem(ri, ci, it)
540
575
 
576
+
577
+ def _cleanup(self):
578
+ # stop debounce timer
579
+ try:
580
+ if getattr(self, "threshold_timer", None) is not None:
581
+ self.threshold_timer.stop()
582
+ except Exception:
583
+ pass
584
+
585
+ # disconnect doc listener
586
+ try:
587
+ if self._doc_conn and hasattr(self.doc, "changed"):
588
+ self.doc.changed.disconnect(self._on_doc_changed)
589
+ except Exception:
590
+ pass
591
+ self._doc_conn = False
592
+
593
+ # stop worker/thread
594
+ try:
595
+ thr = getattr(self, "_psf_thread", None)
596
+ wkr = getattr(self, "_psf_worker", None)
597
+
598
+ if wkr is not None:
599
+ try:
600
+ wkr.deleteLater()
601
+ except Exception:
602
+ pass
603
+
604
+ if thr is not None:
605
+ try:
606
+ thr.requestInterruption()
607
+ except Exception:
608
+ pass
609
+ try:
610
+ thr.quit()
611
+ except Exception:
612
+ pass
613
+ try:
614
+ thr.wait(250)
615
+ except Exception:
616
+ pass
617
+ try:
618
+ thr.deleteLater()
619
+ except Exception:
620
+ pass
621
+ except Exception:
622
+ pass
623
+
624
+ self._psf_worker = None
625
+ self._psf_thread = None
626
+
541
627
  # ---------- lifecycle ----------
542
628
  def closeEvent(self, e):
543
- # Best-effort disconnect
544
- if hasattr(self.doc, "changed"):
545
- try:
546
- self.doc.changed.disconnect(self._on_doc_changed)
547
- except Exception:
548
- pass
629
+ self._cleanup()
549
630
  super().closeEvent(e)
631
+
@@ -245,6 +245,39 @@ class Icons:
245
245
  LIVE_STACKING = property(lambda self: _resource_path('livestacking.png'))
246
246
  IMAGE_COMBINE = property(lambda self: _resource_path('imagecombine.png'))
247
247
 
248
+ # Moon phase (WIMS)
249
+ MOON_NEW = property(lambda self: _resource_path('new_moon.png'))
250
+ MOON_WAXING_CRES_1 = property(lambda self: _resource_path('waxing_crescent_1.png'))
251
+ MOON_WAXING_CRES_2 = property(lambda self: _resource_path('waxing_crescent_2.png'))
252
+ MOON_WAXING_CRES_3 = property(lambda self: _resource_path('waxing_crescent_3.png'))
253
+ MOON_WAXING_CRES_4 = property(lambda self: _resource_path('waxing_crescent_4.png'))
254
+ MOON_WAXING_CRES_5 = property(lambda self: _resource_path('waxing_crescent_5.png'))
255
+
256
+ MOON_FIRST_QUARTER = property(lambda self: _resource_path('first_quarter.png'))
257
+
258
+ MOON_WAXING_GIB_1 = property(lambda self: _resource_path('waxing_gibbous_1.png'))
259
+ MOON_WAXING_GIB_2 = property(lambda self: _resource_path('waxing_gibbous_2.png'))
260
+ MOON_WAXING_GIB_3 = property(lambda self: _resource_path('waxing_gibbous_3.png'))
261
+ MOON_WAXING_GIB_4 = property(lambda self: _resource_path('waxing_gibbous_4.png'))
262
+ MOON_WAXING_GIB_5 = property(lambda self: _resource_path('waxing_gibbous_5.png'))
263
+
264
+ MOON_FULL = property(lambda self: _resource_path('full_moon.png'))
265
+
266
+ MOON_WANING_GIB_1 = property(lambda self: _resource_path('waning_gibbous_1.png'))
267
+ MOON_WANING_GIB_2 = property(lambda self: _resource_path('waning_gibbous_2.png'))
268
+ MOON_WANING_GIB_3 = property(lambda self: _resource_path('waning_gibbous_3.png'))
269
+ MOON_WANING_GIB_4 = property(lambda self: _resource_path('waning_gibbous_4.png'))
270
+ MOON_WANING_GIB_5 = property(lambda self: _resource_path('waning_gibbous_5.png'))
271
+
272
+ MOON_LAST_QUARTER = property(lambda self: _resource_path('last_quarter.png'))
273
+
274
+ MOON_WANING_CRES_1 = property(lambda self: _resource_path('waning_crescent_1.png'))
275
+ MOON_WANING_CRES_2 = property(lambda self: _resource_path('waning_crescent_2.png'))
276
+ MOON_WANING_CRES_3 = property(lambda self: _resource_path('waning_crescent_3.png'))
277
+ MOON_WANING_CRES_4 = property(lambda self: _resource_path('waning_crescent_4.png'))
278
+ MOON_WANING_CRES_5 = property(lambda self: _resource_path('waning_crescent_5.png'))
279
+
280
+
248
281
  # Special features
249
282
  SUPERNOVA = property(lambda self: _resource_path('supernova.png'))
250
283
  PEDESTAL = property(lambda self: _resource_path('pedestal.png'))
@@ -284,6 +317,7 @@ class Icons:
284
317
  CSV = property(lambda self: _resource_path('cvs.png'))
285
318
  PPP = property(lambda self: _resource_path('ppp.png'))
286
319
  SCRIPT = property(lambda self: _resource_path('script.png'))
320
+ ACV = property(lambda self: _resource_path('acv_icon.png'))
287
321
 
288
322
  # Blink & comparison
289
323
  BLINK = property(lambda self: _resource_path('blink.png'))
@@ -393,6 +427,39 @@ def _init_legacy_paths():
393
427
  'slot7_path': get_icon_path('slot7.png'),
394
428
  'slot8_path': get_icon_path('slot8.png'),
395
429
  'slot9_path': get_icon_path('slot9.png'),
430
+ 'acv_icon_path': get_icon_path('acv_icon.png'),
431
+
432
+ 'moon_new_path': get_icon_path('new_moon.png'),
433
+ 'moon_waxing_crescent_1_path': get_icon_path('waxing_crescent_1.png'),
434
+ 'moon_waxing_crescent_2_path': get_icon_path('waxing_crescent_2.png'),
435
+ 'moon_waxing_crescent_3_path': get_icon_path('waxing_crescent_3.png'),
436
+ 'moon_waxing_crescent_4_path': get_icon_path('waxing_crescent_4.png'),
437
+ 'moon_waxing_crescent_5_path': get_icon_path('waxing_crescent_5.png'),
438
+
439
+ 'moon_first_quarter_path': get_icon_path('first_quarter.png'),
440
+
441
+ 'moon_waxing_gibbous_1_path': get_icon_path('waxing_gibbous_1.png'),
442
+ 'moon_waxing_gibbous_2_path': get_icon_path('waxing_gibbous_2.png'),
443
+ 'moon_waxing_gibbous_3_path': get_icon_path('waxing_gibbous_3.png'),
444
+ 'moon_waxing_gibbous_4_path': get_icon_path('waxing_gibbous_4.png'),
445
+ 'moon_waxing_gibbous_5_path': get_icon_path('waxing_gibbous_5.png'),
446
+
447
+ 'moon_full_path': get_icon_path('full_moon.png'),
448
+
449
+ 'moon_waning_gibbous_1_path': get_icon_path('waning_gibbous_1.png'),
450
+ 'moon_waning_gibbous_2_path': get_icon_path('waning_gibbous_2.png'),
451
+ 'moon_waning_gibbous_3_path': get_icon_path('waning_gibbous_3.png'),
452
+ 'moon_waning_gibbous_4_path': get_icon_path('waning_gibbous_4.png'),
453
+ 'moon_waning_gibbous_5_path': get_icon_path('waning_gibbous_5.png'),
454
+
455
+ 'moon_last_quarter_path': get_icon_path('last_quarter.png'),
456
+
457
+ 'moon_waning_crescent_1_path': get_icon_path('waning_crescent_1.png'),
458
+ 'moon_waning_crescent_2_path': get_icon_path('waning_crescent_2.png'),
459
+ 'moon_waning_crescent_3_path': get_icon_path('waning_crescent_3.png'),
460
+ 'moon_waning_crescent_4_path': get_icon_path('waning_crescent_4.png'),
461
+ 'moon_waning_crescent_5_path': get_icon_path('waning_crescent_5.png'),
462
+
396
463
  'rgbcombo_path': get_icon_path('rgbcombo.png'),
397
464
  'rgbextract_path': get_icon_path('rgbextract.png'),
398
465
  'copyslot_path': get_icon_path('copyslot.png'),
@@ -350,6 +350,10 @@ class RGBAlignDialog(QDialog):
350
350
  self.setWindowFlag(Qt.WindowType.Window, True)
351
351
  self.setWindowModality(Qt.WindowModality.NonModal)
352
352
  self.setModal(False)
353
+ try:
354
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
355
+ except Exception:
356
+ pass # older PyQt6 versions
353
357
  self.parent = parent
354
358
  # document could be a view; try to unwrap
355
359
  self.doc_view = document
@@ -458,7 +458,10 @@ class SelectiveColorCorrection(QDialog):
458
458
  self.setWindowTitle(self.tr("Selective Color Correction"))
459
459
  if window_icon:
460
460
  self.setWindowIcon(window_icon)
461
-
461
+ try:
462
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
463
+ except Exception:
464
+ pass # older PyQt6 versions
462
465
  self.docman = doc_manager
463
466
  self.document = document
464
467
  if self.document is None or getattr(self.document, "image", None) is None:
setiastro/saspro/sfcc.py CHANGED
@@ -349,6 +349,10 @@ class SFCCDialog(QDialog):
349
349
  self.setWindowFlag(Qt.WindowType.Window, True)
350
350
  self.setWindowModality(Qt.WindowModality.NonModal)
351
351
  self.setModal(False)
352
+ try:
353
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
354
+ except Exception:
355
+ pass # older PyQt6 versions
352
356
  self.setMinimumSize(800, 600)
353
357
 
354
358
  self.doc_manager = doc_manager
@@ -375,6 +379,7 @@ class SFCCDialog(QDialog):
375
379
  self.lp_filter_combo2.currentIndexChanged.connect(self.save_lp2_setting)
376
380
  self.sens_combo.currentIndexChanged.connect(self.save_sensor_setting)
377
381
  self.star_combo.currentIndexChanged.connect(self.save_star_setting)
382
+ self.finished.connect(lambda *_: self._cleanup())
378
383
 
379
384
  self.grad_method = "poly3"
380
385
  self.grad_method_combo.currentTextChanged.connect(lambda m: setattr(self, "grad_method", m))
@@ -528,12 +533,12 @@ class SFCCDialog(QDialog):
528
533
  self.remove_curve_btn = QPushButton(self.tr("Remove Filter/Sensor Curve…"))
529
534
  self.remove_curve_btn.clicked.connect(self.remove_custom_curve); row4.addWidget(self.remove_curve_btn)
530
535
  row4.addStretch()
531
- self.close_btn = QPushButton(self.tr("Close")); self.close_btn.clicked.connect(self.close); row4.addWidget(self.close_btn)
536
+ self.close_btn = QPushButton(self.tr("Close")); self.close_btn.clicked.connect(self.reject); row4.addWidget(self.close_btn)
532
537
 
533
538
  self.count_label = QLabel(""); layout.addWidget(self.count_label)
534
539
 
535
540
  self.figure = Figure(figsize=(6, 4)); self.canvas = FigureCanvas(self.figure); self.canvas.setVisible(False); layout.addWidget(self.canvas, stretch=1)
536
- self.reset_btn = QPushButton(self.tr("Reset View/Close")); self.reset_btn.clicked.connect(self.close); layout.addWidget(self.reset_btn)
541
+ self.reset_btn = QPushButton(self.tr("Reset View/Close")); self.reset_btn.clicked.connect(self.reject); layout.addWidget(self.reset_btn)
537
542
 
538
543
  # hide gradient controls by default (enable if you like)
539
544
  self.run_grad_btn.hide(); self.grad_method_combo.hide()
@@ -1454,11 +1459,64 @@ class SFCCDialog(QDialog):
1454
1459
  self.sasp_viewer_window.show()
1455
1460
  self.sasp_viewer_window.destroyed.connect(self._on_sasp_closed)
1456
1461
 
1462
+ def _cleanup(self):
1463
+ # 1) Close/cleanup child window (SaspViewer)
1464
+ try:
1465
+ if getattr(self, "sasp_viewer_window", None) is not None:
1466
+ try:
1467
+ self.sasp_viewer_window.destroyed.disconnect(self._on_sasp_closed)
1468
+ except Exception:
1469
+ pass
1470
+ try:
1471
+ self.sasp_viewer_window.close()
1472
+ except Exception:
1473
+ pass
1474
+ self.sasp_viewer_window = None
1475
+ except Exception:
1476
+ pass
1477
+
1478
+ # 2) Disconnect any long-lived external signals (add these if/when used)
1479
+ # Example patterns:
1480
+ try:
1481
+ self.doc_manager.activeDocumentChanged.disconnect(self._on_active_doc_changed)
1482
+ except Exception:
1483
+ pass
1484
+ try:
1485
+ self.main_win.currentDocumentChanged.disconnect(self._on_active_doc_changed)
1486
+ except Exception:
1487
+ pass
1488
+
1489
+ # 3) Release large caches/refs (important since dialog may not be deleted)
1490
+ try:
1491
+ self.current_image = None
1492
+ self.current_header = None
1493
+ self.star_list = []
1494
+ self._last_matched = []
1495
+ if hasattr(self, "wcs"):
1496
+ self.wcs = None
1497
+ if hasattr(self, "wcs_header"):
1498
+ self.wcs_header = None
1499
+ except Exception:
1500
+ pass
1501
+
1502
+ # 4) Matplotlib cleanup
1503
+ try:
1504
+ if getattr(self, "figure", None) is not None:
1505
+ self.figure.clf()
1506
+ if getattr(self, "canvas", None) is not None:
1507
+ self.canvas.setVisible(False)
1508
+ self.canvas.draw_idle()
1509
+ except Exception:
1510
+ pass
1511
+
1512
+
1457
1513
  def _on_sasp_closed(self, _=None):
1458
1514
  # Called when the SaspViewer window is destroyed
1459
1515
  self.sasp_viewer_window = None
1516
+ self._cleanup()
1460
1517
 
1461
1518
  def closeEvent(self, event):
1519
+ self._cleanup()
1462
1520
  super().closeEvent(event)
1463
1521
 
1464
1522
 
@@ -493,7 +493,8 @@ class DraggableToolBar(QToolBar):
493
493
  cid = self._action_id(act)
494
494
  if cid:
495
495
  m.addSeparator()
496
- m.addAction(self.tr("Hide this icon"), lambda: self._set_action_hidden(act, True))
496
+ m.addAction(self.tr("Hide this icon"), lambda: self.window()._hide_action_to_hidden_toolbar(act))
497
+
497
498
 
498
499
  # (Optional) teach users about Alt+Drag:
499
500
  m.addSeparator()
@@ -521,16 +522,19 @@ class DraggableToolBar(QToolBar):
521
522
  m.addSeparator()
522
523
 
523
524
  # Submenu listing hidden actions for this toolbar
524
- hidden = self._load_hidden_set()
525
525
  sub = m.addMenu(self.tr("Show hidden…"))
526
526
 
527
- # Build list from actions that are currently invisible
527
+ mw = self.window()
528
+ tb_hidden = getattr(mw, "_hidden_toolbar", lambda: None)()
528
529
  any_hidden = False
529
- for act in self.actions():
530
- cid = self._action_id(act)
531
- if cid and (cid in hidden) and (not act.isVisible()):
530
+ if tb_hidden:
531
+ for act in tb_hidden.actions():
532
+ # Skip separators
533
+ if act.isSeparator():
534
+ continue
532
535
  any_hidden = True
533
- sub.addAction(act.text() or cid, lambda a=act: self._set_action_hidden(a, False))
536
+ sub.addAction(act.text() or (act.property("command_id") or act.objectName() or "item"),
537
+ lambda a=act: mw._unhide_action_from_hidden_toolbar(a))
534
538
 
535
539
  if not any_hidden:
536
540
  sub.setEnabled(False)
@@ -541,10 +545,15 @@ class DraggableToolBar(QToolBar):
541
545
  m.exec(ev.globalPos())
542
546
 
543
547
  def _reset_hidden_icons(self):
544
- # Show everything and clear hidden list
545
- for act in self.actions():
546
- act.setVisible(True)
547
- self._save_hidden_set(set())
548
+ mw = self.window()
549
+ tb_hidden = getattr(mw, "_hidden_toolbar", lambda: None)()
550
+ if not tb_hidden:
551
+ return
552
+ # copy list because we'll mutate
553
+ acts = [a for a in tb_hidden.actions() if not a.isSeparator()]
554
+ for a in acts:
555
+ mw._unhide_action_from_hidden_toolbar(a)
556
+
548
557
 
549
558
 
550
559
  _PRESET_UI_IDS = {
@@ -1910,50 +1919,160 @@ class ShortcutManager:
1910
1919
  # legacy single-remove (kept for callers)
1911
1920
  self.delete_by_id(sid, persist=True)
1912
1921
 
1913
-
1914
1922
  class _StatStretchPresetDialog(QDialog):
1923
+ """
1924
+ Preset editor for headless replay: command_id="stat_stretch"
1925
+
1926
+ Keys supported:
1927
+ target_median: float
1928
+ linked: bool
1929
+ normalize: bool
1930
+ apply_curves: bool
1931
+ curves_boost: float # 0..1
1932
+ blackpoint_sigma: float # 0..1 (matches your slider/100)
1933
+ no_black_clip: bool
1934
+ hdr_compress: bool
1935
+ hdr_amount: float # 0..1
1936
+ hdr_knee: float # 0..1
1937
+ luma_only: bool
1938
+ luma_mode: str # e.g. "rec709"
1939
+ """
1915
1940
  def __init__(self, parent=None, initial: dict | None = None):
1916
1941
  super().__init__(parent)
1917
1942
  self.setWindowTitle("Statistical Stretch — Preset")
1918
1943
  init = dict(initial or {})
1919
1944
 
1945
+ # --- Target median ---
1920
1946
  self.spin_target = QDoubleSpinBox()
1921
- self.spin_target.setRange(0.0, 1.0); self.spin_target.setDecimals(3)
1947
+ self.spin_target.setRange(0.0, 1.0)
1948
+ self.spin_target.setDecimals(3)
1922
1949
  self.spin_target.setSingleStep(0.01)
1923
1950
  self.spin_target.setValue(float(init.get("target_median", 0.25)))
1924
1951
 
1952
+ # --- Linked / Normalize ---
1925
1953
  self.chk_linked = QCheckBox("Linked RGB channels")
1926
1954
  self.chk_linked.setChecked(bool(init.get("linked", False)))
1927
1955
 
1928
1956
  self.chk_normalize = QCheckBox("Normalize to [0..1]")
1929
1957
  self.chk_normalize.setChecked(bool(init.get("normalize", False)))
1930
1958
 
1959
+ # --- Curves ---
1960
+ self.chk_curves = QCheckBox("Apply Curves")
1961
+ self.chk_curves.setChecked(bool(init.get("apply_curves", False)))
1962
+
1931
1963
  self.spin_curves = QDoubleSpinBox()
1932
- self.spin_curves.setRange(0.0, 1.0); self.spin_curves.setDecimals(2)
1964
+ self.spin_curves.setRange(0.0, 1.0)
1965
+ self.spin_curves.setDecimals(2)
1933
1966
  self.spin_curves.setSingleStep(0.05)
1934
- self.spin_curves.setValue(float(init.get("curves_boost", 0.0 if not init.get("apply_curves") else 0.20)))
1967
+ self.spin_curves.setValue(float(init.get("curves_boost", 0.0)))
1968
+ self.spin_curves.setEnabled(self.chk_curves.isChecked())
1969
+ self.chk_curves.toggled.connect(self.spin_curves.setEnabled)
1970
+
1971
+ # --- Blackpoint sigma ---
1972
+ self.spin_bp = QDoubleSpinBox()
1973
+ self.spin_bp.setRange(0.0, 1.0)
1974
+ self.spin_bp.setDecimals(2)
1975
+ self.spin_bp.setSingleStep(0.05)
1976
+ self.spin_bp.setValue(float(init.get("blackpoint_sigma", 0.0)))
1977
+
1978
+ self.chk_no_black_clip = QCheckBox("No black clip")
1979
+ self.chk_no_black_clip.setChecked(bool(init.get("no_black_clip", False)))
1980
+
1981
+ # --- HDR compress ---
1982
+ self.chk_hdr = QCheckBox("HDR compression")
1983
+ self.chk_hdr.setChecked(bool(init.get("hdr_compress", False)))
1984
+
1985
+ self.spin_hdr_amt = QDoubleSpinBox()
1986
+ self.spin_hdr_amt.setRange(0.0, 1.0)
1987
+ self.spin_hdr_amt.setDecimals(2)
1988
+ self.spin_hdr_amt.setSingleStep(0.05)
1989
+ self.spin_hdr_amt.setValue(float(init.get("hdr_amount", 0.0)))
1990
+
1991
+ self.spin_hdr_knee = QDoubleSpinBox()
1992
+ self.spin_hdr_knee.setRange(0.0, 1.0)
1993
+ self.spin_hdr_knee.setDecimals(2)
1994
+ self.spin_hdr_knee.setSingleStep(0.05)
1995
+ self.spin_hdr_knee.setValue(float(init.get("hdr_knee", 0.5)))
1996
+
1997
+ def _set_hdr_enabled(on: bool):
1998
+ on = bool(on)
1999
+ self.spin_hdr_amt.setEnabled(on)
2000
+ self.spin_hdr_knee.setEnabled(on)
2001
+
2002
+ _set_hdr_enabled(self.chk_hdr.isChecked())
2003
+ self.chk_hdr.toggled.connect(_set_hdr_enabled)
2004
+
2005
+ # --- Luma only ---
2006
+ self.chk_luma_only = QCheckBox("Luma-only mode")
2007
+ self.chk_luma_only.setChecked(bool(init.get("luma_only", False)))
2008
+
2009
+ self.cmb_luma = QComboBox()
2010
+ # keep in sync with your tool’s supported modes
2011
+ self.cmb_luma.addItems(["rec709", "avg", "hsp", "max"])
2012
+ init_mode = str(init.get("luma_mode", "rec709") or "rec709")
2013
+ idx = self.cmb_luma.findText(init_mode)
2014
+ if idx >= 0:
2015
+ self.cmb_luma.setCurrentIndex(idx)
2016
+
2017
+ self.cmb_luma.setEnabled(self.chk_luma_only.isChecked())
2018
+ self.chk_luma_only.toggled.connect(self.cmb_luma.setEnabled)
2019
+
2020
+ # --- Layout ---
2021
+ form = QFormLayout()
1935
2022
 
1936
- form = QFormLayout(self)
1937
2023
  form.addRow("Target median:", self.spin_target)
1938
2024
  form.addRow("", self.chk_linked)
1939
2025
  form.addRow("", self.chk_normalize)
2026
+
2027
+ form.addRow("", QLabel("— Tone shaping —"))
2028
+ form.addRow("", self.chk_curves)
1940
2029
  form.addRow("Curves boost (0–1):", self.spin_curves)
1941
- form.addRow(QLabel("Curves are applied only if boost > 0."))
1942
2030
 
1943
- btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
1944
- btns.accepted.connect(self.accept); btns.rejected.connect(self.reject)
2031
+ form.addRow("", QLabel("— Blackpoint / HDR —"))
2032
+ form.addRow("Blackpoint σ (0–1):", self.spin_bp)
2033
+ form.addRow("", self.chk_no_black_clip)
2034
+
2035
+ form.addRow("", self.chk_hdr)
2036
+ form.addRow("HDR amount (0–1):", self.spin_hdr_amt)
2037
+ form.addRow("HDR knee (0–1):", self.spin_hdr_knee)
2038
+
2039
+ form.addRow("", QLabel("— Luma mode —"))
2040
+ form.addRow("", self.chk_luma_only)
2041
+ form.addRow("Luma mode:", self.cmb_luma)
2042
+
2043
+ btns = QDialogButtonBox(
2044
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
2045
+ parent=self
2046
+ )
2047
+ btns.accepted.connect(self.accept)
2048
+ btns.rejected.connect(self.reject)
1945
2049
  form.addRow(btns)
1946
2050
 
2051
+ self.setLayout(form)
2052
+
1947
2053
  def result_dict(self) -> dict:
1948
- boost = float(self.spin_curves.value())
2054
+ hdr_on = bool(self.chk_hdr.isChecked())
2055
+ curves_on = bool(self.chk_curves.isChecked())
2056
+ luma_on = bool(self.chk_luma_only.isChecked())
2057
+
1949
2058
  return {
1950
2059
  "target_median": float(self.spin_target.value()),
1951
2060
  "linked": bool(self.chk_linked.isChecked()),
1952
2061
  "normalize": bool(self.chk_normalize.isChecked()),
1953
- "apply_curves": bool(boost > 0.0),
1954
- "curves_boost": boost,
2062
+
2063
+ "apply_curves": curves_on,
2064
+ "curves_boost": float(self.spin_curves.value()) if curves_on else 0.0,
2065
+
2066
+ "blackpoint_sigma": float(self.spin_bp.value()),
2067
+ "no_black_clip": bool(self.chk_no_black_clip.isChecked()),
2068
+
2069
+ "hdr_compress": hdr_on,
2070
+ "hdr_amount": float(self.spin_hdr_amt.value()) if hdr_on else 0.0,
2071
+ "hdr_knee": float(self.spin_hdr_knee.value()) if hdr_on else 0.0,
2072
+
2073
+ "luma_only": luma_on,
2074
+ "luma_mode": str(self.cmb_luma.currentText()) if luma_on else "rec709",
1955
2075
  }
1956
-
1957
2076
 
1958
2077
  class _StarStretchPresetDialog(QDialog):
1959
2078
  def __init__(self, parent=None, initial: dict | None = None):