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
@@ -24,7 +24,7 @@ from setiastro.saspro.legacy.image_manager import load_image, save_image
24
24
  from setiastro.saspro.legacy.numba_utils import bulk_cosmetic_correction_numba
25
25
  from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
26
26
  from setiastro.saspro.star_alignment import PolyGradientRemoval
27
- from pro import minorbodycatalog as mbc
27
+ from setiastro.saspro import minorbodycatalog as mbc
28
28
  from setiastro.saspro.plate_solver import PlateSolverDialog as PlateSolver
29
29
  from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
30
30
 
@@ -202,7 +202,10 @@ class WaveScaleHDRDialogPro(QDialog):
202
202
  self.setWindowFlag(Qt.WindowType.Window, True)
203
203
  self.setWindowModality(Qt.WindowModality.NonModal)
204
204
  self.setModal(False)
205
-
205
+ try:
206
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
207
+ except Exception:
208
+ pass # older PyQt6 versions
206
209
  self._doc = doc
207
210
  base = getattr(doc, "image", None)
208
211
  if base is None:
@@ -204,7 +204,10 @@ class WaveScaleDarkEnhancerDialogPro(QDialog):
204
204
  self.setWindowFlag(Qt.WindowType.Window, True)
205
205
  self.setWindowModality(Qt.WindowModality.NonModal)
206
206
  self.setModal(False)
207
-
207
+ try:
208
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
209
+ except Exception:
210
+ pass # older PyQt6 versions
208
211
  self._doc = doc
209
212
  base = getattr(doc, "image", None)
210
213
  if base is None:
@@ -88,7 +88,7 @@ def plot_star_color_ratios_comparison(raw_pixels: np.ndarray, after_pixels: np.n
88
88
 
89
89
  plt.suptitle("Star Color Ratios with RGB Mapping", fontsize=14)
90
90
  plt.tight_layout()
91
- plt.show()
91
+ plt.show(block=False)
92
92
 
93
93
  def apply_manual_white_balance(img: np.ndarray, r_gain: float, g_gain: float, b_gain: float) -> np.ndarray:
94
94
  """Simple per-channel gain, clipped to [0,1]."""
@@ -184,6 +184,30 @@ def apply_white_balance_to_doc(doc, preset: Optional[Dict] = None):
184
184
  step_name="White Balance",
185
185
  )
186
186
 
187
+ def apply_pivot_gain(img: np.ndarray, med: np.ndarray, gains: np.ndarray) -> np.ndarray:
188
+ # img: HxWx3 float32 in [0,1]
189
+ med3 = med.reshape(1, 1, 3).astype(np.float32)
190
+ g3 = gains.reshape(1, 1, 3).astype(np.float32)
191
+
192
+ # pivot around median; do not scale negative deltas
193
+ d = img - med3
194
+ d = np.maximum(d, 0.0)
195
+ out = d * g3 + med3
196
+ return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
197
+
198
+ def smoothstep(edge0, edge1, x):
199
+ t = np.clip((x - edge0) / (edge1 - edge0 + 1e-12), 0.0, 1.0)
200
+ return t * t * (3.0 - 2.0 * t)
201
+
202
+ def apply_soft_protect(img: np.ndarray, out_pivot: np.ndarray, k: float = 0.02) -> np.ndarray:
203
+ # luminance-based fade-in above median luminance
204
+ L = 0.2126*img[...,0] + 0.7152*img[...,1] + 0.0722*img[...,2]
205
+ Lm = float(np.median(L))
206
+ w = smoothstep(Lm, Lm + k, L).astype(np.float32)
207
+ w3 = w[..., None]
208
+ out = img * (1.0 - w3) + out_pivot * w3
209
+ return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
210
+
187
211
 
188
212
  # -------------------------
189
213
  # Interactive dialog (UI)
@@ -193,16 +217,23 @@ class WhiteBalanceDialog(QDialog):
193
217
  super().__init__(parent)
194
218
  self._main = parent
195
219
  self.doc = doc
196
-
197
- # Connect to active document change signal
220
+ self._active_doc_conn = False
198
221
  if hasattr(self._main, "currentDocumentChanged"):
199
- self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
222
+ try:
223
+ self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
224
+ self._active_doc_conn = True
225
+ except Exception:
226
+ self._active_doc_conn = False
200
227
  if icon:
201
228
  self.setWindowIcon(icon)
202
229
  self.setWindowTitle(self.tr("White Balance"))
203
230
  self.setWindowFlag(Qt.WindowType.Window, True)
204
231
  self.setWindowModality(Qt.WindowModality.NonModal)
205
232
  self.setModal(False)
233
+ try:
234
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
235
+ except Exception:
236
+ pass # older PyQt6 versions
206
237
  self.resize(900, 600)
207
238
 
208
239
  self._build_ui()
@@ -213,6 +244,8 @@ class WhiteBalanceDialog(QDialog):
213
244
  self._update_mode_widgets()
214
245
  # kick off a first detection preview
215
246
  QTimer.singleShot(200, self._update_star_preview)
247
+ self.finished.connect(lambda *_: self._cleanup())
248
+
216
249
 
217
250
  # ---- UI construction ------------------------------------------------
218
251
  def _build_ui(self):
@@ -327,11 +360,16 @@ class WhiteBalanceDialog(QDialog):
327
360
  )
328
361
  self.star_count.setText(self.tr("Detected {0} stars.").format(count))
329
362
  # to pixmap
330
- h, w, _ = overlay.shape
331
- qimg = QImage((overlay * 255).astype(np.uint8).data, w, h, 3 * w, QImage.Format.Format_RGB888)
332
- pm = QPixmap.fromImage(qimg).scaled(self.preview.width(), self.preview.height(),
333
- Qt.AspectRatioMode.KeepAspectRatio,
334
- Qt.TransformationMode.SmoothTransformation)
363
+ overlay8 = np.ascontiguousarray(np.clip(overlay * 255.0, 0, 255).astype(np.uint8))
364
+ h, w, _ = overlay8.shape
365
+ qimg = QImage(overlay8.data, w, h, 3 * w, QImage.Format.Format_RGB888)
366
+
367
+ # Make sure the numpy buffer stays alive until QPixmap is created
368
+ pm = QPixmap.fromImage(qimg.copy()).scaled(
369
+ self.preview.width(), self.preview.height(),
370
+ Qt.AspectRatioMode.KeepAspectRatio,
371
+ Qt.TransformationMode.SmoothTransformation
372
+ )
335
373
  self.preview.setPixmap(pm)
336
374
  except Exception as e:
337
375
  self.star_count.setText(self.tr("Detection failed."))
@@ -394,7 +432,8 @@ class WhiteBalanceDialog(QDialog):
394
432
  # Use the headless helper so doc metadata is consistent
395
433
  apply_white_balance_to_doc(self.doc, preset)
396
434
  # Dialog stays open - refresh document for next operation
397
- self._refresh_document_from_active()
435
+ self._finish_and_close()
436
+ return
398
437
 
399
438
  elif mode == "Auto":
400
439
  preset = {"mode": "auto"}
@@ -404,7 +443,8 @@ class WhiteBalanceDialog(QDialog):
404
443
 
405
444
  apply_white_balance_to_doc(self.doc, preset)
406
445
  # Dialog stays open - refresh document for next operation
407
- self._refresh_document_from_active()
446
+ self._finish_and_close()
447
+ return
408
448
 
409
449
  else: # --- Star-Based: compute here so we can plot like SASv2 ---
410
450
  thr = float(self.thr_slider.value())
@@ -472,7 +512,8 @@ class WhiteBalanceDialog(QDialog):
472
512
  self.tr("Star-Based WB applied.\nDetected {0} stars.").format(int(star_count)),
473
513
  )
474
514
  # Dialog stays open - refresh document for next operation
475
- self._refresh_document_from_active()
515
+ self._finish_and_close()
516
+ return
476
517
 
477
518
  except Exception as e:
478
519
  QMessageBox.critical(self, self.tr("White Balance"), self.tr("Failed to apply White Balance:\n{0}").format(e))
@@ -490,3 +531,34 @@ class WhiteBalanceDialog(QDialog):
490
531
  self.doc = new_doc
491
532
  except Exception:
492
533
  pass
534
+
535
+ def _finish_and_close(self):
536
+ """
537
+ Close this dialog after a successful apply.
538
+ Use accept() so it behaves like a successful completion.
539
+ """
540
+ try:
541
+ self.accept()
542
+ except Exception:
543
+ self.close()
544
+
545
+ def _cleanup(self):
546
+ # Disconnect active-doc signal so the main window doesn't keep us alive
547
+ try:
548
+ if self._active_doc_conn and hasattr(self._main, "currentDocumentChanged"):
549
+ self._main.currentDocumentChanged.disconnect(self._on_active_doc_changed)
550
+ except Exception:
551
+ pass
552
+ self._active_doc_conn = False
553
+
554
+ # Stop debounce timer
555
+ try:
556
+ if getattr(self, "_debounce", None) is not None:
557
+ self._debounce.stop()
558
+ except Exception:
559
+ pass
560
+
561
+
562
+ def closeEvent(self, ev):
563
+ self._cleanup()
564
+ super().closeEvent(ev)
@@ -1,3 +1,4 @@
1
+ #src/setiastro/saspro/widgets/common_utilities.py
1
2
  """
2
3
  Common UI utilities and shared components.
3
4
 
@@ -220,32 +221,38 @@ _strip_ui_decorations = strip_ui_decorations
220
221
  # ---------------------------------------------------------------------------
221
222
 
222
223
  def install_crash_handlers(app: 'QApplication') -> None:
223
- """
224
- Install global crash and exception handlers for the application.
225
-
226
- Sets up:
227
- 1. faulthandler for hard crashes (segfaults) → saspro_crash.log
228
- 2. sys.excepthook for uncaught main thread exceptions
229
- 3. threading.excepthook for uncaught background thread exceptions
230
-
231
- All exceptions are logged and displayed in a dialog to the user.
232
-
233
- Args:
234
- app: The QApplication instance
235
-
236
- Example:
237
- app = QApplication(sys.argv)
238
- install_crash_handlers(app)
239
- """
240
224
  import faulthandler
241
-
242
- # 1) Hard crashes (segfaults, access violations) → saspro_crash.log
225
+ import tempfile
226
+ from pathlib import Path
227
+
228
+ def _get_crash_log_path() -> str:
229
+ try:
230
+ if hasattr(sys, "_MEIPASS"):
231
+ if sys.platform.startswith("win"):
232
+ log_dir = Path(os.path.expandvars("%APPDATA%")) / "SetiAstroSuitePro" / "logs"
233
+ elif sys.platform.startswith("darwin"):
234
+ log_dir = Path.home() / "Library" / "Logs" / "SetiAstroSuitePro"
235
+ else:
236
+ log_dir = Path.home() / ".local" / "share" / "SetiAstroSuitePro" / "logs"
237
+ else:
238
+ # dev fallback
239
+ log_dir = Path("logs")
240
+
241
+ log_dir.mkdir(parents=True, exist_ok=True)
242
+ return str(log_dir / "saspro_crash.log")
243
+ except Exception:
244
+ return str(Path(tempfile.gettempdir()) / "saspro_crash.log")
245
+
246
+ # 1) Hard crashes → saspro_crash.log
243
247
  try:
244
- _crash_log = open("saspro_crash.log", "w", encoding="utf-8", errors="replace")
248
+ crash_path = _get_crash_log_path()
249
+ _crash_log = open(crash_path, "a", encoding="utf-8", errors="replace")
245
250
  faulthandler.enable(file=_crash_log, all_threads=True)
246
251
  atexit.register(_crash_log.close)
252
+ logging.info("Faulthandler crash log: %s", crash_path)
247
253
  except Exception:
248
254
  logging.exception("Failed to enable faulthandler")
255
+
249
256
 
250
257
  def _show_dialog(title: str, head: str, details: str) -> None:
251
258
  """Show error dialog marshaled to main thread."""
@@ -261,7 +268,7 @@ def install_crash_handlers(app: 'QApplication') -> None:
261
268
  m.setStandardButtons(QMessageBox.StandardButton.Ok)
262
269
  m.exec()
263
270
  QTimer.singleShot(0, _ui)
264
-
271
+
265
272
  # 2) Any uncaught exception on the main thread
266
273
  def _excepthook(exc_type, exc_value, exc_tb):
267
274
  tb = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
@@ -5,93 +5,122 @@ import psutil
5
5
  from PyQt6.QtCore import Qt, QUrl, QTimer, QObject, pyqtProperty, pyqtSignal, QThread
6
6
  from PyQt6.QtWidgets import QWidget, QVBoxLayout, QFrame
7
7
  from PyQt6.QtQuickWidgets import QQuickWidget
8
-
8
+ import time
9
+ import subprocess
9
10
  from setiastro.saspro.memory_utils import get_memory_usage_mb
10
11
  from setiastro.saspro.resources import _get_base_path
11
12
 
13
+
12
14
  class GPUWorker(QThread):
13
- """Background worker to monitor GPU without blocking the UI."""
14
15
  resultReady = pyqtSignal(float)
15
16
 
16
17
  def __init__(self, has_nvidia: bool, parent=None):
17
18
  super().__init__(parent)
18
19
  self._has_nvidia = has_nvidia
19
- self._last_val = 0.0
20
+
21
+ # cache + throttle (Windows PowerShell is expensive)
22
+ self._last_win_poll = 0.0
23
+ self._cached_win_val = 0.0
24
+
25
+ self._last_emit = 0.0
26
+ self._last_emitted_val = None
27
+
28
+ def _startupinfo_hidden(self):
29
+ if os.name != "nt":
30
+ return None
31
+ si = subprocess.STARTUPINFO()
32
+ si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
33
+ si.wShowWindow = 0
34
+ return si
20
35
 
21
36
  def _get_windows_gpu_load(self) -> float:
22
- if os.name != 'nt':
37
+ if os.name != "nt":
23
38
  return 0.0
39
+
40
+ now = time.monotonic()
41
+
42
+ # THROTTLE: run this at most once every 1.5 seconds
43
+ if (now - self._last_win_poll) < 1.5:
44
+ return self._cached_win_val
45
+
46
+ self._last_win_poll = now
47
+
24
48
  try:
25
- import subprocess
26
- # Aggregation logic to match Task Manager:
27
- # 1. Group by unique engine (Adapter LUID + Engine Type/Index).
28
- # 2. Sum utilization of all processes sharing that engine.
29
- # 3. Take the Maximum of these sums as the overall GPU load.
30
- # Aggregation logic to match Task Manager:
31
- # 1. Group by unique engine (Adapter LUID + Engine Type/Index).
32
- # 2. Sum utilization of all processes sharing that engine.
33
- # 3. Take the Maximum of these sums as the overall GPU load.
34
- cmd = (
35
- "powershell -NoProfile -ExecutionPolicy Bypass -Command \""
36
- "$groups = Get-CimInstance Win32_PerfFormattedData_GPUPerformanceCounters_GPUEngine -ErrorAction SilentlyContinue | "
37
- "Group-Object -Property { $_.Name -replace '^pid_\\d+_', '' }; "
38
- "$res_list = $groups | ForEach-Object { ($_.Group | Measure-Object -Property UtilizationPercentage -Sum).Sum }; "
39
- "$max_val = ($res_list | Measure-Object -Maximum).Maximum; "
40
- "if ($max_val) { [math]::Round($max_val, 1) } else { 0 }\""
49
+ # Use explicit powershell.exe and make it non-interactive + hidden
50
+ cmd = [
51
+ "powershell.exe",
52
+ "-NoProfile",
53
+ "-NonInteractive",
54
+ "-ExecutionPolicy", "Bypass",
55
+ "-Command",
56
+ (
57
+ "$x = Get-CimInstance Win32_PerfFormattedData_GPUPerformanceCounters_GPUEngine "
58
+ "-ErrorAction SilentlyContinue; "
59
+ "if (-not $x) { 0 } else { "
60
+ " $m = ($x | Measure-Object -Property UtilizationPercentage -Maximum).Maximum; "
61
+ " if ($m) { [math]::Round([double]$m, 1) } else { 0 } "
62
+ "}"
63
+ ),
64
+ ]
65
+
66
+ out = subprocess.check_output(
67
+ cmd,
68
+ startupinfo=self._startupinfo_hidden(),
69
+ timeout=2.0, # IMPORTANT: don’t allow 5s hangs
70
+ stderr=subprocess.DEVNULL, # keep it quiet
41
71
  )
42
- startupinfo = subprocess.STARTUPINFO()
43
- startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
44
- startupinfo.wShowWindow = 0
45
-
46
- out = subprocess.check_output(cmd, startupinfo=startupinfo, timeout=5.0)
47
- val_str = out.decode("utf-8").strip()
48
-
49
- if not val_str: return 0.0
50
- return float(val_str.replace(",", "."))
72
+ val_str = out.decode("utf-8", errors="ignore").strip()
73
+
74
+ val = float(val_str.replace(",", ".")) if val_str else 0.0
75
+ self._cached_win_val = val
76
+ return val
51
77
  except Exception:
52
- return 0.0
78
+ # keep last known value instead of spamming 0.0
79
+ return self._cached_win_val
53
80
 
54
81
  def _get_gpu_load(self) -> float:
55
82
  nv_val = 0.0
56
83
  win_val = 0.0
57
-
58
- # 1. Check NVIDIA (Discrete)
84
+
85
+ # NVIDIA (fast, keep it)
59
86
  if self._has_nvidia:
60
87
  try:
61
- import subprocess
62
- startupinfo = None
63
- if os.name == 'nt':
64
- startupinfo = subprocess.STARTUPINFO()
65
- startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
66
- startupinfo.wShowWindow = 0
67
-
68
88
  out = subprocess.check_output(
69
89
  ["nvidia-smi", "--query-gpu=utilization.gpu", "--format=csv,noheader,nounits"],
70
- startupinfo=startupinfo,
71
- timeout=0.6
90
+ startupinfo=self._startupinfo_hidden(),
91
+ timeout=0.6,
92
+ stderr=subprocess.DEVNULL,
72
93
  )
73
- line = out.decode("utf-8").strip().split('\n')[0]
94
+ line = out.decode("utf-8", errors="ignore").strip().split("\n")[0]
74
95
  nv_val = float(line)
75
96
  except Exception:
76
- pass
77
-
78
- # 2. Check Universal (Integrated)
79
- if os.name == 'nt':
97
+ pass
98
+
99
+ # Windows integrated (slow, throttled)
100
+ if os.name == "nt":
80
101
  win_val = self._get_windows_gpu_load()
81
-
102
+
82
103
  return max(nv_val, win_val)
83
104
 
84
105
  def run(self):
85
106
  while not self.isInterruptionRequested():
86
107
  try:
87
108
  val = self._get_gpu_load()
88
- self.resultReady.emit(val)
89
- # Sleep between measurements. 250ms as requested.
90
- # Note: PowerShell queries might take longer than 250ms,
91
- # but this loop will run as fast as the hardware allows without blocking UI.
109
+
110
+ # Optional: emit only if value changed a bit, or once per 250ms max
111
+ now = time.monotonic()
112
+ if (
113
+ self._last_emitted_val is None
114
+ or abs(val - self._last_emitted_val) >= 1.0
115
+ or (now - self._last_emit) >= 0.5
116
+ ):
117
+ self._last_emit = now
118
+ self._last_emitted_val = val
119
+ self.resultReady.emit(val)
120
+
92
121
  self.msleep(250)
93
122
  except Exception:
94
- self.msleep(1000) # Error backoff on failure
123
+ self.msleep(1000)
95
124
 
96
125
  class ResourceBackend(QObject):
97
126
  """Backend logic for the QML Resource Monitor."""
@@ -197,6 +226,7 @@ class SystemMonitorWidget(QQuickWidget):
197
226
  self.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False)
198
227
  self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
199
228
  self.setClearColor(Qt.GlobalColor.transparent)
229
+ self._qml_push_pending = False
200
230
 
201
231
  # Connect Backend
202
232
  self.backend = ResourceBackend(self)
@@ -218,16 +248,23 @@ class SystemMonitorWidget(QQuickWidget):
218
248
  # Let's re-write the QML loading part to use a safer 'initialProperties' approach or just signal/slots.
219
249
  #
220
250
  # EASIEST: QML binds to `root.cpuUsage`. Python sets `root.cpuUsage`.
221
-
222
- self.backend.cpuChanged.connect(self._push_data_to_qml)
223
- self.backend.ramChanged.connect(self._push_data_to_qml)
224
- self.backend.gpuChanged.connect(self._push_data_to_qml)
225
- self.backend.appRamChanged.connect(self._push_data_to_qml)
251
+
226
252
 
227
253
  # Load QML
228
254
  qml_path = os.path.join(_get_base_path(), "qml", "ResourceMonitor.qml")
229
255
  self.setSource(QUrl.fromLocalFile(qml_path))
230
256
 
257
+ def _schedule_qml_push(self):
258
+ if self._qml_push_pending:
259
+ return
260
+ self._qml_push_pending = True
261
+ QTimer.singleShot(0, self._push_data_to_qml_coalesced)
262
+
263
+ def _push_data_to_qml_coalesced(self):
264
+ self._qml_push_pending = False
265
+ self._push_data_to_qml()
266
+
267
+
231
268
  def _push_data_to_qml(self):
232
269
  root = self.rootObject()
233
270
  if root:
@@ -239,10 +276,23 @@ class SystemMonitorWidget(QQuickWidget):
239
276
  # --- Drag & Drop Support ---
240
277
  def mousePressEvent(self, event):
241
278
  if event.button() == Qt.MouseButton.LeftButton:
279
+ # Wayland-friendly: ask compositor to move the window
280
+ wh = self.windowHandle()
281
+ if wh is not None:
282
+ try:
283
+ # Works best for frameless overlays on Wayland
284
+ wh.startSystemMove()
285
+ event.accept()
286
+ return
287
+ except Exception:
288
+ pass
289
+
290
+ # Fallback (Windows/X11): manual move tracking
242
291
  self._drag_start_pos = event.globalPosition().toPoint() - self.frameGeometry().topLeft()
243
292
  event.accept()
244
- else:
245
- super().mousePressEvent(event)
293
+ return
294
+
295
+ super().mousePressEvent(event)
246
296
 
247
297
  def mouseMoveEvent(self, event):
248
298
  if event.buttons() & Qt.MouseButton.LeftButton:
@@ -37,7 +37,7 @@ class CustomSpinBox(QWidget):
37
37
  self.minimum = minimum
38
38
  self.maximum = maximum
39
39
  self.step = step
40
- self._value = initial
40
+ self._value = int(initial)
41
41
 
42
42
  # Line edit for value display/entry
43
43
  self.lineEdit = QLineEdit(str(initial))
@@ -77,14 +77,13 @@ class CustomSpinBox(QWidget):
77
77
 
78
78
  self._update_button_states()
79
79
 
80
- @property
81
80
  def value(self) -> int:
82
- """Get the current value."""
83
- return self._value
81
+ """Qt-style getter."""
82
+ return int(self._value)
84
83
 
85
84
  def setValue(self, val: int) -> None:
86
- """Set the value, clamping to min/max."""
87
- val = max(self.minimum, min(self.maximum, val))
85
+ val = int(val)
86
+ val = max(int(self.minimum), min(int(self.maximum), val))
88
87
  if val != self._value:
89
88
  self._value = val
90
89
  self.lineEdit.setText(str(val))
@@ -144,7 +143,6 @@ class CustomSpinBox(QWidget):
144
143
  return self._value
145
144
 
146
145
  def _update_button_states(self) -> None:
147
- """Enable/disable buttons at limits."""
148
146
  self.upButton.setEnabled(self._value < self.maximum)
149
147
  self.downButton.setEnabled(self._value > self.minimum)
150
148
 
@@ -175,7 +173,7 @@ class CustomDoubleSpinBox(QWidget):
175
173
  self.maximum = maximum
176
174
  self.step = step
177
175
  self.decimals = decimals
178
- self._value = initial
176
+ self._value = float(initial)
179
177
 
180
178
  # Line edit for value display/entry
181
179
  self.lineEdit = QLineEdit(f"{initial:.{decimals}f}")
@@ -215,14 +213,13 @@ class CustomDoubleSpinBox(QWidget):
215
213
 
216
214
  self._update_button_states()
217
215
 
218
- @property
219
216
  def value(self) -> float:
220
- """Get the current value."""
221
- return self._value
217
+ """Qt-style getter."""
218
+ return float(self._value)
222
219
 
223
220
  def setValue(self, val: float) -> None:
224
- """Set the value, clamping to min/max."""
225
- val = max(self.minimum, min(self.maximum, val))
221
+ val = float(val)
222
+ val = max(float(self.minimum), min(float(self.maximum), val))
226
223
  if abs(val - self._value) > 1e-10:
227
224
  self._value = val
228
225
  self.lineEdit.setText(f"{val:.{self.decimals}f}")