setiastrosuitepro 1.6.10__py3-none-any.whl → 1.7.0.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 (51) hide show
  1. setiastro/images/colorwheel.svg +97 -0
  2. setiastro/images/narrowbandnormalization.png +0 -0
  3. setiastro/images/planetarystacker.png +0 -0
  4. setiastro/saspro/__main__.py +1 -1
  5. setiastro/saspro/_generated/build_info.py +2 -2
  6. setiastro/saspro/aberration_ai.py +49 -11
  7. setiastro/saspro/aberration_ai_preset.py +29 -3
  8. setiastro/saspro/backgroundneutral.py +73 -33
  9. setiastro/saspro/blink_comparator_pro.py +116 -71
  10. setiastro/saspro/convo.py +9 -6
  11. setiastro/saspro/curve_editor_pro.py +72 -22
  12. setiastro/saspro/curves_preset.py +249 -47
  13. setiastro/saspro/doc_manager.py +178 -11
  14. setiastro/saspro/gui/main_window.py +305 -66
  15. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  16. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  17. setiastro/saspro/gui/mixins/menu_mixin.py +32 -1
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +135 -11
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +972 -0
  22. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  23. setiastro/saspro/imageops/stretch.py +66 -15
  24. setiastro/saspro/legacy/numba_utils.py +25 -48
  25. setiastro/saspro/live_stacking.py +24 -4
  26. setiastro/saspro/multiscale_decomp.py +30 -17
  27. setiastro/saspro/narrowband_normalization.py +1618 -0
  28. setiastro/saspro/numba_utils.py +0 -55
  29. setiastro/saspro/ops/script_editor.py +5 -0
  30. setiastro/saspro/ops/scripts.py +119 -0
  31. setiastro/saspro/remove_green.py +1 -1
  32. setiastro/saspro/resources.py +4 -0
  33. setiastro/saspro/ser_stack_config.py +74 -0
  34. setiastro/saspro/ser_stacker.py +2310 -0
  35. setiastro/saspro/ser_stacker_dialog.py +1500 -0
  36. setiastro/saspro/ser_tracking.py +206 -0
  37. setiastro/saspro/serviewer.py +1258 -0
  38. setiastro/saspro/sfcc.py +602 -214
  39. setiastro/saspro/shortcuts.py +35 -16
  40. setiastro/saspro/stacking_suite.py +332 -87
  41. setiastro/saspro/star_alignment.py +243 -122
  42. setiastro/saspro/stat_stretch.py +220 -31
  43. setiastro/saspro/subwindow.py +2 -4
  44. setiastro/saspro/whitebalance.py +24 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/METADATA +2 -2
  47. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/RECORD +51 -40
  48. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/LICENSE +0 -0
  51. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/license.txt +0 -0
@@ -12,6 +12,7 @@ from PyQt6.QtWidgets import QMenu, QToolButton
12
12
 
13
13
  from PyQt6.QtCore import QElapsedTimer
14
14
 
15
+ import sys
15
16
 
16
17
  if TYPE_CHECKING:
17
18
  pass
@@ -30,10 +31,10 @@ from setiastro.saspro.resources import (
30
31
  stacking_path, pedestal_icon_path, starspike_path, astrospike_path,
31
32
  signature_icon_path, livestacking_path, convoicon_path, spcc_icon_path,
32
33
  exoicon_path, peeker_icon, dse_icon_path, isophote_path, statstretch_path,
33
- starstretch_path, curves_path, disk_path, uhs_path, blink_path, ppp_path,
34
+ starstretch_path, curves_path, disk_path, uhs_path, blink_path, ppp_path, narrowbandnormalization_path,
34
35
  nbtorgb_path, freqsep_path, multiscale_decomp_path, contsub_path, halo_path, cosmic_path,
35
36
  satellite_path, imagecombine_path, wims_path, wimi_path, linearfit_path,
36
- debayer_path, aberration_path, functionbundles_path, viewbundles_path,
37
+ debayer_path, aberration_path, functionbundles_path, viewbundles_path, planetarystacker_path,
37
38
  selectivecolor_path, rgbalign_path,
38
39
  )
39
40
 
@@ -255,6 +256,7 @@ class ToolbarMixin:
255
256
  tb_tl.addAction(self.act_blink) # Tools start here; Blink shows with QIcon(blink_path)
256
257
  tb_tl.addAction(self.act_ppp) # Perfect Palette Picker
257
258
  tb_tl.addAction(self.act_nbtorgb)
259
+ tb_tl.addAction(self.act_narrowband_normalization)
258
260
  tb_tl.addAction(self.act_selective_color)
259
261
  tb_tl.addAction(self.act_freqsep)
260
262
  tb_tl.addAction(self.act_multiscale_decomp)
@@ -301,6 +303,7 @@ class ToolbarMixin:
301
303
  tb_star.addAction(self.act_psf_viewer)
302
304
  tb_star.addAction(self.act_stacking_suite)
303
305
  tb_star.addAction(self.act_live_stacking)
306
+ tb_star.addAction(self.act_planetary_stacker)
304
307
  tb_star.addAction(self.act_plate_solve)
305
308
  tb_star.addAction(self.act_star_align)
306
309
  tb_star.addAction(self.act_star_register)
@@ -366,6 +369,7 @@ class ToolbarMixin:
366
369
  tb_hidden.setObjectName("Hidden")
367
370
  tb_hidden.setSettingsKey("Toolbar/Hidden")
368
371
  self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb_hidden)
372
+ #tb_hidden.addAction(self.act_narrowband_normalization)
369
373
  tb_hidden.setVisible(False) # <- always hidden
370
374
 
371
375
  # This can move actions between toolbars, so do it after each toolbar has its base order restored.
@@ -594,7 +598,16 @@ class ToolbarMixin:
594
598
  fit_menu.addAction(self.act_auto_fit_resize) # use the real action
595
599
  btn_fit.setMenu(fit_menu)
596
600
  btn_fit.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
601
+ tb = self._toolbar_containing_action(self.act_autostretch)
602
+ if tb:
603
+ btn = tb.widgetForAction(self.act_autostretch)
604
+ if isinstance(btn, QToolButton):
605
+ # ... build menu ...
606
+ btn.setMenu(menu)
607
+ btn.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
597
608
 
609
+ # IMPORTANT: re-apply style after action moves / rebind
610
+ self._style_toggle_toolbutton(btn)
598
611
 
599
612
  def _bind_view_toolbar_menus(self, tb: DraggableToolBar):
600
613
  # --- Display-Stretch menu ---
@@ -650,6 +663,12 @@ class ToolbarMixin:
650
663
  btn_fit.setMenu(fit_menu)
651
664
  btn_fit.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
652
665
 
666
+ def _linux_force_text_action(self, act: QAction, text: str) -> None:
667
+ """On Linux, show text-only for this action (no theme icon)."""
668
+ if not sys.platform.startswith("linux"):
669
+ return
670
+ act.setIcon(QIcon()) # remove whatever theme icon got assigned
671
+ act.setText(text) # show the glyph/text
653
672
 
654
673
  def _create_actions(self):
655
674
  # File actions
@@ -771,30 +790,30 @@ class ToolbarMixin:
771
790
  self.act_bake_display_stretch.triggered.connect(self._bake_display_stretch)
772
791
 
773
792
  # --- Zoom controls ---
774
- # --- Zoom controls (themed icons) ---
775
793
  self.act_zoom_out = QAction(QIcon.fromTheme("zoom-out"), self.tr("Zoom Out"), self)
776
794
  self.act_zoom_out.setStatusTip(self.tr("Zoom out"))
777
795
  self.act_zoom_out.setShortcuts([QKeySequence("Ctrl+-")])
778
796
  self.act_zoom_out.triggered.connect(lambda: self._zoom_step_active(-1))
797
+ self._linux_force_text_action(self.act_zoom_out, "−") # true minus
779
798
 
780
799
  self.act_zoom_in = QAction(QIcon.fromTheme("zoom-in"), self.tr("Zoom In"), self)
781
800
  self.act_zoom_in.setStatusTip(self.tr("Zoom in"))
782
- self.act_zoom_in.setShortcuts([
783
- QKeySequence("Ctrl++"), # Ctrl + (Shift + = on many keyboards)
784
- QKeySequence("Ctrl+="), # fallback
785
- ])
801
+ self.act_zoom_in.setShortcuts([QKeySequence("Ctrl++"), QKeySequence("Ctrl+=")])
786
802
  self.act_zoom_in.triggered.connect(lambda: self._zoom_step_active(+1))
803
+ self._linux_force_text_action(self.act_zoom_in, "+")
787
804
 
788
805
  self.act_zoom_1_1 = QAction(QIcon.fromTheme("zoom-original"), self.tr("1:1"), self)
789
806
  self.act_zoom_1_1.setStatusTip(self.tr("Zoom to 100% (pixel-for-pixel)"))
790
807
  self.act_zoom_1_1.setShortcut(QKeySequence("Ctrl+1"))
791
808
  self.act_zoom_1_1.triggered.connect(self._zoom_active_1_1)
809
+ self._linux_force_text_action(self.act_zoom_1_1, "1:1")
792
810
 
793
811
  self.act_zoom_fit = QAction(QIcon.fromTheme("zoom-fit-best"), self.tr("Fit"), self)
794
812
  self.act_zoom_fit.setStatusTip(self.tr("Fit image to current window"))
795
813
  self.act_zoom_fit.setShortcut(QKeySequence("Ctrl+0"))
796
814
  self.act_zoom_fit.triggered.connect(self._zoom_active_fit)
797
815
  self.act_zoom_fit.setCheckable(True)
816
+ self._linux_force_text_action(self.act_zoom_fit, "Fit")
798
817
 
799
818
  self.act_auto_fit_resize = QAction(self.tr("Auto-fit on Resize"), self)
800
819
  self.act_auto_fit_resize.setCheckable(True)
@@ -813,7 +832,13 @@ class ToolbarMixin:
813
832
  self.act_paste_view.setShortcut("Ctrl+Shift+V")
814
833
  self.act_copy_view.triggered.connect(self._copy_active_view)
815
834
  self.act_paste_view.triggered.connect(self._paste_active_view)
816
-
835
+ # --- Edit: Mono -> RGB (triplicate channels) ---
836
+ self.act_mono_to_rgb = QAction(self.tr("Convert Mono to RGB"), self)
837
+ self.act_mono_to_rgb.setStatusTip(self.tr("Convert a mono image to RGB by duplicating the channel"))
838
+ self.act_mono_to_rgb.triggered.connect(self._convert_mono_to_rgb_active)
839
+ self.act_swap_rb = QAction(self.tr("Swap R and B Channels"), self)
840
+ self.act_swap_rb.setStatusTip(self.tr("Swap red and blue channels on the active RGB image"))
841
+ self.act_swap_rb.triggered.connect(self._swap_rb_active)
817
842
  # Functions
818
843
  self.act_crop = QAction(QIcon(cropicon_path), self.tr("Crop..."), self)
819
844
  self.act_crop.setStatusTip(self.tr("Crop / rotate with handles"))
@@ -877,7 +902,7 @@ class ToolbarMixin:
877
902
  self.act_linear_fit.setShortcut("Ctrl+L")
878
903
  self.act_linear_fit.triggered.connect(self._open_linear_fit)
879
904
 
880
- self.act_remove_green = QAction(QIcon(green_path), self.tr("Remove Green..."), self)
905
+ self.act_remove_green = QAction(QIcon(green_path), self.tr("Remove Green (SCNR)..."), self)
881
906
  self.act_remove_green.setToolTip(self.tr("SCNR-style green channel removal."))
882
907
  self.act_remove_green.setIconVisibleInMenu(True)
883
908
  self.act_remove_green.triggered.connect(self._open_remove_green)
@@ -1102,6 +1127,16 @@ class ToolbarMixin:
1102
1127
  self.act_ppp.setStatusTip(self.tr("Pick the perfect palette for your image"))
1103
1128
  self.act_ppp.triggered.connect(self._open_ppp_tool)
1104
1129
 
1130
+ self.act_narrowband_normalization = QAction(QIcon(narrowbandnormalization_path), self.tr("Narrowband Normalization..."), self)
1131
+ self.act_narrowband_normalization.setStatusTip(
1132
+ self.tr("Normalize HOO/SHO/HSO/HOS (PixelMath port by Bill Blanshan and Mike Cranfield)")
1133
+ )
1134
+ self.act_narrowband_normalization.setIconVisibleInMenu(False)
1135
+ self.act_narrowband_normalization.setShortcut(QKeySequence("Ctrl+Alt+Shift+N"))
1136
+ self.act_narrowband_normalization.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
1137
+ self.addAction(self.act_narrowband_normalization)
1138
+ self.act_narrowband_normalization.triggered.connect(self._open_narrowband_normalization_tool)
1139
+
1105
1140
  self.act_nbtorgb = QAction(QIcon(nbtorgb_path), self.tr("NB->RGB Stars..."), self)
1106
1141
  self.act_nbtorgb.setStatusTip(self.tr("Combine narrowband to RGB with optional OSC stars"))
1107
1142
  self.act_nbtorgb.setIconVisibleInMenu(True)
@@ -1149,6 +1184,11 @@ class ToolbarMixin:
1149
1184
  self.act_live_stacking.setStatusTip(self.tr("Live monitor and stack incoming frames"))
1150
1185
  self.act_live_stacking.triggered.connect(self._open_live_stacking)
1151
1186
 
1187
+ self.act_planetary_stacker = QAction(QIcon(planetarystacker_path), self.tr("Planetary Stacker..."), self)
1188
+ self.act_planetary_stacker.setIconVisibleInMenu(True)
1189
+ self.act_planetary_stacker.setStatusTip(self.tr("Stack SER videos (planetary/solar/lunar)"))
1190
+ self.act_planetary_stacker.triggered.connect(self._open_planetary_stacker)
1191
+
1152
1192
  self.act_plate_solve = QAction(QIcon(platesolve_path), self.tr("Plate Solver..."), self)
1153
1193
  self.act_plate_solve.setIconVisibleInMenu(True)
1154
1194
  self.act_plate_solve.setStatusTip(self.tr("Solve WCS/SIP for the active image or a file"))
@@ -1345,6 +1385,7 @@ class ToolbarMixin:
1345
1385
  reg("nbtorgb", self.act_nbtorgb)
1346
1386
  reg("freqsep", self.act_freqsep)
1347
1387
  reg("selective_color", self.act_selective_color)
1388
+ reg("narrowband_normalization", self.act_narrowband_normalization)
1348
1389
  reg("contsub", self.act_contsub)
1349
1390
  reg("abe", self.act_abe)
1350
1391
  reg("create_mask", self.act_create_mask)
@@ -1369,7 +1410,7 @@ class ToolbarMixin:
1369
1410
  reg("pixel_math", self.act_pixelmath)
1370
1411
  reg("signature_insert", self.act_signature)
1371
1412
  reg("halo_b_gon", self.act_halobgon)
1372
-
1413
+ reg("planetary_stacker", self.act_planetary_stacker)
1373
1414
  reg("multiscale_decomp", self.act_multiscale_decomp)
1374
1415
  reg("geom_invert", self.act_geom_invert)
1375
1416
  reg("geom_flip_horizontal", self.act_geom_flip_h)
@@ -1407,6 +1448,83 @@ class ToolbarMixin:
1407
1448
  reg("view_bundles", self.act_view_bundles)
1408
1449
  reg("function_bundles", self.act_function_bundles)
1409
1450
 
1451
+ def _reset_all_toolbars_to_factory(self):
1452
+ """
1453
+ Clears all persisted toolbar membership/order/hidden state so the UI
1454
+ returns to the factory layout defined in code.
1455
+ """
1456
+ if not hasattr(self, "settings"):
1457
+ return
1458
+
1459
+ # 1) Clear global toolbar persistence
1460
+ keys_to_clear = [
1461
+ "Toolbar/Assignments",
1462
+ "Toolbar/HiddenPrev",
1463
+ "Toolbar/HiddenMigrationDone",
1464
+
1465
+ # Per-toolbar order lists
1466
+ "Toolbar/View",
1467
+ "Toolbar/Functions",
1468
+ "Toolbar/Cosmic",
1469
+ "Toolbar/Tools",
1470
+ "Toolbar/Geometry",
1471
+ "Toolbar/StarStuff",
1472
+ "Toolbar/Masks",
1473
+ "Toolbar/WhatsInMy",
1474
+ "Toolbar/Bundles",
1475
+ "Toolbar/Hidden",
1476
+ ]
1477
+
1478
+ for k in keys_to_clear:
1479
+ try:
1480
+ self.settings.remove(k)
1481
+ except Exception:
1482
+ pass
1483
+
1484
+ # 2) Clear legacy hidden lists (old system) if they exist
1485
+ # (These are the ones like "Toolbar/Tools/Hidden")
1486
+ try:
1487
+ # brute-force remove known ones
1488
+ legacy_hidden = [
1489
+ "Toolbar/View/Hidden",
1490
+ "Toolbar/Functions/Hidden",
1491
+ "Toolbar/Cosmic/Hidden",
1492
+ "Toolbar/Tools/Hidden",
1493
+ "Toolbar/Geometry/Hidden",
1494
+ "Toolbar/StarStuff/Hidden",
1495
+ "Toolbar/Masks/Hidden",
1496
+ "Toolbar/WhatsInMy/Hidden",
1497
+ "Toolbar/Bundles/Hidden",
1498
+ ]
1499
+ for k in legacy_hidden:
1500
+ self.settings.remove(k)
1501
+ except Exception:
1502
+ pass
1503
+
1504
+ try:
1505
+ self.settings.sync()
1506
+ except Exception:
1507
+ pass
1508
+
1509
+ # 3) Rebuild toolbars from code defaults
1510
+ # Safest approach: remove existing toolbars and rebuild.
1511
+ from setiastro.saspro.shortcuts import DraggableToolBar
1512
+ for tb in self.findChildren(DraggableToolBar):
1513
+ try:
1514
+ self.removeToolBar(tb)
1515
+ tb.deleteLater()
1516
+ except Exception:
1517
+ pass
1518
+
1519
+ # Build fresh
1520
+ self._init_toolbar()
1521
+
1522
+ # Optional: ensure Hidden stays hidden
1523
+ tb_hidden = self._hidden_toolbar()
1524
+ if tb_hidden:
1525
+ tb_hidden.setVisible(False)
1526
+
1527
+
1410
1528
  def _restore_toolbar_order(self, tb, settings_key: str):
1411
1529
  """
1412
1530
  Restore toolbar action order from QSettings, using command_id/objectName.
@@ -1821,4 +1939,10 @@ class ToolbarMixin:
1821
1939
  if hasattr(self, "act_hide_mask"):
1822
1940
  self.act_hide_mask.setEnabled(has_mask and overlay_on)
1823
1941
 
1824
-
1942
+ def _style_toggle_toolbutton(self, btn: QToolButton):
1943
+ # Make sure the action visually shows "on" state
1944
+ btn.setCheckable(True) # safe even if already
1945
+ btn.setStyleSheet("""
1946
+ QToolButton { color: #dcdcdc; }
1947
+ QToolButton:checked { color: #DAA520; font-weight: 600; }
1948
+ """)
@@ -4,8 +4,8 @@ import numpy as np
4
4
 
5
5
  from PyQt6.QtCore import Qt, QSettings, QTimer, QEvent, pyqtSignal
6
6
  from PyQt6.QtWidgets import (
7
- QDialog, QVBoxLayout, QHBoxLayout, QLabel, QSlider, QPushButton, QScrollArea,
8
- QTableWidget, QTableWidgetItem, QMessageBox, QToolButton, QInputDialog, QSplitter, QSizePolicy, QHeaderView
7
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QSlider, QPushButton, QScrollArea,QWidget,
8
+ QTableWidget, QTableWidgetItem, QMessageBox, QToolButton, QInputDialog, QSplitter, QSizePolicy, QHeaderView, QApplication
9
9
  )
10
10
  from PyQt6.QtGui import QPixmap, QPainter, QPen, QColor, QFont, QPalette
11
11
 
@@ -125,7 +125,12 @@ class HistogramDialog(QDialog):
125
125
 
126
126
  splitter.addWidget(self.scroll_area)
127
127
 
128
- # right: stats table
128
+ # right: stats panel (table + button under it)
129
+ stats_panel = QWidget(self)
130
+ stats_v = QVBoxLayout(stats_panel)
131
+ stats_v.setContentsMargins(0, 0, 0, 0)
132
+ stats_v.setSpacing(6)
133
+
129
134
  self.stats_table = QTableWidget(self)
130
135
  self.stats_table.setRowCount(7)
131
136
  self.stats_table.setColumnCount(1)
@@ -137,15 +142,21 @@ class HistogramDialog(QDialog):
137
142
  # Let it grow/shrink with the splitter
138
143
  self.stats_table.setMinimumWidth(320)
139
144
  self.stats_table.setSizePolicy(
140
- QSizePolicy.Policy.Preferred, # <- was Fixed
145
+ QSizePolicy.Policy.Preferred,
141
146
  QSizePolicy.Policy.Expanding,
142
147
  )
143
148
 
144
- # Make the columns use available width nicely
145
149
  hdr = self.stats_table.horizontalHeader()
146
150
  hdr.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
147
- # hdr.setStretchLastSection(True)
148
- splitter.addWidget(self.stats_table)
151
+
152
+ stats_v.addWidget(self.stats_table, 1)
153
+
154
+ # NEW: button directly under the table
155
+ self.btn_more_stats = QPushButton(self.tr("More Stats…"), self)
156
+ self.btn_more_stats.clicked.connect(self._show_more_stats)
157
+ stats_v.addWidget(self.btn_more_stats, 0)
158
+
159
+ splitter.addWidget(stats_panel)
149
160
 
150
161
  # Give more space to histogram side by default
151
162
  splitter.setStretchFactor(0, 3)
@@ -631,6 +642,167 @@ class HistogramDialog(QDialog):
631
642
 
632
643
  self._adjust_stats_width()
633
644
 
645
+ def _show_more_stats(self):
646
+ if self.image is None:
647
+ return
648
+
649
+ dlg = QDialog(self)
650
+ dlg.setWindowTitle(self.tr("Image Statistics"))
651
+ dlg.setWindowModality(Qt.WindowModality.NonModal)
652
+ dlg.setModal(False)
653
+
654
+ root = QVBoxLayout(dlg)
655
+
656
+ info = QLabel(self.tr(
657
+ "Detailed robust statistics and percentiles.\n"
658
+ "Computed on normalized float image values in [0,1]."
659
+ ))
660
+ info.setWordWrap(True)
661
+ root.addWidget(info)
662
+
663
+ tbl = QTableWidget(dlg)
664
+ tbl.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
665
+ root.addWidget(tbl, 1)
666
+
667
+ btn_row = QHBoxLayout()
668
+ btn_copy = QPushButton(self.tr("Copy as Text"), dlg)
669
+ btn_close = QPushButton(self.tr("Close"), dlg)
670
+ btn_row.addStretch(1)
671
+ btn_row.addWidget(btn_copy)
672
+ btn_row.addWidget(btn_close)
673
+ root.addLayout(btn_row)
674
+
675
+ btn_close.clicked.connect(dlg.accept)
676
+
677
+ # ---- compute stats ----
678
+ img = self.image
679
+ if img.ndim == 3 and img.shape[2] == 3:
680
+ chans = [img[..., 0], img[..., 1], img[..., 2]]
681
+ col_names = ["R", "G", "B"]
682
+ else:
683
+ chan = img if img.ndim == 2 else img[..., 0]
684
+ chans = [chan]
685
+ col_names = ["Gray"]
686
+
687
+ row_defs = [
688
+ ("Min", "min"),
689
+ ("Max", "max"),
690
+ ("Mean", "mean"),
691
+ ("Median", "median"),
692
+ ("StdDev", "std"),
693
+ ("Variance", "var"),
694
+ ("MAD", "mad"),
695
+ ("IQR (p75-p25)", "iqr"),
696
+ ("p0.1", "p0.1"),
697
+ ("p1", "p1"),
698
+ ("p5", "p5"),
699
+ ("p25", "p25"),
700
+ ("p50", "p50"),
701
+ ("p75", "p75"),
702
+ ("p95", "p95"),
703
+ ("p99", "p99"),
704
+ ("p99.9", "p99.9"),
705
+ ("Low Clipped (<=0)", "lowclip"),
706
+ ("High Clipped (>=TrueMax)", "highclip"),
707
+ ]
708
+
709
+ tbl.setRowCount(len(row_defs))
710
+ tbl.setColumnCount(len(chans))
711
+ tbl.setHorizontalHeaderLabels(col_names)
712
+ tbl.setVerticalHeaderLabels([r[0] for r in row_defs])
713
+
714
+ # Precompute thresholds for clipping
715
+ eps = 1e-6
716
+ hi_thr = max(eps, float(self.sensor_max01) - eps)
717
+
718
+ def _fmt(x):
719
+ return f"{float(x):.6f}"
720
+
721
+ def _fmt_clip(k, n):
722
+ pct = 100.0 * float(k) / float(max(1, n))
723
+ return f"{int(k)} ({pct:.3f}%)"
724
+
725
+ # Percentiles we want
726
+ pct_list = [0.1, 1, 5, 25, 50, 75, 95, 99, 99.9]
727
+
728
+ # Fill table
729
+ for c_idx, c_arr in enumerate(chans):
730
+ flat = np.asarray(c_arr, dtype=np.float32).ravel()
731
+ if flat.size > 20_000_000:
732
+ idx = np.random.default_rng(0).choice(flat.size, size=5_000_000, replace=False)
733
+ flat = flat[idx]
734
+
735
+ n = int(flat.size) if flat.size else 1
736
+
737
+ # basic moments
738
+ cmin = float(np.min(flat))
739
+ cmax = float(np.max(flat))
740
+ cmean = float(np.mean(flat))
741
+ cmed = float(np.median(flat))
742
+ cstd = float(np.std(flat))
743
+ cvar = float(np.var(flat))
744
+
745
+ # robust
746
+ mad = float(np.median(np.abs(flat - cmed)))
747
+ pcts = np.percentile(flat, pct_list) if flat.size else np.zeros(len(pct_list), dtype=np.float32)
748
+ p25, p75 = float(pcts[3]), float(pcts[5])
749
+ iqr = float(p75 - p25)
750
+
751
+ # clipping
752
+ low_k = int(np.count_nonzero(flat <= eps))
753
+ high_k = int(np.count_nonzero(flat >= hi_thr))
754
+
755
+ values = {
756
+ "min": cmin,
757
+ "max": cmax,
758
+ "mean": cmean,
759
+ "median": cmed,
760
+ "std": cstd,
761
+ "var": cvar,
762
+ "mad": mad,
763
+ "iqr": iqr,
764
+ "p0.1": float(pcts[0]),
765
+ "p1": float(pcts[1]),
766
+ "p5": float(pcts[2]),
767
+ "p25": float(pcts[3]),
768
+ "p50": float(pcts[4]),
769
+ "p75": float(pcts[5]),
770
+ "p95": float(pcts[6]),
771
+ "p99": float(pcts[7]),
772
+ "p99.9": float(pcts[8]),
773
+ "lowclip": _fmt_clip(low_k, n),
774
+ "highclip": _fmt_clip(high_k, n),
775
+ }
776
+
777
+ for r, (_, key) in enumerate(row_defs):
778
+ v = values[key]
779
+ text = v if isinstance(v, str) else _fmt(v)
780
+ it = QTableWidgetItem(text)
781
+ it.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
782
+ tbl.setItem(r, c_idx, it)
783
+
784
+ hdr = tbl.horizontalHeader()
785
+ hdr.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
786
+
787
+ def _copy_as_text():
788
+ # TSV with rows
789
+ lines = []
790
+ lines.append("\t" + "\t".join(col_names))
791
+ for r, (lab, _) in enumerate(row_defs):
792
+ row = [lab]
793
+ for c in range(len(col_names)):
794
+ item = tbl.item(r, c)
795
+ row.append(item.text() if item else "")
796
+ lines.append("\t".join(row))
797
+ QApplication.clipboard().setText("\n".join(lines))
798
+ QMessageBox.information(dlg, self.tr("Copied"), self.tr("Statistics copied to clipboard."))
799
+
800
+ btn_copy.clicked.connect(_copy_as_text)
801
+
802
+ dlg.resize(720, 520)
803
+ dlg.show()
804
+
805
+
634
806
  def _theoretical_native_max_from_meta(self):
635
807
  meta = getattr(self.doc, "metadata", None) or {}
636
808
  bd = str(meta.get("bit_depth", "")).lower()