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.
- setiastro/images/TextureClarity.svg +56 -0
- setiastro/images/abeicon.svg +16 -0
- setiastro/images/acv_icon.png +0 -0
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/cosmic.svg +40 -0
- setiastro/images/cosmicsat.svg +24 -0
- setiastro/images/first_quarter.png +0 -0
- setiastro/images/full_moon.png +0 -0
- setiastro/images/graxpert.svg +19 -0
- setiastro/images/last_quarter.png +0 -0
- setiastro/images/linearfit.svg +32 -0
- setiastro/images/narrowbandnormalization.png +0 -0
- setiastro/images/new_moon.png +0 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/images/waning_crescent_1.png +0 -0
- setiastro/images/waning_crescent_2.png +0 -0
- setiastro/images/waning_crescent_3.png +0 -0
- setiastro/images/waning_crescent_4.png +0 -0
- setiastro/images/waning_crescent_5.png +0 -0
- setiastro/images/waning_gibbous_1.png +0 -0
- setiastro/images/waning_gibbous_2.png +0 -0
- setiastro/images/waning_gibbous_3.png +0 -0
- setiastro/images/waning_gibbous_4.png +0 -0
- setiastro/images/waning_gibbous_5.png +0 -0
- setiastro/images/waxing_crescent_1.png +0 -0
- setiastro/images/waxing_crescent_2.png +0 -0
- setiastro/images/waxing_crescent_3.png +0 -0
- setiastro/images/waxing_crescent_4.png +0 -0
- setiastro/images/waxing_crescent_5.png +0 -0
- setiastro/images/waxing_gibbous_1.png +0 -0
- setiastro/images/waxing_gibbous_2.png +0 -0
- setiastro/images/waxing_gibbous_3.png +0 -0
- setiastro/images/waxing_gibbous_4.png +0 -0
- setiastro/images/waxing_gibbous_5.png +0 -0
- setiastro/qml/ResourceMonitor.qml +84 -82
- setiastro/saspro/__main__.py +20 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/abe.py +37 -4
- setiastro/saspro/aberration_ai.py +364 -33
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/acv_exporter.py +379 -0
- setiastro/saspro/add_stars.py +33 -6
- setiastro/saspro/astrospike_python.py +45 -3
- setiastro/saspro/backgroundneutral.py +108 -40
- setiastro/saspro/blemish_blaster.py +4 -1
- setiastro/saspro/blink_comparator_pro.py +150 -55
- setiastro/saspro/clahe.py +4 -1
- setiastro/saspro/continuum_subtract.py +4 -1
- setiastro/saspro/convo.py +13 -7
- setiastro/saspro/cosmicclarity.py +129 -18
- setiastro/saspro/crop_dialog_pro.py +123 -7
- setiastro/saspro/curve_editor_pro.py +181 -64
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +245 -15
- setiastro/saspro/exoplanet_detector.py +120 -28
- setiastro/saspro/frequency_separation.py +1158 -204
- setiastro/saspro/ghs_dialog_pro.py +81 -16
- setiastro/saspro/graxpert.py +1 -0
- setiastro/saspro/gui/main_window.py +706 -264
- setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
- setiastro/saspro/gui/mixins/file_mixin.py +35 -16
- setiastro/saspro/gui/mixins/menu_mixin.py +35 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +499 -24
- setiastro/saspro/gui/mixins/update_mixin.py +138 -36
- setiastro/saspro/gui/mixins/view_mixin.py +42 -0
- setiastro/saspro/halobgon.py +4 -0
- setiastro/saspro/histogram.py +184 -8
- setiastro/saspro/image_combine.py +4 -0
- setiastro/saspro/image_peeker_pro.py +4 -0
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +1345 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +582 -62
- setiastro/saspro/isophote.py +4 -0
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/image_manager.py +154 -20
- setiastro/saspro/legacy/numba_utils.py +68 -48
- setiastro/saspro/legacy/xisf.py +240 -98
- setiastro/saspro/live_stacking.py +203 -82
- setiastro/saspro/luminancerecombine.py +228 -27
- setiastro/saspro/mask_creation.py +174 -15
- setiastro/saspro/mfdeconv.py +113 -35
- setiastro/saspro/mfdeconvcudnn.py +119 -70
- setiastro/saspro/mfdeconvsport.py +112 -35
- setiastro/saspro/morphology.py +4 -0
- setiastro/saspro/multiscale_decomp.py +81 -29
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/numba_utils.py +72 -57
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/ops/script_editor.py +10 -2
- setiastro/saspro/ops/scripts.py +122 -0
- setiastro/saspro/perfect_palette_picker.py +37 -3
- setiastro/saspro/plate_solver.py +84 -49
- setiastro/saspro/psf_viewer.py +119 -37
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +73 -0
- setiastro/saspro/rgbalign.py +460 -12
- setiastro/saspro/selective_color.py +4 -1
- setiastro/saspro/ser_stack_config.py +82 -0
- setiastro/saspro/ser_stacker.py +2321 -0
- setiastro/saspro/ser_stacker_dialog.py +1838 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1625 -0
- setiastro/saspro/sfcc.py +662 -216
- setiastro/saspro/shortcuts.py +171 -33
- setiastro/saspro/signature_insert.py +692 -33
- setiastro/saspro/stacking_suite.py +1347 -485
- setiastro/saspro/star_alignment.py +247 -123
- setiastro/saspro/star_spikes.py +4 -0
- setiastro/saspro/star_stretch.py +38 -3
- setiastro/saspro/stat_stretch.py +892 -129
- setiastro/saspro/subwindow.py +787 -363
- setiastro/saspro/supernovaasteroidhunter.py +1 -1
- setiastro/saspro/texture_clarity.py +593 -0
- setiastro/saspro/wavescale_hdr.py +4 -1
- setiastro/saspro/wavescalede.py +4 -1
- setiastro/saspro/whitebalance.py +84 -12
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/resource_monitor.py +209 -111
- setiastro/saspro/widgets/spinboxes.py +10 -13
- setiastro/saspro/wimi.py +27 -656
- setiastro/saspro/wims.py +13 -3
- setiastro/saspro/xisf.py +101 -11
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +4 -2
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +132 -87
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
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(
|
|
113
|
-
|
|
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(
|
|
126
|
-
|
|
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
|
|
154
|
+
self.statusBar().showMessage(self.tr("Update check failed (no version)."), 5000)
|
|
138
155
|
if interactive:
|
|
139
|
-
QMessageBox.warning(
|
|
140
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
+
key = (
|
|
168
213
|
"Windows" if plat.startswith("win") else
|
|
169
|
-
"macOS"
|
|
170
|
-
"Linux"
|
|
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"),
|
|
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
|
-
|
|
187
|
-
|
|
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
|
setiastro/saspro/halobgon.py
CHANGED
|
@@ -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:
|
setiastro/saspro/histogram.py
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
144
|
-
|
|
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
|