setiastrosuitepro 1.6.4__py3-none-any.whl → 1.7.1.post2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of setiastrosuitepro might be problematic. Click here for more details.
- setiastro/images/TextureClarity.svg +56 -0
- setiastro/images/abeicon.svg +16 -0
- setiastro/images/acv_icon.png +0 -0
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/cosmic.svg +40 -0
- setiastro/images/cosmicsat.svg +24 -0
- setiastro/images/first_quarter.png +0 -0
- setiastro/images/full_moon.png +0 -0
- setiastro/images/graxpert.svg +19 -0
- setiastro/images/last_quarter.png +0 -0
- setiastro/images/linearfit.svg +32 -0
- setiastro/images/narrowbandnormalization.png +0 -0
- setiastro/images/new_moon.png +0 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/images/waning_crescent_1.png +0 -0
- setiastro/images/waning_crescent_2.png +0 -0
- setiastro/images/waning_crescent_3.png +0 -0
- setiastro/images/waning_crescent_4.png +0 -0
- setiastro/images/waning_crescent_5.png +0 -0
- setiastro/images/waning_gibbous_1.png +0 -0
- setiastro/images/waning_gibbous_2.png +0 -0
- setiastro/images/waning_gibbous_3.png +0 -0
- setiastro/images/waning_gibbous_4.png +0 -0
- setiastro/images/waning_gibbous_5.png +0 -0
- setiastro/images/waxing_crescent_1.png +0 -0
- setiastro/images/waxing_crescent_2.png +0 -0
- setiastro/images/waxing_crescent_3.png +0 -0
- setiastro/images/waxing_crescent_4.png +0 -0
- setiastro/images/waxing_crescent_5.png +0 -0
- setiastro/images/waxing_gibbous_1.png +0 -0
- setiastro/images/waxing_gibbous_2.png +0 -0
- setiastro/images/waxing_gibbous_3.png +0 -0
- setiastro/images/waxing_gibbous_4.png +0 -0
- setiastro/images/waxing_gibbous_5.png +0 -0
- setiastro/qml/ResourceMonitor.qml +84 -82
- setiastro/saspro/__main__.py +20 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/abe.py +37 -4
- setiastro/saspro/aberration_ai.py +364 -33
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/acv_exporter.py +379 -0
- setiastro/saspro/add_stars.py +33 -6
- setiastro/saspro/astrospike_python.py +45 -3
- setiastro/saspro/backgroundneutral.py +108 -40
- setiastro/saspro/blemish_blaster.py +4 -1
- setiastro/saspro/blink_comparator_pro.py +150 -55
- setiastro/saspro/clahe.py +4 -1
- setiastro/saspro/continuum_subtract.py +4 -1
- setiastro/saspro/convo.py +13 -7
- setiastro/saspro/cosmicclarity.py +129 -18
- setiastro/saspro/crop_dialog_pro.py +123 -7
- setiastro/saspro/curve_editor_pro.py +181 -64
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +245 -15
- setiastro/saspro/exoplanet_detector.py +120 -28
- setiastro/saspro/frequency_separation.py +1158 -204
- setiastro/saspro/ghs_dialog_pro.py +81 -16
- setiastro/saspro/graxpert.py +1 -0
- setiastro/saspro/gui/main_window.py +706 -264
- setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
- setiastro/saspro/gui/mixins/file_mixin.py +35 -16
- setiastro/saspro/gui/mixins/menu_mixin.py +35 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +499 -24
- setiastro/saspro/gui/mixins/update_mixin.py +138 -36
- setiastro/saspro/gui/mixins/view_mixin.py +42 -0
- setiastro/saspro/halobgon.py +4 -0
- setiastro/saspro/histogram.py +184 -8
- setiastro/saspro/image_combine.py +4 -0
- setiastro/saspro/image_peeker_pro.py +4 -0
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +1345 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +582 -62
- setiastro/saspro/isophote.py +4 -0
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/image_manager.py +154 -20
- setiastro/saspro/legacy/numba_utils.py +68 -48
- setiastro/saspro/legacy/xisf.py +240 -98
- setiastro/saspro/live_stacking.py +203 -82
- setiastro/saspro/luminancerecombine.py +228 -27
- setiastro/saspro/mask_creation.py +174 -15
- setiastro/saspro/mfdeconv.py +113 -35
- setiastro/saspro/mfdeconvcudnn.py +119 -70
- setiastro/saspro/mfdeconvsport.py +112 -35
- setiastro/saspro/morphology.py +4 -0
- setiastro/saspro/multiscale_decomp.py +81 -29
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/numba_utils.py +72 -57
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/ops/script_editor.py +10 -2
- setiastro/saspro/ops/scripts.py +122 -0
- setiastro/saspro/perfect_palette_picker.py +37 -3
- setiastro/saspro/plate_solver.py +84 -49
- setiastro/saspro/psf_viewer.py +119 -37
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +73 -0
- setiastro/saspro/rgbalign.py +460 -12
- setiastro/saspro/selective_color.py +4 -1
- setiastro/saspro/ser_stack_config.py +82 -0
- setiastro/saspro/ser_stacker.py +2321 -0
- setiastro/saspro/ser_stacker_dialog.py +1838 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1625 -0
- setiastro/saspro/sfcc.py +662 -216
- setiastro/saspro/shortcuts.py +171 -33
- setiastro/saspro/signature_insert.py +692 -33
- setiastro/saspro/stacking_suite.py +1347 -485
- setiastro/saspro/star_alignment.py +247 -123
- setiastro/saspro/star_spikes.py +4 -0
- setiastro/saspro/star_stretch.py +38 -3
- setiastro/saspro/stat_stretch.py +892 -129
- setiastro/saspro/subwindow.py +787 -363
- setiastro/saspro/supernovaasteroidhunter.py +1 -1
- setiastro/saspro/texture_clarity.py +593 -0
- setiastro/saspro/wavescale_hdr.py +4 -1
- setiastro/saspro/wavescalede.py +4 -1
- setiastro/saspro/whitebalance.py +84 -12
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/resource_monitor.py +209 -111
- setiastro/saspro/widgets/spinboxes.py +10 -13
- setiastro/saspro/wimi.py +27 -656
- setiastro/saspro/wims.py +13 -3
- setiastro/saspro/xisf.py +101 -11
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +4 -2
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +132 -87
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/license.txt +0 -0
setiastro/saspro/whitebalance.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 !=
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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=
|
|
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(
|
|
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
|
-
#
|
|
79
|
-
if os.name ==
|
|
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
|
-
|
|
89
|
-
#
|
|
90
|
-
|
|
91
|
-
|
|
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)
|
|
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
|
-
|
|
107
|
-
self.
|
|
108
|
-
self.
|
|
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
|
|
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
|
|
153
|
-
|
|
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
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
245
|
-
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
super().mousePressEvent(event)
|
|
246
344
|
|
|
247
345
|
def mouseMoveEvent(self, event):
|
|
248
346
|
if event.buttons() & Qt.MouseButton.LeftButton:
|