setiastrosuitepro 1.6.4__py3-none-any.whl → 1.7.1.post2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (132) hide show
  1. setiastro/images/TextureClarity.svg +56 -0
  2. setiastro/images/abeicon.svg +16 -0
  3. setiastro/images/acv_icon.png +0 -0
  4. setiastro/images/colorwheel.svg +97 -0
  5. setiastro/images/cosmic.svg +40 -0
  6. setiastro/images/cosmicsat.svg +24 -0
  7. setiastro/images/first_quarter.png +0 -0
  8. setiastro/images/full_moon.png +0 -0
  9. setiastro/images/graxpert.svg +19 -0
  10. setiastro/images/last_quarter.png +0 -0
  11. setiastro/images/linearfit.svg +32 -0
  12. setiastro/images/narrowbandnormalization.png +0 -0
  13. setiastro/images/new_moon.png +0 -0
  14. setiastro/images/pixelmath.svg +42 -0
  15. setiastro/images/planetarystacker.png +0 -0
  16. setiastro/images/waning_crescent_1.png +0 -0
  17. setiastro/images/waning_crescent_2.png +0 -0
  18. setiastro/images/waning_crescent_3.png +0 -0
  19. setiastro/images/waning_crescent_4.png +0 -0
  20. setiastro/images/waning_crescent_5.png +0 -0
  21. setiastro/images/waning_gibbous_1.png +0 -0
  22. setiastro/images/waning_gibbous_2.png +0 -0
  23. setiastro/images/waning_gibbous_3.png +0 -0
  24. setiastro/images/waning_gibbous_4.png +0 -0
  25. setiastro/images/waning_gibbous_5.png +0 -0
  26. setiastro/images/waxing_crescent_1.png +0 -0
  27. setiastro/images/waxing_crescent_2.png +0 -0
  28. setiastro/images/waxing_crescent_3.png +0 -0
  29. setiastro/images/waxing_crescent_4.png +0 -0
  30. setiastro/images/waxing_crescent_5.png +0 -0
  31. setiastro/images/waxing_gibbous_1.png +0 -0
  32. setiastro/images/waxing_gibbous_2.png +0 -0
  33. setiastro/images/waxing_gibbous_3.png +0 -0
  34. setiastro/images/waxing_gibbous_4.png +0 -0
  35. setiastro/images/waxing_gibbous_5.png +0 -0
  36. setiastro/qml/ResourceMonitor.qml +84 -82
  37. setiastro/saspro/__main__.py +20 -1
  38. setiastro/saspro/_generated/build_info.py +2 -2
  39. setiastro/saspro/abe.py +37 -4
  40. setiastro/saspro/aberration_ai.py +364 -33
  41. setiastro/saspro/aberration_ai_preset.py +29 -3
  42. setiastro/saspro/acv_exporter.py +379 -0
  43. setiastro/saspro/add_stars.py +33 -6
  44. setiastro/saspro/astrospike_python.py +45 -3
  45. setiastro/saspro/backgroundneutral.py +108 -40
  46. setiastro/saspro/blemish_blaster.py +4 -1
  47. setiastro/saspro/blink_comparator_pro.py +150 -55
  48. setiastro/saspro/clahe.py +4 -1
  49. setiastro/saspro/continuum_subtract.py +4 -1
  50. setiastro/saspro/convo.py +13 -7
  51. setiastro/saspro/cosmicclarity.py +129 -18
  52. setiastro/saspro/crop_dialog_pro.py +123 -7
  53. setiastro/saspro/curve_editor_pro.py +181 -64
  54. setiastro/saspro/curves_preset.py +249 -47
  55. setiastro/saspro/doc_manager.py +245 -15
  56. setiastro/saspro/exoplanet_detector.py +120 -28
  57. setiastro/saspro/frequency_separation.py +1158 -204
  58. setiastro/saspro/ghs_dialog_pro.py +81 -16
  59. setiastro/saspro/graxpert.py +1 -0
  60. setiastro/saspro/gui/main_window.py +706 -264
  61. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  62. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  63. setiastro/saspro/gui/mixins/menu_mixin.py +35 -1
  64. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  65. setiastro/saspro/gui/mixins/toolbar_mixin.py +499 -24
  66. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  67. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  68. setiastro/saspro/halobgon.py +4 -0
  69. setiastro/saspro/histogram.py +184 -8
  70. setiastro/saspro/image_combine.py +4 -0
  71. setiastro/saspro/image_peeker_pro.py +4 -0
  72. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  73. setiastro/saspro/imageops/serloader.py +1345 -0
  74. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  75. setiastro/saspro/imageops/stretch.py +582 -62
  76. setiastro/saspro/isophote.py +4 -0
  77. setiastro/saspro/layers.py +13 -9
  78. setiastro/saspro/layers_dock.py +183 -3
  79. setiastro/saspro/legacy/image_manager.py +154 -20
  80. setiastro/saspro/legacy/numba_utils.py +68 -48
  81. setiastro/saspro/legacy/xisf.py +240 -98
  82. setiastro/saspro/live_stacking.py +203 -82
  83. setiastro/saspro/luminancerecombine.py +228 -27
  84. setiastro/saspro/mask_creation.py +174 -15
  85. setiastro/saspro/mfdeconv.py +113 -35
  86. setiastro/saspro/mfdeconvcudnn.py +119 -70
  87. setiastro/saspro/mfdeconvsport.py +112 -35
  88. setiastro/saspro/morphology.py +4 -0
  89. setiastro/saspro/multiscale_decomp.py +81 -29
  90. setiastro/saspro/narrowband_normalization.py +1618 -0
  91. setiastro/saspro/numba_utils.py +72 -57
  92. setiastro/saspro/ops/commands.py +18 -18
  93. setiastro/saspro/ops/script_editor.py +10 -2
  94. setiastro/saspro/ops/scripts.py +122 -0
  95. setiastro/saspro/perfect_palette_picker.py +37 -3
  96. setiastro/saspro/plate_solver.py +84 -49
  97. setiastro/saspro/psf_viewer.py +119 -37
  98. setiastro/saspro/remove_green.py +1 -1
  99. setiastro/saspro/resources.py +73 -0
  100. setiastro/saspro/rgbalign.py +460 -12
  101. setiastro/saspro/selective_color.py +4 -1
  102. setiastro/saspro/ser_stack_config.py +82 -0
  103. setiastro/saspro/ser_stacker.py +2321 -0
  104. setiastro/saspro/ser_stacker_dialog.py +1838 -0
  105. setiastro/saspro/ser_tracking.py +206 -0
  106. setiastro/saspro/serviewer.py +1625 -0
  107. setiastro/saspro/sfcc.py +662 -216
  108. setiastro/saspro/shortcuts.py +171 -33
  109. setiastro/saspro/signature_insert.py +692 -33
  110. setiastro/saspro/stacking_suite.py +1347 -485
  111. setiastro/saspro/star_alignment.py +247 -123
  112. setiastro/saspro/star_spikes.py +4 -0
  113. setiastro/saspro/star_stretch.py +38 -3
  114. setiastro/saspro/stat_stretch.py +892 -129
  115. setiastro/saspro/subwindow.py +787 -363
  116. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  117. setiastro/saspro/texture_clarity.py +593 -0
  118. setiastro/saspro/wavescale_hdr.py +4 -1
  119. setiastro/saspro/wavescalede.py +4 -1
  120. setiastro/saspro/whitebalance.py +84 -12
  121. setiastro/saspro/widgets/common_utilities.py +28 -21
  122. setiastro/saspro/widgets/resource_monitor.py +209 -111
  123. setiastro/saspro/widgets/spinboxes.py +10 -13
  124. setiastro/saspro/wimi.py +27 -656
  125. setiastro/saspro/wims.py +13 -3
  126. setiastro/saspro/xisf.py +101 -11
  127. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +4 -2
  128. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +132 -87
  129. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
  130. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
  131. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
  132. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/license.txt +0 -0
@@ -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))
@@ -1,101 +1,134 @@
1
1
  # src/setiastro/saspro/widgets/resource_monitor.py
2
2
  from __future__ import annotations
3
+
3
4
  import os
5
+ import time
6
+ import subprocess
7
+ import numpy as np
8
+
4
9
  import psutil
10
+
5
11
  from PyQt6.QtCore import Qt, QUrl, QTimer, QObject, pyqtProperty, pyqtSignal, QThread
6
- from PyQt6.QtWidgets import QWidget, QVBoxLayout, QFrame
7
12
  from PyQt6.QtQuickWidgets import QQuickWidget
8
13
 
9
14
  from setiastro.saspro.memory_utils import get_memory_usage_mb
10
15
  from setiastro.saspro.resources import _get_base_path
11
16
 
17
+
12
18
  class GPUWorker(QThread):
13
- """Background worker to monitor GPU without blocking the UI."""
14
19
  resultReady = pyqtSignal(float)
15
20
 
16
21
  def __init__(self, has_nvidia: bool, parent=None):
17
22
  super().__init__(parent)
18
23
  self._has_nvidia = has_nvidia
19
- self._last_val = 0.0
24
+
25
+ # cache + throttle (Windows PowerShell is expensive)
26
+ self._last_win_poll = 0.0
27
+ self._cached_win_val = 0.0
28
+
29
+ self._last_emit = 0.0
30
+ self._last_emitted_val = None
31
+
32
+ def _startupinfo_hidden(self):
33
+ if os.name != "nt":
34
+ return None
35
+ si = subprocess.STARTUPINFO()
36
+ si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
37
+ si.wShowWindow = 0
38
+ return si
20
39
 
21
40
  def _get_windows_gpu_load(self) -> float:
22
- if os.name != 'nt':
41
+ if os.name != "nt":
23
42
  return 0.0
43
+
44
+ now = time.monotonic()
45
+
46
+ # THROTTLE: run this at most once every 1.5 seconds
47
+ if (now - self._last_win_poll) < 1.5:
48
+ return self._cached_win_val
49
+
50
+ self._last_win_poll = now
51
+
24
52
  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 }\""
53
+ cmd = [
54
+ "powershell.exe",
55
+ "-NoProfile",
56
+ "-NonInteractive",
57
+ "-ExecutionPolicy", "Bypass",
58
+ "-Command",
59
+ (
60
+ "$x = Get-CimInstance Win32_PerfFormattedData_GPUPerformanceCounters_GPUEngine "
61
+ "-ErrorAction SilentlyContinue; "
62
+ "if (-not $x) { 0 } else { "
63
+ " $m = ($x | Measure-Object -Property UtilizationPercentage -Maximum).Maximum; "
64
+ " if ($m) { [math]::Round([double]$m, 1) } else { 0 } "
65
+ "}"
66
+ ),
67
+ ]
68
+
69
+ out = subprocess.check_output(
70
+ cmd,
71
+ startupinfo=self._startupinfo_hidden(),
72
+ timeout=2.0,
73
+ stderr=subprocess.DEVNULL,
41
74
  )
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(",", "."))
75
+ val_str = out.decode("utf-8", errors="ignore").strip()
76
+
77
+ val = float(val_str.replace(",", ".")) if val_str else 0.0
78
+ self._cached_win_val = val
79
+ return val
51
80
  except Exception:
52
- return 0.0
81
+ # keep last known value instead of spamming 0.0
82
+ return self._cached_win_val
53
83
 
54
84
  def _get_gpu_load(self) -> float:
55
85
  nv_val = 0.0
56
86
  win_val = 0.0
57
-
58
- # 1. Check NVIDIA (Discrete)
87
+
88
+ # NVIDIA (fast)
59
89
  if self._has_nvidia:
60
90
  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
91
  out = subprocess.check_output(
69
92
  ["nvidia-smi", "--query-gpu=utilization.gpu", "--format=csv,noheader,nounits"],
70
- startupinfo=startupinfo,
71
- timeout=0.6
93
+ startupinfo=self._startupinfo_hidden(),
94
+ timeout=0.6,
95
+ stderr=subprocess.DEVNULL,
72
96
  )
73
- line = out.decode("utf-8").strip().split('\n')[0]
97
+ line = out.decode("utf-8", errors="ignore").strip().split("\n")[0]
74
98
  nv_val = float(line)
75
99
  except Exception:
76
- pass
77
-
78
- # 2. Check Universal (Integrated)
79
- if os.name == 'nt':
100
+ pass
101
+
102
+ # Windows integrated (slow, throttled)
103
+ if os.name == "nt":
80
104
  win_val = self._get_windows_gpu_load()
81
-
105
+
82
106
  return max(nv_val, win_val)
83
107
 
84
108
  def run(self):
85
109
  while not self.isInterruptionRequested():
86
110
  try:
87
111
  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.
112
+
113
+ # emit only if changed enough OR periodically
114
+ now = time.monotonic()
115
+ if (
116
+ self._last_emitted_val is None
117
+ or abs(val - self._last_emitted_val) >= 1.0
118
+ or (now - self._last_emit) >= 0.5
119
+ ):
120
+ self._last_emit = now
121
+ self._last_emitted_val = val
122
+ self.resultReady.emit(val)
123
+
92
124
  self.msleep(250)
93
125
  except Exception:
94
- self.msleep(1000) # Error backoff on failure
126
+ self.msleep(1000)
127
+
95
128
 
96
129
  class ResourceBackend(QObject):
97
- """Backend logic for the QML Resource Monitor."""
98
-
130
+ """Backend logic for the QML Resource Monitor (SYSTEM usage, not app usage)."""
131
+
99
132
  cpuChanged = pyqtSignal()
100
133
  ramChanged = pyqtSignal()
101
134
  gpuChanged = pyqtSignal()
@@ -103,12 +136,25 @@ class ResourceBackend(QObject):
103
136
 
104
137
  def __init__(self, parent=None):
105
138
  super().__init__(parent)
106
- self._cpu = 0.0
107
- self._ram = 0.0
108
- self._gpu = 0.0
139
+
140
+ self._cpu = 0.0 # system CPU %
141
+ self._ram = 0.0 # system RAM %
142
+ self._gpu = 0.0 # GPU %
109
143
  self._app_ram_val = 0.0
110
144
  self._app_ram_str = "0 MB"
111
-
145
+
146
+ # ---- Prime psutil CPU baselines (IMPORTANT on Windows) ----
147
+ # First call returns a meaningless 0.0 (or weird) because it establishes the baseline.
148
+ try:
149
+ psutil.cpu_percent(interval=None)
150
+ psutil.cpu_percent(percpu=True, interval=None)
151
+ except Exception:
152
+ pass
153
+
154
+ # Optional smoothing so gauge feels like Task Manager
155
+ self._cpu_ema = None # exponential moving average
156
+ self._last_cpu_times = None
157
+ self._last_cpu_sample_t = 0.0
112
158
  # Check if nvidia-smi is reachable once
113
159
  has_nvidia = False
114
160
  try:
@@ -123,49 +169,101 @@ class ResourceBackend(QObject):
123
169
  self._gpu_worker.resultReady.connect(self._on_gpu_measured)
124
170
  self._gpu_worker.start()
125
171
 
126
- # Timer for CPU/RAM updates (250ms as requested)
172
+ # Timer for CPU/RAM updates (250ms)
127
173
  self._timer = QTimer(self)
128
- self._timer.setInterval(250)
174
+ self._timer.setInterval(250)
129
175
  self._timer.timeout.connect(self._update_stats)
130
176
  self._timer.start()
131
177
 
132
178
  def _on_gpu_measured(self, val: float):
133
- self._gpu = val
179
+ self._gpu = float(val)
134
180
  self.gpuChanged.emit()
135
181
 
136
182
  @pyqtProperty(float, notify=cpuChanged)
137
- def cpuUsage(self):
138
- return self._cpu
183
+ def cpuUsage(self) -> float:
184
+ return float(self._cpu)
139
185
 
140
186
  @pyqtProperty(float, notify=ramChanged)
141
- def ramUsage(self):
142
- return self._ram
187
+ def ramUsage(self) -> float:
188
+ return float(self._ram)
143
189
 
144
190
  @pyqtProperty(float, notify=gpuChanged)
145
- def gpuUsage(self):
146
- return self._gpu
191
+ def gpuUsage(self) -> float:
192
+ return float(self._gpu)
147
193
 
148
194
  @pyqtProperty(str, notify=appRamChanged)
149
- def appRamString(self):
195
+ def appRamString(self) -> str:
150
196
  return self._app_ram_str
151
197
 
152
- def _update_stats(self):
153
- # 1. CPU
198
+ def _read_system_cpu_percent(self) -> float:
199
+ """
200
+ Return SYSTEM-wide CPU utilization as 0..100 using cpu_times() deltas.
201
+ This is robust even if other code calls psutil.cpu_percent().
202
+ """
154
203
  try:
155
- self._cpu = psutil.cpu_percent(interval=None)
204
+ now = time.monotonic()
205
+ cur = psutil.cpu_times(percpu=True)
206
+
207
+ if not cur:
208
+ return 0.0
209
+
210
+ # first sample: store and return 0 (or keep last)
211
+ if self._last_cpu_times is None:
212
+ self._last_cpu_times = cur
213
+ self._last_cpu_sample_t = now
214
+ return float(self._cpu) # keep whatever we had
215
+
216
+ prev = self._last_cpu_times
217
+ self._last_cpu_times = cur
218
+ self._last_cpu_sample_t = now
219
+
220
+ # usage per logical CPU
221
+ usages = []
222
+ for t0, t1 in zip(prev, cur):
223
+ # sum all fields to get total time
224
+ total0 = float(sum(t0))
225
+ total1 = float(sum(t1))
226
+ dt_total = total1 - total0
227
+ if dt_total <= 1e-9:
228
+ continue
229
+
230
+ idle0 = float(getattr(t0, "idle", 0.0) + getattr(t0, "iowait", 0.0))
231
+ idle1 = float(getattr(t1, "idle", 0.0) + getattr(t1, "iowait", 0.0))
232
+ dt_idle = idle1 - idle0
233
+
234
+ busy = 1.0 - (dt_idle / dt_total)
235
+ usages.append(busy)
236
+
237
+ if not usages:
238
+ return float(self._cpu)
239
+
240
+ return float(np.clip((sum(usages) / len(usages)) * 100.0, 0.0, 100.0))
156
241
  except Exception:
157
- self._cpu = 0.0
158
-
159
- # 2. System RAM
242
+ return float(self._cpu)
243
+
244
+
245
+ def _update_stats(self):
246
+ # 1) SYSTEM CPU
247
+ cpu = self._read_system_cpu_percent()
248
+
249
+ # light smoothing (keeps spikes but reduces jitter)
250
+ if self._cpu_ema is None:
251
+ self._cpu_ema = cpu
252
+ else:
253
+ a = 0.25 # smoothing factor (0.0=no update, 1.0=no smoothing)
254
+ self._cpu_ema = (1.0 - a) * self._cpu_ema + a * cpu
255
+ self._cpu = float(self._cpu_ema)
256
+
257
+ # 2) SYSTEM RAM
160
258
  try:
161
259
  vm = psutil.virtual_memory()
162
- self._ram = vm.percent
260
+ self._ram = float(vm.percent)
163
261
  except Exception:
164
262
  self._ram = 0.0
165
263
 
166
- # 3. App RAM
264
+ # 3) APP RAM (your process)
167
265
  try:
168
- mb = get_memory_usage_mb()
266
+ mb = float(get_memory_usage_mb())
169
267
  self._app_ram_val = mb
170
268
  self._app_ram_str = f"{int(mb)} MB"
171
269
  except Exception:
@@ -177,13 +275,22 @@ class ResourceBackend(QObject):
177
275
 
178
276
  def stop(self):
179
277
  """Explicitly stop background threads."""
278
+ try:
279
+ if hasattr(self, "_timer") and self._timer.isActive():
280
+ self._timer.stop()
281
+ except Exception:
282
+ pass
283
+
180
284
  if hasattr(self, "_gpu_worker") and self._gpu_worker.isRunning():
181
285
  self._gpu_worker.requestInterruption()
182
286
  self._gpu_worker.quit()
183
287
  self._gpu_worker.wait(1000)
184
288
 
185
289
  def __del__(self):
186
- self.stop()
290
+ try:
291
+ self.stop()
292
+ except Exception:
293
+ pass
187
294
 
188
295
 
189
296
  class SystemMonitorWidget(QQuickWidget):
@@ -192,7 +299,7 @@ class SystemMonitorWidget(QQuickWidget):
192
299
  """
193
300
  def __init__(self, parent=None):
194
301
  super().__init__(parent)
195
-
302
+
196
303
  self.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView)
197
304
  self.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False)
198
305
  self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
@@ -201,48 +308,39 @@ class SystemMonitorWidget(QQuickWidget):
201
308
  # Connect Backend
202
309
  self.backend = ResourceBackend(self)
203
310
  self.rootContext().setContextProperty("backend", self.backend)
204
-
205
- # We need to manually wire property updates because we are binding to root properties in QML
206
- # Actually, simpler pattern: QML file reads from an object we inject.
207
- # Let's adjust QML slightly to bind to `backend.cpuUsage` etc. if we can,
208
- # OR we leave QML as having properties and we set them from Python.
209
- #
210
- # Better approach for Py+QML:
211
- # Inject `backend` into context, modify QML to use `backend.cpuUsage`.
212
- # But since I already wrote QML with root properties, I will just set them directly
213
- # or update the QML file. Updating QML is cleaner.
214
- #
215
- # For now, let's keep QML independent and binding via setProperty?
216
- # No, properly: context property is best.
217
- #
218
- # Let's re-write the QML loading part to use a safer 'initialProperties' approach or just signal/slots.
219
- #
220
- # 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)
226
-
311
+
227
312
  # Load QML
228
313
  qml_path = os.path.join(_get_base_path(), "qml", "ResourceMonitor.qml")
229
314
  self.setSource(QUrl.fromLocalFile(qml_path))
230
315
 
231
- def _push_data_to_qml(self):
232
- root = self.rootObject()
233
- if root:
234
- root.setProperty("cpuUsage", self.backend.cpuUsage)
235
- root.setProperty("ramUsage", self.backend.ramUsage)
236
- root.setProperty("gpuUsage", self.backend.gpuUsage)
237
- root.setProperty("appRamString", self.backend.appRamString)
316
+ def closeEvent(self, e):
317
+ # make sure worker threads stop when widget closes
318
+ try:
319
+ if hasattr(self, "backend") and self.backend is not None:
320
+ self.backend.stop()
321
+ except Exception:
322
+ pass
323
+ super().closeEvent(e)
238
324
 
239
325
  # --- Drag & Drop Support ---
240
326
  def mousePressEvent(self, event):
241
327
  if event.button() == Qt.MouseButton.LeftButton:
328
+ # Wayland-friendly: ask compositor to move the window
329
+ wh = self.windowHandle()
330
+ if wh is not None:
331
+ try:
332
+ wh.startSystemMove()
333
+ event.accept()
334
+ return
335
+ except Exception:
336
+ pass
337
+
338
+ # Fallback (Windows/X11): manual move tracking
242
339
  self._drag_start_pos = event.globalPosition().toPoint() - self.frameGeometry().topLeft()
243
340
  event.accept()
244
- else:
245
- super().mousePressEvent(event)
341
+ return
342
+
343
+ super().mousePressEvent(event)
246
344
 
247
345
  def mouseMoveEvent(self, event):
248
346
  if event.buttons() & Qt.MouseButton.LeftButton: