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.

Files changed (115) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/colorwheel.svg +97 -0
  4. setiastro/images/cosmic.svg +40 -0
  5. setiastro/images/cosmicsat.svg +24 -0
  6. setiastro/images/first_quarter.png +0 -0
  7. setiastro/images/full_moon.png +0 -0
  8. setiastro/images/graxpert.svg +19 -0
  9. setiastro/images/last_quarter.png +0 -0
  10. setiastro/images/linearfit.svg +32 -0
  11. setiastro/images/new_moon.png +0 -0
  12. setiastro/images/pixelmath.svg +42 -0
  13. setiastro/images/waning_crescent_1.png +0 -0
  14. setiastro/images/waning_crescent_2.png +0 -0
  15. setiastro/images/waning_crescent_3.png +0 -0
  16. setiastro/images/waning_crescent_4.png +0 -0
  17. setiastro/images/waning_crescent_5.png +0 -0
  18. setiastro/images/waning_gibbous_1.png +0 -0
  19. setiastro/images/waning_gibbous_2.png +0 -0
  20. setiastro/images/waning_gibbous_3.png +0 -0
  21. setiastro/images/waning_gibbous_4.png +0 -0
  22. setiastro/images/waning_gibbous_5.png +0 -0
  23. setiastro/images/waxing_crescent_1.png +0 -0
  24. setiastro/images/waxing_crescent_2.png +0 -0
  25. setiastro/images/waxing_crescent_3.png +0 -0
  26. setiastro/images/waxing_crescent_4.png +0 -0
  27. setiastro/images/waxing_crescent_5.png +0 -0
  28. setiastro/images/waxing_gibbous_1.png +0 -0
  29. setiastro/images/waxing_gibbous_2.png +0 -0
  30. setiastro/images/waxing_gibbous_3.png +0 -0
  31. setiastro/images/waxing_gibbous_4.png +0 -0
  32. setiastro/images/waxing_gibbous_5.png +0 -0
  33. setiastro/qml/ResourceMonitor.qml +84 -82
  34. setiastro/saspro/__main__.py +20 -1
  35. setiastro/saspro/_generated/build_info.py +2 -2
  36. setiastro/saspro/abe.py +37 -4
  37. setiastro/saspro/aberration_ai.py +237 -21
  38. setiastro/saspro/acv_exporter.py +379 -0
  39. setiastro/saspro/add_stars.py +33 -6
  40. setiastro/saspro/backgroundneutral.py +108 -40
  41. setiastro/saspro/blemish_blaster.py +4 -1
  42. setiastro/saspro/blink_comparator_pro.py +74 -24
  43. setiastro/saspro/clahe.py +4 -1
  44. setiastro/saspro/continuum_subtract.py +4 -1
  45. setiastro/saspro/convo.py +13 -7
  46. setiastro/saspro/cosmicclarity.py +129 -18
  47. setiastro/saspro/crop_dialog_pro.py +123 -7
  48. setiastro/saspro/curve_editor_pro.py +109 -42
  49. setiastro/saspro/doc_manager.py +245 -15
  50. setiastro/saspro/exoplanet_detector.py +120 -28
  51. setiastro/saspro/frequency_separation.py +1158 -204
  52. setiastro/saspro/ghs_dialog_pro.py +81 -16
  53. setiastro/saspro/graxpert.py +1 -0
  54. setiastro/saspro/gui/main_window.py +429 -228
  55. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  56. setiastro/saspro/gui/mixins/menu_mixin.py +27 -1
  57. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  58. setiastro/saspro/gui/mixins/toolbar_mixin.py +384 -18
  59. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  60. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  61. setiastro/saspro/halobgon.py +4 -0
  62. setiastro/saspro/histogram.py +5 -1
  63. setiastro/saspro/image_combine.py +4 -0
  64. setiastro/saspro/image_peeker_pro.py +4 -0
  65. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  66. setiastro/saspro/imageops/stretch.py +582 -62
  67. setiastro/saspro/isophote.py +4 -0
  68. setiastro/saspro/layers.py +13 -9
  69. setiastro/saspro/layers_dock.py +183 -3
  70. setiastro/saspro/legacy/image_manager.py +154 -20
  71. setiastro/saspro/legacy/numba_utils.py +67 -47
  72. setiastro/saspro/legacy/xisf.py +240 -98
  73. setiastro/saspro/live_stacking.py +180 -79
  74. setiastro/saspro/luminancerecombine.py +228 -27
  75. setiastro/saspro/mask_creation.py +174 -15
  76. setiastro/saspro/mfdeconv.py +113 -35
  77. setiastro/saspro/mfdeconvcudnn.py +119 -70
  78. setiastro/saspro/mfdeconvsport.py +112 -35
  79. setiastro/saspro/morphology.py +4 -0
  80. setiastro/saspro/multiscale_decomp.py +51 -12
  81. setiastro/saspro/numba_utils.py +72 -57
  82. setiastro/saspro/ops/commands.py +18 -18
  83. setiastro/saspro/ops/script_editor.py +10 -2
  84. setiastro/saspro/ops/scripts.py +122 -0
  85. setiastro/saspro/perfect_palette_picker.py +37 -3
  86. setiastro/saspro/plate_solver.py +84 -49
  87. setiastro/saspro/psf_viewer.py +119 -37
  88. setiastro/saspro/resources.py +67 -0
  89. setiastro/saspro/rgbalign.py +4 -0
  90. setiastro/saspro/selective_color.py +4 -1
  91. setiastro/saspro/sfcc.py +364 -152
  92. setiastro/saspro/shortcuts.py +160 -29
  93. setiastro/saspro/signature_insert.py +692 -33
  94. setiastro/saspro/stacking_suite.py +1331 -484
  95. setiastro/saspro/star_alignment.py +247 -123
  96. setiastro/saspro/star_spikes.py +4 -0
  97. setiastro/saspro/star_stretch.py +38 -3
  98. setiastro/saspro/stat_stretch.py +743 -128
  99. setiastro/saspro/subwindow.py +786 -360
  100. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  101. setiastro/saspro/wavescale_hdr.py +4 -1
  102. setiastro/saspro/wavescalede.py +4 -1
  103. setiastro/saspro/whitebalance.py +84 -12
  104. setiastro/saspro/widgets/common_utilities.py +28 -21
  105. setiastro/saspro/widgets/resource_monitor.py +109 -59
  106. setiastro/saspro/widgets/spinboxes.py +10 -13
  107. setiastro/saspro/wimi.py +27 -656
  108. setiastro/saspro/wims.py +13 -3
  109. setiastro/saspro/xisf.py +101 -11
  110. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +2 -1
  111. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +115 -82
  112. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
  113. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
  114. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
  115. {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
- 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:
@@ -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) -> 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
- Operates in-place on a copy; returns the neutralized image (float32 [0,1]).
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
- return out
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) first background neutralization (tone-preserving)
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
- # 3) build overlay (autostretched if requested) and draw ellipses
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
- # 4) compute WB scale using star colors sampled on bg_neutral image
172
- # Optimized: vectorized extraction instead of Python loop (10-50x faster)
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
- balanced = (bg_neutral * scaling.reshape((1, 1, 3))).clip(0.0, 1.0)
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
- # 5) second background neutralization pass on balanced image
190
- balanced = _tone_preserve_bg_neutralize(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.astype(np.float32, copy=False),
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.astype(np.float32, copy=False),
178
+ balanced,
206
179
  int(len(star_pixels)),
207
180
  overlay_rgb.astype(np.float32, copy=False),
208
181
  )
209
-
210
-