setiastrosuitepro 1.6.4__py3-none-any.whl → 1.6.12__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/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/new_moon.png +0 -0
- setiastro/images/pixelmath.svg +42 -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 +237 -21
- setiastro/saspro/acv_exporter.py +379 -0
- setiastro/saspro/add_stars.py +33 -6
- setiastro/saspro/backgroundneutral.py +108 -40
- setiastro/saspro/blemish_blaster.py +4 -1
- setiastro/saspro/blink_comparator_pro.py +74 -24
- 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 +109 -42
- 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 +429 -228
- setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
- setiastro/saspro/gui/mixins/menu_mixin.py +27 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +384 -18
- 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 +5 -1
- setiastro/saspro/image_combine.py +4 -0
- setiastro/saspro/image_peeker_pro.py +4 -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 +67 -47
- setiastro/saspro/legacy/xisf.py +240 -98
- setiastro/saspro/live_stacking.py +180 -79
- 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 +51 -12
- 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/resources.py +67 -0
- setiastro/saspro/rgbalign.py +4 -0
- setiastro/saspro/selective_color.py +4 -1
- setiastro/saspro/sfcc.py +364 -152
- setiastro/saspro/shortcuts.py +160 -29
- setiastro/saspro/signature_insert.py +692 -33
- setiastro/saspro/stacking_suite.py +1331 -484
- 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 +743 -128
- setiastro/saspro/subwindow.py +786 -360
- setiastro/saspro/supernovaasteroidhunter.py +1 -1
- 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 +109 -59
- 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.6.12.dist-info}/METADATA +2 -1
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +115 -82
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.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
|
@@ -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
|
|
@@ -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
|
|
@@ -30,10 +30,10 @@ cached_star_sources: Optional[np.ndarray] = None
|
|
|
30
30
|
cached_flux_radii: Optional[np.ndarray] = None
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
def _tone_preserve_bg_neutralize(rgb: np.ndarray
|
|
33
|
+
def _tone_preserve_bg_neutralize(rgb: np.ndarray, *, return_pivot: bool = False):
|
|
34
34
|
"""
|
|
35
35
|
Neutralize background using the darkest grid patch in a tone-preserving way.
|
|
36
|
-
|
|
36
|
+
Returns float32 RGB in [0,1]. If return_pivot, also returns the patch median (pivot).
|
|
37
37
|
"""
|
|
38
38
|
h, w = rgb.shape[:2]
|
|
39
39
|
patch_size = 10
|
|
@@ -56,12 +56,15 @@ def _tone_preserve_bg_neutralize(rgb: np.ndarray) -> np.ndarray:
|
|
|
56
56
|
out = rgb.copy()
|
|
57
57
|
if best is not None:
|
|
58
58
|
avg = float(np.mean(best))
|
|
59
|
-
# “tone-preserving” shift+scale channel-wise toward avg
|
|
60
59
|
for c in range(3):
|
|
61
60
|
diff = float(best[c] - avg)
|
|
62
61
|
denom = (1.0 - diff) if abs(1.0 - diff) > 1e-8 else 1e-8
|
|
63
62
|
out[:, :, c] = np.clip((out[:, :, c] - diff) / denom, 0.0, 1.0)
|
|
64
|
-
|
|
63
|
+
|
|
64
|
+
if return_pivot:
|
|
65
|
+
pivot = best.astype(np.float32) if best is not None else np.median(rgb, axis=(0, 1)).astype(np.float32)
|
|
66
|
+
return out.astype(np.float32, copy=False), pivot
|
|
67
|
+
return out.astype(np.float32, copy=False)
|
|
65
68
|
|
|
66
69
|
|
|
67
70
|
def apply_star_based_white_balance(
|
|
@@ -73,38 +76,16 @@ def apply_star_based_white_balance(
|
|
|
73
76
|
) -> Tuple[np.ndarray, int, np.ndarray, np.ndarray, np.ndarray] | Tuple[np.ndarray, int, np.ndarray]:
|
|
74
77
|
"""
|
|
75
78
|
Star-based white balance with background neutralization and an RGB overlay of detected stars.
|
|
76
|
-
|
|
77
|
-
Parameters
|
|
78
|
-
----------
|
|
79
|
-
image : np.ndarray
|
|
80
|
-
RGB image (any dtype). Assumed RGB ordering.
|
|
81
|
-
threshold : float
|
|
82
|
-
SExtractor detection threshold (in background sigma).
|
|
83
|
-
autostretch : bool
|
|
84
|
-
If True, overlay is built from an autostretched view for visibility.
|
|
85
|
-
reuse_cached_sources : bool
|
|
86
|
-
If True, reuses star positions measured on a previous call (same scene).
|
|
87
|
-
return_star_colors : bool
|
|
88
|
-
If True, also returns (raw_star_pixels, after_star_pixels).
|
|
89
|
-
|
|
90
|
-
Returns
|
|
91
|
-
-------
|
|
92
|
-
balanced_rgb : float32 RGB in [0,1]
|
|
93
|
-
star_count : int
|
|
94
|
-
overlay_rgb : float32 RGB in [0,1] with star ellipses drawn
|
|
95
|
-
(optional) raw_star_pixels : (N,3) float array, colors sampled from ORIGINAL image
|
|
96
|
-
(optional) after_star_pixels : (N,3) float array, colors sampled after WB
|
|
79
|
+
(Correct version: does NOT crush data below the pivot.)
|
|
97
80
|
"""
|
|
98
81
|
if image.ndim != 3 or image.shape[2] != 3:
|
|
99
82
|
raise ValueError("apply_star_based_white_balance: input must be an RGB image (H,W,3).")
|
|
100
83
|
|
|
101
|
-
# 0) normalize
|
|
102
84
|
img_rgb = _to_float01(image)
|
|
103
85
|
|
|
104
|
-
# 1)
|
|
105
|
-
bg_neutral = _tone_preserve_bg_neutralize(img_rgb)
|
|
86
|
+
# 1) background neutralization + pivot (per-channel medians of darkest patch)
|
|
87
|
+
bg_neutral, pivot = _tone_preserve_bg_neutralize(img_rgb, return_pivot=True)
|
|
106
88
|
|
|
107
|
-
# 2) detect / reuse star positions
|
|
108
89
|
if sep is None:
|
|
109
90
|
raise ImportError(
|
|
110
91
|
"apply_star_based_white_balance requires the 'sep' package. "
|
|
@@ -135,7 +116,6 @@ def apply_star_based_white_balance(
|
|
|
135
116
|
cached_star_sources = sources
|
|
136
117
|
cached_flux_radii = r
|
|
137
118
|
|
|
138
|
-
# filter: small-ish, star-like
|
|
139
119
|
mask = (r > 0) & (r <= 10)
|
|
140
120
|
sources = sources[mask]
|
|
141
121
|
r = r[mask]
|
|
@@ -143,15 +123,14 @@ def apply_star_based_white_balance(
|
|
|
143
123
|
raise ValueError("All detected sources were rejected as non-stellar (too large).")
|
|
144
124
|
|
|
145
125
|
h, w = gray.shape
|
|
146
|
-
# raw colors from ORIGINAL image - optimized vectorized extraction
|
|
147
126
|
xs = sources["x"].astype(np.int32)
|
|
148
127
|
ys = sources["y"].astype(np.int32)
|
|
149
128
|
valid = (xs >= 0) & (xs < w) & (ys >= 0) & (ys < h)
|
|
129
|
+
|
|
150
130
|
raw_star_pixels = img_rgb[ys[valid], xs[valid], :]
|
|
151
131
|
|
|
152
|
-
#
|
|
132
|
+
# overlay
|
|
153
133
|
disp = stretch_color_image(bg_neutral.copy(), 0.25) if autostretch else bg_neutral.copy()
|
|
154
|
-
|
|
155
134
|
if cv2 is not None:
|
|
156
135
|
overlay_bgr = cv2.cvtColor((disp * 255).astype(np.uint8), cv2.COLOR_RGB2BGR)
|
|
157
136
|
for i in range(len(sources)):
|
|
@@ -160,41 +139,35 @@ def apply_star_based_white_balance(
|
|
|
160
139
|
theta_deg = float(sources["theta"][i] * 180.0 / np.pi)
|
|
161
140
|
center = (int(round(cx)), int(round(cy)))
|
|
162
141
|
axes = (max(1, int(round(3 * a))), max(1, int(round(3 * b))))
|
|
163
|
-
# red ellipse in BGR
|
|
164
142
|
cv2.ellipse(overlay_bgr, center, axes, angle=theta_deg, startAngle=0, endAngle=360,
|
|
165
143
|
color=(0, 0, 255), thickness=1)
|
|
166
144
|
overlay_rgb = cv2.cvtColor(overlay_bgr, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
|
|
167
145
|
else:
|
|
168
|
-
# fallback: no ellipses, just the display image
|
|
169
146
|
overlay_rgb = disp.astype(np.float32, copy=False)
|
|
170
147
|
|
|
171
|
-
#
|
|
172
|
-
|
|
173
|
-
xs = sources["x"].astype(np.int32)
|
|
174
|
-
ys = sources["y"].astype(np.int32)
|
|
175
|
-
valid_mask = (xs >= 0) & (xs < w) & (ys >= 0) & (ys < h)
|
|
176
|
-
|
|
148
|
+
# star pixels for WB
|
|
149
|
+
valid_mask = valid
|
|
177
150
|
if not np.any(valid_mask):
|
|
178
151
|
raise ValueError("No stellar samples available for white balance.")
|
|
179
|
-
|
|
152
|
+
|
|
180
153
|
star_pixels = bg_neutral[ys[valid_mask], xs[valid_mask], :].astype(np.float32)
|
|
181
154
|
avg_color = np.mean(star_pixels, axis=0)
|
|
182
155
|
max_val = float(np.max(avg_color))
|
|
183
|
-
# protect against divide-by-zero
|
|
184
156
|
avg_color = np.where(avg_color <= 1e-8, 1e-8, avg_color)
|
|
185
|
-
scaling = max_val / avg_color
|
|
157
|
+
scaling = (max_val / avg_color).astype(np.float32) # (3,)
|
|
186
158
|
|
|
187
|
-
|
|
159
|
+
# ✅ Correct median-locked WB (NO hard floor)
|
|
160
|
+
m = pivot.reshape((1, 1, 3)).astype(np.float32)
|
|
161
|
+
g = scaling.reshape((1, 1, 3)).astype(np.float32)
|
|
188
162
|
|
|
189
|
-
|
|
190
|
-
balanced =
|
|
163
|
+
balanced = (bg_neutral.astype(np.float32) - m) * g + m
|
|
164
|
+
balanced = np.clip(balanced, 0.0, 1.0).astype(np.float32, copy=False)
|
|
191
165
|
|
|
192
|
-
# 6) collect after-WB star samples - optimized vectorized extraction
|
|
193
166
|
after_star_pixels = balanced[ys[valid_mask], xs[valid_mask], :]
|
|
194
167
|
|
|
195
168
|
if return_star_colors:
|
|
196
169
|
return (
|
|
197
|
-
balanced
|
|
170
|
+
balanced,
|
|
198
171
|
int(len(star_pixels)),
|
|
199
172
|
overlay_rgb.astype(np.float32, copy=False),
|
|
200
173
|
np.asarray(raw_star_pixels, dtype=np.float32),
|
|
@@ -202,9 +175,7 @@ def apply_star_based_white_balance(
|
|
|
202
175
|
)
|
|
203
176
|
|
|
204
177
|
return (
|
|
205
|
-
balanced
|
|
178
|
+
balanced,
|
|
206
179
|
int(len(star_pixels)),
|
|
207
180
|
overlay_rgb.astype(np.float32, copy=False),
|
|
208
181
|
)
|
|
209
|
-
|
|
210
|
-
|