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
@@ -10,11 +10,13 @@ import json
10
10
  import sys
11
11
  import webbrowser
12
12
  from typing import TYPE_CHECKING
13
-
13
+ import re
14
14
  from PyQt6.QtCore import QUrl
15
15
  from PyQt6.QtNetwork import QNetworkRequest, QNetworkReply
16
16
  from PyQt6.QtWidgets import QMessageBox, QApplication
17
17
 
18
+ from PyQt6.QtNetwork import QSslSocket
19
+
18
20
  if TYPE_CHECKING:
19
21
  pass
20
22
 
@@ -44,27 +46,38 @@ class UpdateMixin:
44
46
  return str(v or "0.0.0")
45
47
 
46
48
  def _ensure_network_manager(self):
47
- """Ensure the network access manager exists."""
48
49
  from PyQt6.QtNetwork import QNetworkAccessManager
49
-
50
- if not hasattr(self, "_nam") or self._nam is None:
50
+ if getattr(self, "_nam", None) is None:
51
51
  self._nam = QNetworkAccessManager(self)
52
52
  self._nam.finished.connect(self._on_update_reply)
53
53
 
54
54
  def _kick_update_check(self, *, interactive: bool):
55
55
  """
56
56
  Start an update check request.
57
-
58
- Args:
59
- interactive: If True, show UI feedback for the check
60
57
  """
61
58
  self._ensure_network_manager()
62
59
  url_str = self.settings.value("updates/url", self._updates_url, type=str) or self._updates_url
60
+
61
+ if url_str.lower().startswith("https://"):
62
+ try:
63
+ if not QSslSocket.supportsSsl():
64
+ if self.statusBar():
65
+ self.statusBar().showMessage(self.tr("Update check unavailable (TLS missing)."), 8000)
66
+ if interactive:
67
+ QMessageBox.information(
68
+ self, self.tr("Update Check"),
69
+ self.tr("Update check is unavailable because TLS is not available on this system.")
70
+ )
71
+ else:
72
+ print("[updates] TLS unavailable in Qt; skipping update check.")
73
+ return
74
+ except Exception as e:
75
+ print(f"[updates] TLS probe failed ({e}); skipping update check.")
76
+ return
77
+
63
78
  req = QNetworkRequest(QUrl(url_str))
64
- req.setRawHeader(
65
- b"User-Agent",
66
- f"SASPro/{self._current_version_str}".encode("utf-8")
67
- )
79
+ req.setRawHeader(b"User-Agent", f"SASPro/{self._current_version_str}".encode("utf-8"))
80
+
68
81
  reply = self._nam.get(req)
69
82
  reply.setProperty("interactive", interactive)
70
83
 
@@ -97,20 +110,22 @@ class UpdateMixin:
97
110
  def _on_update_reply(self, reply: QNetworkReply):
98
111
  """Handle network reply from update check or download."""
99
112
  interactive = bool(reply.property("interactive"))
100
-
113
+
101
114
  # Was this the second request (the actual installer download)?
102
115
  if bool(reply.property("is_update_download")):
103
116
  self._on_windows_update_download_finished(reply)
104
117
  return
105
-
118
+
106
119
  try:
107
120
  if reply.error() != QNetworkReply.NetworkError.NoError:
108
121
  err = reply.errorString()
109
122
  if self.statusBar():
110
- self.statusBar().showMessage("Update check failed.", 5000)
123
+ self.statusBar().showMessage(self.tr("Update check failed."), 5000)
111
124
  if interactive:
112
- QMessageBox.warning(self, self.tr("Update Check Failed"),
113
- self.tr("Unable to check for updates.\n\n{err}").replace("{err}", err))
125
+ QMessageBox.warning(
126
+ self, self.tr("Update Check Failed"),
127
+ self.tr("Unable to check for updates.\n\n{0}").format(err)
128
+ )
114
129
  else:
115
130
  print(f"[updates] check failed: {err}")
116
131
  return
@@ -120,12 +135,14 @@ class UpdateMixin:
120
135
  data = json.loads(raw.decode("utf-8"))
121
136
  except Exception as je:
122
137
  if self.statusBar():
123
- self.statusBar().showMessage("Update check failed (bad JSON).", 5000)
138
+ self.statusBar().showMessage(self.tr("Update check failed (bad JSON)."), 5000)
124
139
  if interactive:
125
- QMessageBox.warning(self, self.tr("Update Check Failed"),
126
- self.tr("Update JSON is invalid.\n\n{je}").replace("{je}", str(je)))
140
+ QMessageBox.warning(
141
+ self, self.tr("Update Check Failed"),
142
+ self.tr("Update JSON is invalid.\n\n{0}").format(str(je))
143
+ )
127
144
  else:
128
- print(f"[updates] bad JSON: {je}")
145
+ print(f"[updates] bad JSON: {je!r}")
129
146
  return
130
147
 
131
148
  latest_str = str(data.get("version", "")).strip()
@@ -134,25 +151,53 @@ class UpdateMixin:
134
151
 
135
152
  if not latest_str:
136
153
  if self.statusBar():
137
- self.statusBar().showMessage("Update check failed (no 'version').", 5000)
154
+ self.statusBar().showMessage(self.tr("Update check failed (no version)."), 5000)
138
155
  if interactive:
139
- QMessageBox.warning(self, self.tr("Update Check Failed"),
140
- self.tr("Update JSON missing the 'version' field."))
156
+ QMessageBox.warning(
157
+ self, self.tr("Update Check Failed"),
158
+ self.tr("Update JSON missing the 'version' field.")
159
+ )
141
160
  else:
142
161
  print("[updates] JSON missing 'version'")
143
162
  return
144
163
 
145
- cur_tuple = self._parse_version_tuple(self._current_version_str)
146
- latest_tuple = self._parse_version_tuple(latest_str)
147
- available = bool(latest_tuple and cur_tuple and latest_tuple > cur_tuple)
164
+ # ---- PEP 440 version compare ----
165
+ try:
166
+ from packaging.version import Version
167
+ cur_v = Version(str(self._current_version_str).strip())
168
+ latest_v = Version(latest_str)
169
+ except Exception as e:
170
+ if self.statusBar():
171
+ self.statusBar().showMessage(self.tr("Update check failed (version parse)."), 5000)
172
+ if interactive:
173
+ QMessageBox.warning(
174
+ self, self.tr("Update Check Failed"),
175
+ self.tr("Could not compare versions.\n\nCurrent: {0}\nLatest: {1}\n\n{2}")
176
+ .format(self._current_version_str, latest_str, str(e))
177
+ )
178
+ else:
179
+ print(f"[updates] version parse failed: cur={self._current_version_str!r} latest={latest_str!r} err={e!r}")
180
+ return
181
+
182
+ available = latest_v > cur_v
148
183
 
149
184
  if available:
150
185
  if self.statusBar():
151
186
  self.statusBar().showMessage(self.tr("Update available: {0}").format(latest_str), 5000)
187
+
152
188
  msg_box = QMessageBox(self)
153
189
  msg_box.setIcon(QMessageBox.Icon.Information)
154
190
  msg_box.setWindowTitle(self.tr("Update Available"))
155
- msg_box.setText(self.tr("A new version ({0}) is available!").format(latest_str))
191
+ installed_norm = str(cur_v)
192
+ reported_norm = str(latest_v)
193
+
194
+ msg_box.setText(
195
+ self.tr(
196
+ "An update is available!\n\n"
197
+ "Installed version: {0}\n"
198
+ "Available version: {1}"
199
+ ).format(installed_norm, reported_norm)
200
+ )
156
201
  if notes:
157
202
  msg_box.setInformativeText(self.tr("Release Notes:\n{0}").format(notes))
158
203
  msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
@@ -164,27 +209,48 @@ class UpdateMixin:
164
209
 
165
210
  if msg_box.exec() == QMessageBox.StandardButton.Yes:
166
211
  plat = sys.platform
167
- link = downloads.get(
212
+ key = (
168
213
  "Windows" if plat.startswith("win") else
169
- "macOS" if plat.startswith("darwin") else
170
- "Linux" if plat.startswith("linux") else "", ""
214
+ "macOS" if plat.startswith("darwin") else
215
+ "Linux" if plat.startswith("linux") else
216
+ ""
171
217
  )
218
+ link = downloads.get(key, "")
172
219
  if not link:
173
- QMessageBox.warning(self, self.tr("Download"), self.tr("No download link available for this platform."))
220
+ QMessageBox.warning(self, self.tr("Download"),
221
+ self.tr("No download link available for this platform."))
174
222
  return
175
223
 
176
224
  if plat.startswith("win"):
177
- # Use in-app updater for Windows
178
225
  self._start_windows_update_download(link)
179
226
  else:
180
- # Open browser for other platforms
181
227
  webbrowser.open(link)
182
228
  else:
183
229
  if self.statusBar():
184
- self.statusBar().showMessage("You're up to date.", 3000)
230
+ self.statusBar().showMessage(self.tr("You're up to date."), 3000)
231
+
185
232
  if interactive:
186
- QMessageBox.information(self, self.tr("Up to Date"),
187
- self.tr("You're already running the latest version."))
233
+ # Use the same parsed versions you already computed
234
+ installed_str = str(self._current_version_str).strip()
235
+ reported_str = str(latest_str).strip()
236
+
237
+ # If you have cur_v/latest_v (packaging.Version), use their string forms too
238
+ try:
239
+ installed_norm = str(cur_v) # normalized PEP440 (e.g. 1.6.6.post3)
240
+ reported_norm = str(latest_v)
241
+ except Exception:
242
+ installed_norm = installed_str
243
+ reported_norm = reported_str
244
+
245
+ QMessageBox.information(
246
+ self,
247
+ self.tr("Up to Date"),
248
+ self.tr(
249
+ "You're already running the latest version.\n\n"
250
+ "Installed version: {0}\n"
251
+ "Update source reports: {1}"
252
+ ).format(installed_norm, reported_norm)
253
+ )
188
254
  finally:
189
255
  reply.deleteLater()
190
256
 
@@ -307,3 +373,39 @@ class UpdateMixin:
307
373
 
308
374
  # Close app so the installer can overwrite files
309
375
  QApplication.instance().quit()
376
+
377
+
378
+ def _normalize_version_str(self, v: str) -> str:
379
+ v = (v or "").strip()
380
+ # common cases: "v1.2.3", "Version 1.2.3", "1.2.3 (build xyz)"
381
+ v = re.sub(r'^[^\d]*', '', v) # strip leading non-digits
382
+ v = re.split(r'[\s\(\[]', v, 1)[0].strip() # stop at whitespace/( or [
383
+ return v
384
+
385
+ def _parse_version(self, v: str):
386
+ v = self._normalize_version_str(v)
387
+ if not v:
388
+ return None
389
+ # Prefer packaging if present
390
+ try:
391
+ from packaging.version import Version
392
+ return Version(v)
393
+ except Exception:
394
+ # Fallback: compare numeric dot parts only
395
+ parts = re.findall(r'\d+', v)
396
+ if not parts:
397
+ return None
398
+ # normalize length to 3+ (so 1.2 == 1.2.0)
399
+ nums = [int(x) for x in parts[:6]]
400
+ while len(nums) < 3:
401
+ nums.append(0)
402
+ return tuple(nums)
403
+
404
+ def _is_update_available(self, latest_str: str) -> bool:
405
+ cur = self._parse_version(self._current_version_str)
406
+ latest = self._parse_version(latest_str)
407
+ if cur is None or latest is None:
408
+ # If we cannot compare, do NOT claim "up to date".
409
+ # Treat as "unknown" and show a failure message in interactive mode.
410
+ return False
411
+ return latest > cur
@@ -193,6 +193,7 @@ class ViewMixin:
193
193
  if hasattr(view, "set_scale") and callable(view.set_scale):
194
194
  try:
195
195
  view.set_scale(float(scale))
196
+ self._ensure_smooth_resample(view)
196
197
  self._center_view(view)
197
198
  return
198
199
  except Exception:
@@ -209,6 +210,47 @@ class ViewMixin:
209
210
  except Exception:
210
211
  pass
211
212
 
213
+ def _ensure_smooth_resample(self, view):
214
+ """
215
+ Make sure the view is using smooth interpolation for the current scale.
216
+ Different view widgets in SASpro may implement this differently, so we
217
+ try a few known hooks safely.
218
+ """
219
+ # 1) Best case: explicit API
220
+ for name in ("set_smooth_scaling", "set_interpolation", "set_smooth", "enable_smooth_scaling"):
221
+ fn = getattr(view, name, None)
222
+ if callable(fn):
223
+ try:
224
+ fn(True)
225
+ return
226
+ except Exception:
227
+ pass
228
+
229
+ # 2) Some views store a mode flag
230
+ for attr in ("smooth_scaling", "_smooth_scaling", "_use_smooth_scaling", "use_smooth_scaling"):
231
+ if hasattr(view, attr):
232
+ try:
233
+ setattr(view, attr, True)
234
+ # kick a repaint/update if available
235
+ try:
236
+ view.update()
237
+ except Exception:
238
+ pass
239
+ return
240
+ except Exception:
241
+ pass
242
+
243
+ # 3) QLabel pixmap scaling: if you have a custom "rebuild pixmap" method, call it
244
+ for name in ("_rebuild_pixmap", "_update_pixmap", "_render_scaled", "rebuild_pixmap"):
245
+ fn = getattr(view, name, None)
246
+ if callable(fn):
247
+ try:
248
+ fn()
249
+ return
250
+ except Exception:
251
+ pass
252
+
253
+
212
254
  def _infer_image_size(self, view):
213
255
  """Return (img_w, img_h) in device-independent pixels (ints), best-effort."""
214
256
  # Preferred: from the label's pixmap
@@ -252,6 +252,10 @@ class HaloBGonDialogPro(QDialog):
252
252
  self.setWindowFlag(Qt.WindowType.Window, True)
253
253
  self.setWindowModality(Qt.WindowModality.NonModal)
254
254
  self.setModal(False)
255
+ try:
256
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
257
+ except Exception:
258
+ pass # older PyQt6 versions
255
259
  if icon:
256
260
  try: self.setWindowIcon(icon)
257
261
  except Exception as e:
@@ -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
 
@@ -33,13 +33,17 @@ class HistogramDialog(QDialog):
33
33
  self.setWindowFlag(Qt.WindowType.Window, True)
34
34
  self.setWindowModality(Qt.WindowModality.NonModal)
35
35
  self.setModal(False)
36
+ try:
37
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
38
+ except Exception:
39
+ pass # older PyQt6 versions
36
40
  self.doc = document
37
41
  self.image = _to_float_preserve(document.image)
38
42
 
39
43
  self.zoom_factor = 1.0 # 1.0 = 100%
40
44
  self.log_scale = False # log X
41
45
  self.log_y = False # log Y
42
- self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
46
+ #self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
43
47
  self._eps_log = 1e-6 # first log bin edge (for labels)
44
48
 
45
49
  # for mapping clicks → normalized x
@@ -121,7 +125,12 @@ class HistogramDialog(QDialog):
121
125
 
122
126
  splitter.addWidget(self.scroll_area)
123
127
 
124
- # 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
+
125
134
  self.stats_table = QTableWidget(self)
126
135
  self.stats_table.setRowCount(7)
127
136
  self.stats_table.setColumnCount(1)
@@ -133,15 +142,21 @@ class HistogramDialog(QDialog):
133
142
  # Let it grow/shrink with the splitter
134
143
  self.stats_table.setMinimumWidth(320)
135
144
  self.stats_table.setSizePolicy(
136
- QSizePolicy.Policy.Preferred, # <- was Fixed
145
+ QSizePolicy.Policy.Preferred,
137
146
  QSizePolicy.Policy.Expanding,
138
147
  )
139
148
 
140
- # Make the columns use available width nicely
141
149
  hdr = self.stats_table.horizontalHeader()
142
150
  hdr.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
143
- # hdr.setStretchLastSection(True)
144
- 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)
145
160
 
146
161
  # Give more space to histogram side by default
147
162
  splitter.setStretchFactor(0, 3)
@@ -627,6 +642,167 @@ class HistogramDialog(QDialog):
627
642
 
628
643
  self._adjust_stats_width()
629
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
+
630
806
  def _theoretical_native_max_from_meta(self):
631
807
  meta = getattr(self.doc, "metadata", None) or {}
632
808
  bd = str(meta.get("bit_depth", "")).lower()
@@ -94,6 +94,10 @@ class ImageCombineDialog(QDialog):
94
94
  self.setWindowFlag(Qt.WindowType.Window, True)
95
95
  self.setWindowModality(Qt.WindowModality.NonModal)
96
96
  self.setModal(False)
97
+ try:
98
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
99
+ except Exception:
100
+ pass # older PyQt6 versions
97
101
  self.mw = main_window
98
102
  self.dm = getattr(main_window, "doc_manager", None) or getattr(main_window, "dm", None)
99
103
  self.zoom = 1.0
@@ -1317,6 +1317,10 @@ class ImagePeekerDialogPro(QDialog):
1317
1317
  self.setWindowFlag(Qt.WindowType.Window, True)
1318
1318
  self.setWindowModality(Qt.WindowModality.NonModal)
1319
1319
  self.setModal(False)
1320
+ try:
1321
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
1322
+ except Exception:
1323
+ pass # older PyQt6 versions
1320
1324
  self.document = self._coerce_doc(document) # <- ensure we hold a real doc
1321
1325
  self.settings = settings
1322
1326
  # status / progress line