From d25f7e4dd73908e02d9fb3b9a2408722328d0df4 Mon Sep 17 00:00:00 2001 From: epalosh Date: Fri, 29 May 2026 14:41:08 -0500 Subject: [PATCH 1/3] v0.2.0: head-tracking feel, game-contention performance, packaging fixes Added - F10 hotkey to toggle inference on/off: disabling stops MediaPipe (frees CPU) and centers the in-game view; re-enabling resumes from the last calibration (no recenter). Rebindable in Settings > Hotkeys. - "Pause preview" button to measure the preview's CPU cost while tracking and the fps/inference readout keep running. - Anti-contention controls: skip preview/pose emits while minimized; cap OpenCV's thread pool (default 2); optional "Reserve CPU cores" affinity mode (off by default, experimental). Changed - Default yaw: soft-center curve at 3x sensitivity; sensitivity sliders now span 0-5x. - Settings > Performance simplified (inference-downscale spinbox removed, driven by the preset; thread cap not surfaced). - Preview shows an explicit "tracking disabled" banner when toggled off instead of "no face detected". Reworded a wizard tip. Fixed - HIGH process priority never applied on 64-bit Windows (untyped ctypes SetPriorityClass mis-marshalled the handle); now set via psutil. - Shutdown crash: the camera-reader's stop Event shadowed threading.Thread._stop, throwing on every exit and skipping cleanup. Packaging (standalone was broken without these; caught by running the exe) - Bundle mediapipe's libmediapipe.dll: dlopen'd at runtime so Nuitka never bundled it -> tracker init failed in the standalone. - Bundle NPClient64.dll + TrackIR.exe: Nuitka's --include-data-dir skips binaries, so the installed app couldn't deliver tracking to iRacing. - bundled_bin_dir(): detect Nuitka (__compiled__) so it resolves /resources/bin instead of over-shooting to dist/. - Bump version to 0.2.0 (pyproject, __init__, build/installer defaults). Validated: 158 tests pass, ruff clean, and a from-scratch local install (uninstall + bare machine + fresh installer) runs end-to-end. --- CHANGELOG.md | 45 +++++ build/nuitka_build.ps1 | 26 ++- installer/build_installer.ps1 | 4 +- installer/openfov.iss | 2 +- pyproject.toml | 2 +- src/openfov/__init__.py | 2 +- src/openfov/__main__.py | 36 +++- src/openfov/output/npclient_bootstrap.py | 10 +- src/openfov/persistence/config.py | 38 ++++ src/openfov/persistence/profiles.py | 11 +- src/openfov/runtime/pipeline.py | 219 +++++++++++++++++++++-- src/openfov/tracker/base.py | 9 + src/openfov/tracker/mediapipe_tracker.py | 8 + src/openfov/ui/axis_panel.py | 4 +- src/openfov/ui/camera_view.py | 49 ++++- src/openfov/ui/main_window.py | 74 +++++++- src/openfov/ui/settings_dialog.py | 97 ++++++---- src/openfov/ui/wizard.py | 2 +- tests/test_p5_fixes.py | 111 ++++++++++++ tests/test_perf_config.py | 42 +++++ tests/test_persistence.py | 25 ++- 21 files changed, 729 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46b0f2c..09ac9d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,51 @@ All notable changes to OpenFOV are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.2.0] — Head-tracking feel + game-contention performance + +### Added +- Global hotkey (default **F10**) to toggle inference on/off entirely. + Disabling stops MediaPipe (frees CPU) and snaps the in-game view to + center; re-enabling resumes from the **last calibration** (no recenter). + Rebindable in Settings → Hotkeys. +- **Pause preview** button — stops the camera-preview rendering (a + diagnostic to measure its CPU cost) while tracking and the fps/inference + readout keep running. +- CPU-contention controls so tracking holds up while a game saturates the + CPU: + - Window-visibility gating — preview frames + pose updates are skipped + while the window is minimized / hidden to tray (tracking + game output + continue). + - OpenCV thread-pool cap (default 2) on the per-frame cvtColor / resize / + decode work. + - Optional **Reserve CPU cores** mode (Settings → Performance) pinning the + process to the top logical CPUs so MediaPipe stays off the game's + cores. Off by default; experimental. + +### Changed +- Default yaw mapping is now the **soft-center curve at 3x sensitivity** + (fine control near forward view, fast swing to the apex). Pitch and roll + keep the gentle linear default. +- Sensitivity sliders now range **0–5x** (was 0–3x). +- Settings → Performance simplified — the raw "inference downscale" spinbox + is gone (driven by the preset), and the OpenCV thread cap is not + surfaced. +- Camera preview shows an explicit **"tracking disabled"** banner when + inference is toggled off, instead of the misleading "no face detected". +- Reworded a setup-wizard tip ("Constant motion can be disorienting!"). + +### Fixed +- Process priority was never actually raised to HIGH on 64-bit Windows — + the `ctypes` `SetPriorityClass` call mis-marshalled the process handle + and failed silently. Now set via `psutil`, so OpenFOV runs at HIGH and + stops losing scheduler contests with the game's render thread. +- Shutdown crash (`'Event' object is not callable`): the camera-reader + thread's stop Event shadowed `threading.Thread._stop`, so every exit + threw and skipped pipeline cleanup. Renamed the attribute. + +### Tests +- **Full suite: 158/158 passing.** + ## [0.1.0] — First public release ### Added diff --git a/build/nuitka_build.ps1 b/build/nuitka_build.ps1 index b4ba849..989506f 100644 --- a/build/nuitka_build.ps1 +++ b/build/nuitka_build.ps1 @@ -17,7 +17,7 @@ Where to write the standalone folder. Defaults to dist/. .PARAMETER Version - Version string baked into the exe metadata. Default 0.1.0.0. + Version string baked into the exe metadata. Default 0.2.0.0. .NOTES On CI (windows-latest), Python 3.12 + Nuitka 2.7+ should already be @@ -30,7 +30,7 @@ [CmdletBinding()] param( [string]$OutDir = "", - [string]$Version = "0.1.0.0", + [string]$Version = "0.2.0.0", [string]$PythonExe = "" ) @@ -123,6 +123,21 @@ Write-Host " Source: $entry" Write-Host " Output: $OutDir" Write-Host "" +# MediaPipe's native runtime (libmediapipe.dll, ~27 MB) is loaded via +# dlopen at runtime, so Nuitka's dependency scanner never sees it and +# --include-package-data skips DLLs. Bundle it explicitly. We resolve the +# path from the active interpreter (differs per machine / CI) rather than +# hardcoding. Without this the standalone imports mediapipe fine but +# throws "Could not find module libmediapipe.dll" the instant it creates +# the FaceLandmarker -- a runtime failure a successful compile won't catch. +$mpDir = (& $PythonExe -c "import mediapipe, os; print(os.path.dirname(mediapipe.__file__))").Trim() +$mpDll = Join-Path $mpDir "tasks\c\libmediapipe.dll" +if (-not (Test-Path $mpDll)) { + throw "libmediapipe.dll not found at '$mpDll' - mediapipe layout changed; the build would be broken." +} +Write-Host " MediaPipe DLL: $mpDll" +Write-Host "" + # Nuitka args. Standalone (not onefile) - faster startup, better AV # reputation, cleaner Inno Setup integration. # @@ -161,6 +176,13 @@ $args = @( "--nofollow-import-to=torch", "--nofollow-import-to=IPython", "--include-data-files=$resources\models\face_landmarker.task=face_landmarker.task", + "--include-data-files=$mpDll=mediapipe/tasks/c/libmediapipe.dll", + # NPClient64.dll + TrackIR.exe are binaries; Nuitka's --include-data-dir + # silently skips .dll/.exe, so include them explicitly or the installed + # app can't deliver tracking to iRacing (registry points at a missing + # DLL). Destination matches bundled_bin_dir() -> /resources/bin. + "--include-data-files=$resources\bin\NPClient64.dll=resources/bin/NPClient64.dll", + "--include-data-files=$resources\bin\TrackIR.exe=resources/bin/TrackIR.exe", "--include-data-dir=$resources=resources", "--windows-console-mode=disable", "--windows-icon-from-ico=$icon", diff --git a/installer/build_installer.ps1 b/installer/build_installer.ps1 index 9e05094..0a0334c 100644 --- a/installer/build_installer.ps1 +++ b/installer/build_installer.ps1 @@ -26,14 +26,14 @@ .PARAMETER Version Version string passed to Inno Setup via /DMyAppVersion. Defaults to - 0.1.0 -- keep in sync with pyproject.toml [project.version]. + 0.2.0 -- keep in sync with pyproject.toml [project.version]. #> [CmdletBinding()] param( [string]$ISCC = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe", [switch]$SkipRedistDownload, - [string]$Version = "0.1.0" + [string]$Version = "0.2.0" ) $ErrorActionPreference = "Stop" diff --git a/installer/openfov.iss b/installer/openfov.iss index dac7cdd..438e5cb 100644 --- a/installer/openfov.iss +++ b/installer/openfov.iss @@ -26,7 +26,7 @@ #ifndef MyAppVersion ; Keep in sync with pyproject.toml [project.version]. CI can override ; with `iscc /DMyAppVersion=1.2.3 installer/openfov.iss`. - #define MyAppVersion "0.1.0" + #define MyAppVersion "0.2.0" #endif [Setup] diff --git a/pyproject.toml b/pyproject.toml index 108b74f..a20eb61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "openfov" -version = "0.1.0" +version = "0.2.0" description = "Webcam head tracking for iRacing and other sim/flight games." readme = "README.md" requires-python = ">=3.11" diff --git a/src/openfov/__init__.py b/src/openfov/__init__.py index 0c90838..a0e4d11 100644 --- a/src/openfov/__init__.py +++ b/src/openfov/__init__.py @@ -1,3 +1,3 @@ """OpenFOV — webcam head tracking for sim/flight games.""" -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/src/openfov/__main__.py b/src/openfov/__main__.py index 1bf6a5a..3eb7144 100644 --- a/src/openfov/__main__.py +++ b/src/openfov/__main__.py @@ -146,13 +146,19 @@ def _run_gui(camera_index: int | None) -> int: # is slightly more CPU heat during sessions — the kernel + audio # services still get priority over us. try: - import ctypes - HIGH_PRIORITY_CLASS = 0x00000080 - handle = ctypes.windll.kernel32.GetCurrentProcess() - ok = ctypes.windll.kernel32.SetPriorityClass(handle, HIGH_PRIORITY_CLASS) - if not ok: - logger.debug("SetPriorityClass(HIGH) returned 0") - except (OSError, AttributeError) as exc: + # Use psutil (already a dependency) instead of raw ctypes. The + # previous ctypes path called SetPriorityClass without setting + # restype/argtypes, so on 64-bit Windows the GetCurrentProcess + # pseudo-handle was marshalled as a 32-bit int and the call + # failed silently ("SetPriorityClass(HIGH) returned 0") — we + # never actually ran at HIGH, which is exactly the priority + # bump meant to win scheduler contests against iRacing. psutil + # sets the priority class correctly and reports failures. + import psutil + + psutil.Process().nice(psutil.HIGH_PRIORITY_CLASS) + logger.debug("Process priority class set to HIGH") + except Exception as exc: logger.debug("Could not raise process priority: %s", exc) app = QApplication(sys.argv) @@ -223,6 +229,8 @@ def _on_always_on_top(on: bool) -> None: camera_width=config.camera_width, camera_height=config.camera_height, inference_max_dim=config.inference_max_dim, + cv_thread_cap=config.inference_thread_cap, + cpu_affinity_mode=config.cpu_affinity_mode, ) window.attach_pipeline(pipeline) @@ -247,6 +255,12 @@ def _on_always_on_top(on: bool) -> None: hotkey_recenter.activated.connect(pipeline.request_recenter, Qt.QueuedConnection) hotkey_recenter.start() + # Toggle inference on/off entirely. The pipeline centers the in-game + # view on each toggle and skips MediaPipe while off. + hotkey_toggle = GlobalHotkey(key=config.hotkey_toggle_tracking) + hotkey_toggle.activated.connect(pipeline.toggle_tracking, Qt.QueuedConnection) + hotkey_toggle.start() + # ---- Menu hookups ---- def _open_settings() -> None: @@ -275,16 +289,23 @@ def _apply(new_cfg) -> None: new_cfg.camera_width != config.camera_width or new_cfg.camera_height != config.camera_height or new_cfg.inference_max_dim != config.inference_max_dim + or new_cfg.inference_thread_cap != config.inference_thread_cap + or new_cfg.cpu_affinity_mode != config.cpu_affinity_mode ) config.camera_width = new_cfg.camera_width config.camera_height = new_cfg.camera_height config.performance_preset = new_cfg.performance_preset config.inference_max_dim = new_cfg.inference_max_dim + config.inference_thread_cap = new_cfg.inference_thread_cap + config.cpu_affinity_mode = new_cfg.cpu_affinity_mode # Live-apply hotkey changes. if new_cfg.hotkey_recenter != config.hotkey_recenter: hotkey_recenter.set_binding(new_cfg.hotkey_recenter) config.hotkey_recenter = new_cfg.hotkey_recenter + if new_cfg.hotkey_toggle_tracking != config.hotkey_toggle_tracking: + hotkey_toggle.set_binding(new_cfg.hotkey_toggle_tracking) + config.hotkey_toggle_tracking = new_cfg.hotkey_toggle_tracking save_app_config(config) if needs_restart: @@ -320,6 +341,7 @@ def _run_wizard_from_menu() -> None: def _shutdown() -> None: watcher.stop() hotkey_recenter.stop() + hotkey_toggle.stop() pipeline.stop() pipeline.wait(2000) # Save current profile + config so the next launch picks up where diff --git a/src/openfov/output/npclient_bootstrap.py b/src/openfov/output/npclient_bootstrap.py index 26deaf5..76529cd 100644 --- a/src/openfov/output/npclient_bootstrap.py +++ b/src/openfov/output/npclient_bootstrap.py @@ -44,8 +44,14 @@ def bundled_bin_dir() -> Path: # Walk up from this file to find the project root, then check the dev # path; if the bundled-resources path next to the exe exists, prefer # that. - if hasattr(sys, "frozen") or getattr(sys, "_MEIPASS", None): - # Nuitka / PyInstaller distribution. + # Detect a packaged build. PyInstaller sets sys.frozen / _MEIPASS; + # Nuitka sets neither but attaches `__compiled__` to every compiled + # module. Without the `__compiled__` check we'd fall through to the dev + # path below, whose parents[3] over-shoots to the dist/ root inside a + # Nuitka bundle — the cause of the "NPClient binaries not found" + # warning. (asset_path in ui/resources.py already detects Nuitka this + # way; this keeps bin resolution consistent with it.) + if hasattr(sys, "frozen") or getattr(sys, "_MEIPASS", None) or "__compiled__" in globals(): exe_dir = Path(sys.argv[0]).resolve().parent candidate = exe_dir / "resources" / "bin" if candidate.exists(): diff --git a/src/openfov/persistence/config.py b/src/openfov/persistence/config.py index 1c5a096..d53cb7f 100644 --- a/src/openfov/persistence/config.py +++ b/src/openfov/persistence/config.py @@ -91,8 +91,30 @@ class AppConfig: performance_preset: str = "performance" inference_max_dim: int | None = 320 + # Anti-contention knobs. These are independent of the performance + # preset (like the resolution/downscale knobs above, but they don't + # participate in preset matching — a user can run any preset with or + # without them). + # + # OpenCV thread-pool cap for per-frame cvtColor/resize/decode. 0 means + # "leave OpenCV's default" (one worker per core); a small cap stops + # those sub-ms ops from waking a thread on every core and fighting the + # game everywhere. Defaults to 2 — plenty for 720p decode + a 320-px + # resize, and keeps the work off the rest of the cores. + inference_thread_cap: int = 2 + # CPU affinity strategy. "auto" lets the OS schedule us freely. + # "isolate" pins the whole OpenFOV process to the top 2 logical CPUs + # so MediaPipe's inference pool can't spread onto the cores iRacing is + # using. Opt-in / experimental: it can backfire on hybrid (P/E-core) + # CPUs where the top logical CPUs are the slower efficiency cores. + cpu_affinity_mode: str = "auto" + # Hotkey bindings (pynput-format keysym strings). hotkey_recenter: str = "" + # Toggle inference on/off entirely (saves CPU when off). On each + # toggle the in-game view is snapped back to center. Defaults to F10, + # next to the F9 recenter key. + hotkey_toggle_tracking: str = "" # Reserved for forward compat — pause functionality is currently # removed from the UI. Keeping the field so old config.toml files # still load cleanly and the binding can be reintroduced later. @@ -113,6 +135,8 @@ def to_dict(self) -> dict[str, object]: "performance": { "preset": self.performance_preset, "inference_max_dim": infer_dim, + "cv_thread_cap": self.inference_thread_cap, + "cpu_affinity": self.cpu_affinity_mode, }, "ui": { "show_wizard_on_next_launch": self.show_wizard_on_next_launch, @@ -121,6 +145,7 @@ def to_dict(self) -> dict[str, object]: "system": {"start_with_windows": self.start_with_windows}, "hotkeys": { "recenter": self.hotkey_recenter, + "toggle_tracking": self.hotkey_toggle_tracking, "pause": self.hotkey_pause, }, } @@ -142,6 +167,14 @@ def from_dict(cls, raw: dict[str, object]) -> AppConfig: ) infer_dim: int | None = infer_raw if infer_raw > 0 else None + # Anti-contention knobs. Negative caps are clamped to 0 (= auto); + # an unrecognized affinity string falls back to "auto" so a typo in + # a hand-edited TOML can't put us in an unknown mode. + cap_raw = int(perf.get("cv_thread_cap", 2)) if isinstance(perf, dict) else 2 + affinity = str(perf.get("cpu_affinity", "auto")) if isinstance(perf, dict) else "auto" + if affinity not in ("auto", "isolate"): + affinity = "auto" + return cls( last_profile=str(raw.get("last_profile", "Default")), camera_index=int(cam.get("index", 0)) if isinstance(cam, dict) else 0, @@ -151,6 +184,8 @@ def from_dict(cls, raw: dict[str, object]) -> AppConfig: str(perf.get("preset", "balanced")) if isinstance(perf, dict) else "balanced" ), inference_max_dim=infer_dim, + inference_thread_cap=max(0, cap_raw), + cpu_affinity_mode=affinity, show_wizard_on_next_launch=( bool(ui.get("show_wizard_on_next_launch", True)) if isinstance(ui, dict) @@ -163,6 +198,9 @@ def from_dict(cls, raw: dict[str, object]) -> AppConfig: bool(sysd.get("start_with_windows", False)) if isinstance(sysd, dict) else False ), hotkey_recenter=str(hk.get("recenter", "")) if isinstance(hk, dict) else "", + hotkey_toggle_tracking=( + str(hk.get("toggle_tracking", "")) if isinstance(hk, dict) else "" + ), hotkey_pause=str(hk.get("pause", "")) if isinstance(hk, dict) else "", ) diff --git a/src/openfov/persistence/profiles.py b/src/openfov/persistence/profiles.py index 4033ed6..c05a1cd 100644 --- a/src/openfov/persistence/profiles.py +++ b/src/openfov/persistence/profiles.py @@ -11,7 +11,7 @@ from openfov.filtering.pipeline import AxisFilterParams from openfov.mapping.axis_mapper import AxisSettings from openfov.mapping.curve import CubicBezierCurve -from openfov.mapping.presets import linear +from openfov.mapping.presets import linear, soft_center from openfov.persistence.paths import profile_path, profiles_dir, sanitize_profile_name _AXES: tuple[str, ...] = ("yaw", "pitch", "roll", "x", "y", "z") @@ -29,14 +29,15 @@ def _default_axis_settings() -> dict[str, AxisSettings]: always want yaw first (looking left/right to apex) and find pitch + roll disorienting before they've adjusted to head-tracking. - Sensitivity defaults to 0.75 across the board — slightly below 1:1 - so small head movements don't over-rotate the in-game camera. Users - can crank it up per-axis once they've found their preferred feel. + Yaw ships with the soft-center curve and 3x sensitivity: fine control + near forward view (shallow slope at zero) with a fast swing to the + apex on bigger head turns. Pitch and roll keep the gentle 0.75 linear + default so they feel tame if a user enables them. Translation axes are structurally disabled until v2 adds proper XYZ support (the AxisMapper short-circuits to 0 when enabled=False).""" return { - "yaw": AxisSettings(invert=True, sensitivity=0.75, curve=linear(domain=90.0), clamp_deg=90.0, enabled=True), + "yaw": AxisSettings(invert=True, sensitivity=3.0, curve=soft_center(domain=90.0), clamp_deg=90.0, enabled=True), "pitch": AxisSettings(invert=True, sensitivity=0.75, curve=linear(domain=90.0), clamp_deg=90.0, enabled=False), "roll": AxisSettings(invert=True, sensitivity=0.75, curve=linear(domain=90.0), clamp_deg=90.0, enabled=False), "x": AxisSettings(invert=True, sensitivity=0.75, curve=linear(domain=200.0), clamp_deg=0.0, enabled=False), diff --git a/src/openfov/runtime/pipeline.py b/src/openfov/runtime/pipeline.py index a2c194e..660fab9 100644 --- a/src/openfov/runtime/pipeline.py +++ b/src/openfov/runtime/pipeline.py @@ -17,6 +17,7 @@ from __future__ import annotations import logging +import os import sys import threading import time @@ -82,6 +83,66 @@ def _bump_thread_priority(thread: threading.Thread) -> None: logger.debug("Could not raise thread priority: %s", exc) +def _isolate_process_to_high_cores(reserved: int = 2) -> None: + """Pin the entire OpenFOV process to the top `reserved` logical CPUs. + + Why the whole process and not just this thread: on Windows a new + thread inherits the *process* affinity mask, not its creator's. The + cost is in MediaPipe's TFLite/XNNPACK worker pool, whose threads we + don't create or control — only a process-level mask reliably keeps + them off the cores iRacing is hammering. The Qt UI is idle during + gameplay, so confining it to the same two cores costs nothing. + + Heuristic + caveats: we reserve the highest-numbered logical CPUs on + the theory that schedulers and games tend to weight lower-numbered + ones. This is imperfect — on Intel hybrid (P/E-core) parts the top + logical CPUs are often the slower efficiency cores, which can make + inference *worse*. That's why this is opt-in. We bail on machines + with too few cores to spare (would just starve us) or with >64 + logical CPUs (those span Windows processor groups, which a single + affinity mask can't address). Best-effort: any failure is logged at + debug and the process keeps its default (unpinned) scheduling.""" + if sys.platform != "win32": + return + n = os.cpu_count() or 0 + if n < 6: + logger.info( + "CPU affinity 'isolate' skipped: %d logical CPUs is too few to " + "spare without starving the tracker; leaving it to the OS.", n, + ) + return + if n > 64: + logger.info( + "CPU affinity 'isolate' skipped: %d logical CPUs span processor " + "groups, which a single mask can't address.", n, + ) + return + try: + import ctypes + from ctypes import wintypes + + mask = 0 + for i in range(n - reserved, n): + mask |= 1 << i + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.GetCurrentProcess.restype = wintypes.HANDLE + SetProcessAffinityMask = kernel32.SetProcessAffinityMask + # DWORD_PTR is pointer-sized (64-bit on x64); c_size_t matches it. + SetProcessAffinityMask.argtypes = [wintypes.HANDLE, ctypes.c_size_t] + SetProcessAffinityMask.restype = wintypes.BOOL + if not SetProcessAffinityMask(kernel32.GetCurrentProcess(), mask): + logger.debug( + "SetProcessAffinityMask failed (err=%d)", ctypes.get_last_error() + ) + else: + logger.info( + "CPU affinity: pinned OpenFOV to logical CPUs %d-%d (mask=0x%x)", + n - reserved, n - 1, mask, + ) + except Exception as exc: + logger.debug("Could not set CPU affinity: %s", exc) + + class _FrameSlot: """Single-slot threadsafe frame buffer with "latest wins" semantics. @@ -150,7 +211,11 @@ def __init__( super().__init__(name="OpenFOV-CameraReader", daemon=True) self._get_camera = get_camera self._slot = slot - self._stop = stop_event + # NB: must NOT be named `_stop` — that shadows threading.Thread._stop, + # an internal method join()/_wait_for_tstate_lock() calls during + # teardown. Shadowing it with an Event made every shutdown raise + # "'Event' object is not callable" and skip the rest of cleanup. + self._stop_event = stop_event # Main thread reads these for hot-plug detection + perf telemetry. # No lock — int reads/writes are atomic in CPython; a stale value # by one frame doesn't matter for either use case. @@ -158,11 +223,11 @@ def __init__( self.last_read_ms = 0.0 def run(self) -> None: - while not self._stop.is_set(): + while not self._stop_event.is_set(): cam = self._get_camera() if cam is None or not cam.is_open: # Wait for the main thread to reopen the device. - self._stop.wait(0.05) + self._stop_event.wait(0.05) continue t0 = time.perf_counter() try: @@ -173,7 +238,7 @@ def run(self) -> None: if not ok or frame is None: self.consecutive_fails += 1 # Tiny back-off so we don't spin if the device is dead. - self._stop.wait(0.005) + self._stop_event.wait(0.005) continue self.consecutive_fails = 0 self.last_read_ms = (time.perf_counter() - t0) * 1000.0 @@ -186,6 +251,9 @@ class PipelineStats: inference_ms: float = 0.0 detected: bool = False camera_connected: bool = True + # False when the user has toggled inference off via the hotkey. The UI + # uses this to show a "tracking disabled" state instead of "searching". + tracking_enabled: bool = True # Per-stage timings (rolling average, ms). Surfaced for the # "where is my time going?" diagnostic question. With pipelining, @@ -245,6 +313,8 @@ def __init__( camera_width: int = 1280, camera_height: int = 720, inference_max_dim: int | None = None, + cv_thread_cap: int = 0, + cpu_affinity_mode: str = "auto", parent=None, ) -> None: super().__init__(parent) @@ -252,6 +322,8 @@ def __init__( self._output = output self._camera = CameraSource(index=camera_index, width=camera_width, height=camera_height) self._inference_max_dim = inference_max_dim + self._cv_thread_cap = cv_thread_cap + self._cpu_affinity_mode = cpu_affinity_mode self._filters = PerAxisFilters() self._mapper = AxisMapper() @@ -261,6 +333,11 @@ def __init__( self._recenter_requested = False self._paused = False self._running = False + # Master inference on/off (hotkey-toggled). When off we skip + # MediaPipe entirely and hold the game view centered. `_prev` + # lets the run loop detect the edge and react once. + self._tracking_enabled = True + self._tracking_enabled_prev = True # Optional game-output profile. None means write to FT_SharedMem # with GameID=0 (game-agnostic). @@ -274,6 +351,18 @@ def __init__( # UI emit throttle state. self._last_ui_emit_at = 0.0 + # When False (window minimized / hidden to tray), we skip the + # frame + pose emits entirely. The output write to iRacing is + # unaffected — tracking keeps running; we just stop shipping + # 2.8 MB preview frames and pose updates to a UI no one can see. + # Plain bool; reads/writes are atomic in CPython so no lock is + # needed (same convention as _paused). + self._ui_active = True + # Narrower gate for *just* the camera preview frame (the expensive + # emit). The "Pause preview" button toggles this while leaving + # pose_ready flowing, so the fps/inference readout keeps updating + # and the user can watch the preview's CPU cost in real time. + self._preview_active = True # -- thread-safe control surface ---------------------------------- @@ -294,6 +383,34 @@ def set_neutral(self, pose: Pose6DOF) -> None: def set_paused(self, paused: bool) -> None: self._paused = paused + def set_tracking_enabled(self, enabled: bool) -> None: + """Enable/disable inference entirely. When disabled the pipeline + stops running MediaPipe (the CPU win) and holds the in-game view + centered; the run loop reacts to the edge. Thread-safe (atomic + bool).""" + self._tracking_enabled = enabled + + def toggle_tracking(self) -> None: + """Flip inference on/off — the target of the global toggle hotkey.""" + self._tracking_enabled = not self._tracking_enabled + + def set_ui_active(self, active: bool) -> None: + """Tell the pipeline whether the UI is visible. When inactive + (window minimized or hidden to tray) the pipeline stops emitting + preview frames + pose updates, which removes the cross-thread + hand-off of 2.8 MB frames and the consumer-side cvtColor/scale/ + paint — exactly the UI load we don't want competing with the game. + Tracking + output to iRacing continue regardless.""" + self._ui_active = active + + def set_preview_active(self, active: bool) -> None: + """Toggle only the camera *preview* emit (frame_ready). Unlike + set_ui_active, this leaves pose_ready (the fps/inference readout + and pose widgets) flowing — so the user can pause the expensive + preview rendering and watch the numbers respond live. Tracking + + output to the game are unaffected.""" + self._preview_active = active + def stop(self) -> None: self._running = False @@ -338,9 +455,18 @@ def run(self) -> None: f"Camera {self._camera.index} not available - will retry", ) + # Pin the process before starting the tracker so MediaPipe's + # TFLite/XNNPACK worker pool (created inside tracker.start) inherits + # the affinity mask. No-op unless the user opted into "isolate". + if self._cpu_affinity_mode == "isolate": + _isolate_process_to_high_cores() + try: self._tracker.start( - TrackerSettings(max_inference_dim=self._inference_max_dim) + TrackerSettings( + max_inference_dim=self._inference_max_dim, + cv_thread_cap=self._cv_thread_cap, + ) ) except Exception as exc: self._camera.close() @@ -438,17 +564,20 @@ def run(self) -> None: self._MAX_RETRY_INTERVAL_S, ) # Still no camera — emit an empty stats frame so the UI - # status bar updates, then idle briefly. - self.pose_ready.emit( - Pose6DOF(), - Pose6DOF(), - PipelineStats( - fps=0.0, - inference_ms=last_inference_ms, - detected=False, - camera_connected=False, - ), - ) + # status bar updates, then idle briefly. Skip the emit + # when the UI is hidden (nothing to update); on restore + # the next pass refreshes status within ~80 ms. + if self._ui_active: + self.pose_ready.emit( + Pose6DOF(), + Pose6DOF(), + PipelineStats( + fps=0.0, + inference_ms=last_inference_ms, + detected=False, + camera_connected=False, + ), + ) QThread.msleep(80) continue @@ -482,6 +611,50 @@ def run(self) -> None: # slot soon. continue + # ---- inference enable/disable (hotkey) ----------------- + if self._tracking_enabled != self._tracking_enabled_prev: + self._tracking_enabled_prev = self._tracking_enabled + # Reset only the smoothing history so the first frame + # after a toggle doesn't see a huge dt. The neutral + # (calibration) is deliberately left intact — re-enabling + # resumes from exactly where the last calibration put + # zero, rather than recentering on the current pose. + # (F9 / the Calibrate button is still there to recenter.) + self._filters.reset() + if self._tracking_enabled: + logger.info( + "Inference enabled via hotkey; resuming from the " + "last calibration (no recenter)" + ) + else: + logger.info( + "Inference disabled via hotkey; in-game view centered" + ) + + if not self._tracking_enabled: + # Inference off — skip MediaPipe entirely (the CPU win) + # and hold the in-game view centered by writing zeros + # every frame (keeps FreeTrack's data fresh so iRacing + # doesn't treat it as a dropped signal). Preview shows + # the live camera without landmarks so the user can + # re-frame before resuming. + self._output.write(Pose6DOF()) + now = time.monotonic() + if self._ui_active and (now - self._last_ui_emit_at) >= self._UI_EMIT_INTERVAL_S: + self._last_ui_emit_at = now + if self._preview_active: + self.frame_ready.emit(frame, None) + self.pose_ready.emit( + Pose6DOF(), + Pose6DOF(), + PipelineStats( + detected=False, + camera_connected=True, + tracking_enabled=False, + ), + ) + continue + ts_ms = int((time.monotonic() - t0) * 1000) try: result = self._tracker.step(frame, ts_ms) @@ -585,9 +758,19 @@ def run(self) -> None: # tracker frame; the UI only needs ~30 Hz, and emitting at # full inference rate (50-80 Hz) backs up the Qt event # queue with 2.8 MB BGR frames the painter can't drain. - if (now - self._last_ui_emit_at) >= self._UI_EMIT_INTERVAL_S: + # Skip entirely when the UI is hidden — that's the whole + # point during gameplay: don't pay the cross-thread frame + # hand-off + consumer-side paint for a window no one sees. + if self._ui_active and (now - self._last_ui_emit_at) >= self._UI_EMIT_INTERVAL_S: self._last_ui_emit_at = now - self.frame_ready.emit(frame, result.landmarks_2d) + # The camera preview frame is the expensive emit (2.8 MB + # cross-thread + a cvtColor/scale/paint on the UI side). + # The "Pause preview" button drops just that via + # _preview_active, while pose_ready (status fps + pose + # widgets) keeps flowing so the user can watch the cost + # change in real time. + if self._preview_active: + self.frame_ready.emit(frame, result.landmarks_2d) self.pose_ready.emit(raw_pose, mapped_pose, stats) except Exception as exc: logger.exception("Pipeline thread crashed") diff --git a/src/openfov/tracker/base.py b/src/openfov/tracker/base.py index d73b493..86777c9 100644 --- a/src/openfov/tracker/base.py +++ b/src/openfov/tracker/base.py @@ -65,6 +65,15 @@ class TrackerSettings: # resolution." max_inference_dim: int | None = None + # Cap on OpenCV's internal thread pool (cv2.setNumThreads). The + # per-frame cvtColor + resize (and MJPG decode in the camera reader) + # are sub-millisecond on small frames, but OpenCV defaults to one + # worker per core and will wake a thread on every core for that tiny + # work — contending with the game across all of them. A small cap + # keeps that off the game's cores. 0 leaves OpenCV's default + # untouched (used by the wizard + CI, where contention is moot). + cv_thread_cap: int = 0 + class Tracker(ABC): """Abstract tracker. Concrete subclasses must be safe to use from the diff --git a/src/openfov/tracker/mediapipe_tracker.py b/src/openfov/tracker/mediapipe_tracker.py index 0268af1..b6a042a 100644 --- a/src/openfov/tracker/mediapipe_tracker.py +++ b/src/openfov/tracker/mediapipe_tracker.py @@ -99,6 +99,14 @@ def __init__(self) -> None: def start(self, settings: TrackerSettings) -> None: if self._landmarker is not None: return + # Cap OpenCV's thread pool before any cvtColor/resize runs. This is + # process-global; we set it here (rather than once at import) so the + # value tracks the user's setting and the wizard/CI paths that pass + # the default (0) leave OpenCV alone. MediaPipe's own TFLite/XNNPACK + # pool isn't reachable from the Tasks API — CPU affinity (set by the + # pipeline) is what bounds that one. + if settings.cv_thread_cap > 0: + cv2.setNumThreads(settings.cv_thread_cap) model_path = Path(settings.model_path) if settings.model_path else _default_model_path() if not model_path.exists(): # The model is bundled by the installer. If a user sees this diff --git a/src/openfov/ui/axis_panel.py b/src/openfov/ui/axis_panel.py index a5eac83..7a6b456 100644 --- a/src/openfov/ui/axis_panel.py +++ b/src/openfov/ui/axis_panel.py @@ -29,7 +29,7 @@ from openfov.ui.curve_editor import PRESETS, CurveEditor from openfov.ui.widgets import reset_button -_SENS_SCALE = 100 # slider integer 0..300 -> 0.00..3.00 +_SENS_SCALE = 100 # slider integer 0..500 -> 0.00..5.00 _SENS_DEFAULT = 1.0 @@ -112,7 +112,7 @@ def __init__(self, axis_name: str, label: str, parent=None) -> None: sens_label_widget.setMinimumWidth(78) self._sens = QSlider(Qt.Horizontal) self._sens.setMinimum(0) - self._sens.setMaximum(3 * _SENS_SCALE) + self._sens.setMaximum(5 * _SENS_SCALE) self._sens.setValue(int(self._settings.sensitivity * _SENS_SCALE)) self._sens.setSingleStep(1) self._sens.setPageStep(10) diff --git a/src/openfov/ui/camera_view.py b/src/openfov/ui/camera_view.py index 3886b91..d4112eb 100644 --- a/src/openfov/ui/camera_view.py +++ b/src/openfov/ui/camera_view.py @@ -31,6 +31,10 @@ def __init__(self, parent=None) -> None: self._frame: np.ndarray | None = None self._landmarks: np.ndarray | None = None self._detected: bool = False + # True when inference is toggled off via the hotkey. Shown as an + # explicit "tracking disabled" banner so the frozen-but-live preview + # isn't mistaken for a detection failure ("no face detected"). + self._tracking_disabled: bool = False self._last_pixmap_size: tuple[int, int] = (0, 0) @Slot(object, object) @@ -40,6 +44,16 @@ def update_frame(self, frame_bgr: np.ndarray, landmarks_2d: np.ndarray | None) - self._detected = landmarks_2d is not None and len(landmarks_2d) > 0 self._render() + def set_tracking_disabled(self, disabled: bool) -> None: + """Flag the hotkey-disabled state so the preview shows a clear + 'tracking disabled' banner instead of the misleading 'no face + detected'. Re-renders only on change to avoid per-frame churn.""" + if disabled == self._tracking_disabled: + return + self._tracking_disabled = disabled + if self._frame is not None: + self._render() + def _render(self) -> None: if self._frame is None: return @@ -78,8 +92,27 @@ def _render(self) -> None: finally: painter.end() - # Status banner overlay if not detected. - if not self._detected: + # Status banner overlay. The hotkey-disabled state is an + # intentional pause, so it takes priority over "no face detected" + # (which would otherwise mislead the user into thinking detection + # failed) and is centered + tinted differently to read as a + # deliberate state rather than an error. + if self._tracking_disabled: + painter = QPainter(pix) + try: + painter.setPen(QColor(120, 180, 255)) + font = painter.font() + font.setPointSize(13) + font.setBold(True) + painter.setFont(font) + painter.drawText( + pix.rect(), + Qt.AlignCenter, + "tracking disabled — toggle hotkey to resume", + ) + finally: + painter.end() + elif not self._detected: painter = QPainter(pix) try: painter.setPen(QColor(240, 200, 60)) @@ -97,6 +130,18 @@ def _render(self) -> None: self.setPixmap(pix) + def show_idle(self, message: str) -> None: + """Drop the current frame and show a centered message instead. + + Used when the preview is intentionally paused. Setting text on a + QLabel clears any pixmap; nulling `_frame` keeps resizeEvent from + repainting the stale frame. The next `update_frame` (on resume) + replaces the text with live video again.""" + self._frame = None + self._landmarks = None + self._detected = False + self.setText(message) + def resizeEvent(self, event) -> None: # Re-render so the pixmap matches the new size. super().resizeEvent(event) diff --git a/src/openfov/ui/main_window.py b/src/openfov/ui/main_window.py index 49df2d9..4b01ebd 100644 --- a/src/openfov/ui/main_window.py +++ b/src/openfov/ui/main_window.py @@ -11,7 +11,7 @@ import logging from typing import TYPE_CHECKING -from PySide6.QtCore import Qt, Signal, Slot +from PySide6.QtCore import QEvent, Qt, Signal, Slot from PySide6.QtGui import QAction, QCloseEvent from PySide6.QtWidgets import ( QComboBox, @@ -78,6 +78,9 @@ def __init__( self._profile = initial_profile self._allow_close = False self._always_on_top_initial = always_on_top + # Manual "Pause preview" toggle (a diagnostic to measure the + # preview's CPU cost). Independent of window visibility gating. + self._preview_paused = False # ------------------------------------------------------------------ # Profile bar — moves *under* the camera preview so the profile @@ -103,12 +106,26 @@ def __init__( ) self._btn_calibrate.clicked.connect(self.request_recenter.emit) + # Diagnostic toggle: stop repainting the preview to see what the + # camera view costs the tracking loop. Tracking + game output keep + # running and the fps/inference readout keeps updating, so the + # effect is visible immediately in the status bar. + self._btn_pause_preview = QPushButton("Pause preview") + self._btn_pause_preview.setCheckable(True) + self._btn_pause_preview.setToolTip( + "Stop repainting the camera preview to measure its CPU cost.\n" + "Tracking and output to your game keep running; watch the fps /\n" + "inference readout in the status bar respond." + ) + self._btn_pause_preview.toggled.connect(self._on_pause_preview) + camera_row = QHBoxLayout() camera_row.setContentsMargins(0, 0, 0, 0) camera_row.addWidget(QLabel("Camera:")) camera_row.addWidget(self._camera_combo, 1) camera_row.addSpacing(10) camera_row.addWidget(self._btn_calibrate) + camera_row.addWidget(self._btn_pause_preview) # ------------------------------------------------------------------ # Left column: camera dropdown -> preview -> profile bar @@ -299,6 +316,11 @@ def attach_pipeline(self, pipeline: PipelineThread) -> None: # Reapply current settings now that the pipeline exists. self._push_profile_to_pipeline(pipeline) self._pipeline = pipeline # type: ignore[attr-defined] + # Seed the pipeline's UI-active state to match our current + # visibility. We're not shown yet at attach time, so this starts + # False; the showEvent fired by window.show() flips it True. + self._sync_pipeline_ui_active() + pipeline.set_preview_active(not self._preview_paused) # ------------------------------------------------------------------ # Profile sync @@ -359,6 +381,10 @@ def _on_filter_changed(self, axis: str, params: AxisFilterParams) -> None: def _on_pose( self, raw: Pose6DOF, mapped: Pose6DOF, stats: PipelineStats ) -> None: + # Keep the preview's banner in sync with the hotkey on/off state so + # a disabled, still-live preview doesn't read as "no face detected". + self._camera_view.set_tracking_disabled(not stats.tracking_enabled) + # Push the live dot into each axis's curve editor — raw input vs # mapped output, both in degrees. The mapper applies sensitivity # *before* the curve, so the dot's x corresponds to the curve's @@ -381,6 +407,10 @@ def _on_pose( # The camera_status slot owns the disconnected message; here # just show fps/inference status (which is 0/0 when disconnected). self.statusBar().showMessage("camera offline") + elif not stats.tracking_enabled: + self.statusBar().showMessage( + "tracking disabled — view centered (press the toggle hotkey to resume)" + ) else: self.statusBar().showMessage( f"{stats.fps:4.1f} fps • {stats.inference_ms:.1f} ms • " @@ -448,6 +478,48 @@ def _on_camera_changed(self, _combo_idx: int) -> None: # window with `_allow_close = True` — second call here accepts the # event and the QApplication exits. + # ------------------------------------------------------------------ + # Visibility → pipeline UI-load gating + # ------------------------------------------------------------------ + # When the window is minimized or hidden to tray, the pipeline has no + # reason to ship preview frames or pose updates. We translate Qt + # show/hide/minimize transitions into pipeline.set_ui_active() so the + # tracking loop sheds that UI load exactly while the game is in front. + + def _sync_pipeline_ui_active(self) -> None: + pipeline = getattr(self, "_pipeline", None) + if pipeline is None: + return + # A minimized window is still "visible" in Qt's sense, so check + # both: active means on-screen AND not minimized. + pipeline.set_ui_active(self.isVisible() and not self.isMinimized()) + + @Slot(bool) + def _on_pause_preview(self, paused: bool) -> None: + self._preview_paused = paused + self._btn_pause_preview.setText("Resume preview" if paused else "Pause preview") + if hasattr(self, "_pipeline"): + self._pipeline.set_preview_active(not paused) + if paused: + self._camera_view.show_idle("preview paused\ntracking + output still running") + self.statusBar().showMessage( + "preview paused - tracking still running" if paused else "preview resumed", + 3000, + ) + + def changeEvent(self, event: QEvent) -> None: + super().changeEvent(event) + if event.type() == QEvent.WindowStateChange: + self._sync_pipeline_ui_active() + + def showEvent(self, event) -> None: + super().showEvent(event) + self._sync_pipeline_ui_active() + + def hideEvent(self, event) -> None: + super().hideEvent(event) + self._sync_pipeline_ui_active() + def allow_close(self) -> None: self._allow_close = True diff --git a/src/openfov/ui/settings_dialog.py b/src/openfov/ui/settings_dialog.py index 0262c2f..d0866ec 100644 --- a/src/openfov/ui/settings_dialog.py +++ b/src/openfov/ui/settings_dialog.py @@ -25,7 +25,6 @@ QHBoxLayout, QLabel, QPushButton, - QSpinBox, QTabWidget, QVBoxLayout, QWidget, @@ -46,13 +45,6 @@ ) -# 0 in the spinbox means "native resolution, no downscale" — maps to -# inference_max_dim=None in AppConfig. The spinbox range covers the -# useful inference sizes; below 160 MediaPipe accuracy starts dropping. -_INFER_DIM_MIN = 0 -_INFER_DIM_MAX = 1920 - - class SettingsDialog(QDialog): """Tabbed app-settings dialog.""" @@ -105,9 +97,9 @@ def _make_performance_tab(self) -> QWidget: w = QWidget() form = QFormLayout(w) - # Preset picker. Selecting one populates resolution + inference - # downscale below; the user can then hand-tune (which flips the - # preset to "Custom"). + # Preset picker. Selecting one populates the resolution below and + # carries the matching inference downscale internally; changing the + # resolution flips the preset to "Custom". self._preset = QComboBox() self._preset.addItem("Performance (low-end CPU)", "performance") self._preset.addItem("Balanced (recommended)", "balanced") @@ -131,34 +123,53 @@ def _make_performance_tab(self) -> QWidget: self._resolution.setCurrentIndex(self._resolution.count() - 1) form.addRow("Capture resolution", self._resolution) - # Inference downscale. 0 = native resolution. - self._infer_dim = QSpinBox() - self._infer_dim.setRange(_INFER_DIM_MIN, _INFER_DIM_MAX) - self._infer_dim.setSingleStep(16) - self._infer_dim.setSuffix(" px") - self._infer_dim.setSpecialValueText("Native (no downscale)") - self._infer_dim.setValue( - self._config.inference_max_dim if self._config.inference_max_dim else 0 - ) - form.addRow("Inference downscale", self._infer_dim) + # Inference downscale is driven by the preset (Performance / + # Balanced / Quality map to progressively larger inference inputs) + # and is no longer a user-facing control. Carry the current value + # here so the preset handler and _harvest can pass it through + # without a widget. + self._inference_max_dim = self._config.inference_max_dim note = QLabel( "OpenFOV asks the camera for 60 fps and falls back to " - "whatever the device supports (typically 30 fps on older " - "or integrated webcams). Lower the inference downscale to " - "use less CPU; raise it for more stable landmarks. iRacing " - "receives one pose write per inference frame." + "whatever the device supports (typically 30 fps on older or " + "integrated webcams). iRacing receives one pose write per " + "tracked frame." ) note.setWordWrap(True) note.setStyleSheet("color: #7a838c;") form.addRow(note) + # --- Game-performance knob (independent of the preset) --- + # This is the CPU-affinity "isolate" mode, reworded for end users. + # Deliberately does NOT flip the preset to "Custom" (orthogonal to + # resolution/downscale). The OpenCV thread cap is intentionally not + # surfaced here — its safe default lives in AppConfig and is rarely + # worth a user touching. + self._affinity_isolate = QCheckBox( + "Reserve CPU cores to reduce impact on the game" + ) + self._affinity_isolate.setChecked(self._config.cpu_affinity_mode == "isolate") + self._affinity_isolate.setToolTip( + "Pins OpenFOV to a couple of CPU cores so it competes less with " + "your game for processor time." + ) + form.addRow(self._affinity_isolate) + + affinity_note = QLabel( + "Experimental. Helps most on a busy CPU while a game is " + "running; on some processors it makes no difference or can " + "slightly hurt. Off by default. Applies on the next launch." + ) + affinity_note.setWordWrap(True) + affinity_note.setStyleSheet("color: #7a838c;") + form.addRow(affinity_note) + # Wire the interactions: - # - Preset change → snap dependent fields. - # - User edits a dependent field → flip preset to Custom. + # - Preset change → snap resolution + carry the preset's downscale. + # - User edits the resolution → flip preset to Custom. self._preset.activated.connect(self._on_preset_activated) self._resolution.activated.connect(self._mark_custom) - self._infer_dim.valueChanged.connect(self._mark_custom_from_value) return w @@ -169,20 +180,18 @@ def _on_preset_activated(self, _idx: int) -> None: spec = PERFORMANCE_PRESETS.get(preset_id) if spec is None: return - # Apply the preset to the dependent fields without triggering the - # "mark custom" handler (we're the source of the change). + # Apply the preset's resolution without retriggering "mark custom"; + # carry its inference downscale internally (no widget for it). self._resolution.blockSignals(True) - self._infer_dim.blockSignals(True) try: pair = (spec["camera_width"], spec["camera_height"]) for i in range(self._resolution.count()): if self._resolution.itemData(i) == pair: self._resolution.setCurrentIndex(i) break - self._infer_dim.setValue(spec["inference_max_dim"] or 0) finally: self._resolution.blockSignals(False) - self._infer_dim.blockSignals(False) + self._inference_max_dim = spec["inference_max_dim"] def _mark_custom(self, _idx: int = 0) -> None: # Find the "custom" entry and select it. @@ -191,9 +200,6 @@ def _mark_custom(self, _idx: int = 0) -> None: self._preset.setCurrentIndex(i) return - def _mark_custom_from_value(self, _v: int) -> None: - self._mark_custom() - # ----- Hotkeys ----- def _make_hotkeys_tab(self) -> QWidget: @@ -210,10 +216,22 @@ def _make_hotkeys_tab(self) -> QWidget: wrap1.setLayout(row1) form.addRow("Recenter", wrap1) + self._toggle_button = HotkeyButton(self._config.hotkey_toggle_tracking) + row2 = QHBoxLayout() + row2.addWidget(self._toggle_button, 1) + clear2 = QPushButton("Clear") + clear2.clicked.connect(self._toggle_button.clear_binding) + row2.addWidget(clear2) + wrap2 = QWidget() + wrap2.setLayout(row2) + form.addRow("Toggle tracking", wrap2) + note = QLabel( "Hotkeys are global - they work even when iRacing has focus. " "Click a binding, then press the key combination you want. " - "Escape cancels." + "Escape cancels. Toggle tracking turns inference fully " + "off/on (saves CPU when off) and recenters the in-game view " + "each time." ) note.setWordWrap(True) note.setStyleSheet("color: #7a838c;") @@ -254,8 +272,7 @@ def _make_output_tab(self) -> QWidget: def _harvest(self) -> AppConfig: width, height = self._resolution.currentData() - infer_dim_raw = self._infer_dim.value() - infer_dim = infer_dim_raw if infer_dim_raw > 0 else None + infer_dim = self._inference_max_dim preset_id = self._preset.currentData() or "custom" candidate = replace( self._config, @@ -264,7 +281,9 @@ def _harvest(self) -> AppConfig: camera_height=int(height), performance_preset=preset_id, inference_max_dim=infer_dim, + cpu_affinity_mode="isolate" if self._affinity_isolate.isChecked() else "auto", hotkey_recenter=self._recenter_button.binding(), + hotkey_toggle_tracking=self._toggle_button.binding(), ) # If the spinbox values now actually match one of the named # presets (e.g. user picked Custom but ended up on Balanced's diff --git a/src/openfov/ui/wizard.py b/src/openfov/ui/wizard.py index 32fa0f3..055ed69 100644 --- a/src/openfov/ui/wizard.py +++ b/src/openfov/ui/wizard.py @@ -651,7 +651,7 @@ def __init__(self, parent=None) -> None: "
  • Don't overuse it. Keep your head still by " "default — only move when you need to (apex/exit, " "wheel-to-wheel battles, learning new tracks). Constant " - "motion makes it worse.
  • " + "motion can be disorienting!" "
  • Tune for your setup. The defaults are a " "starting point — adjust sensitivity and filters until " "your movements feel right for your monitor distance.
  • " diff --git a/tests/test_p5_fixes.py b/tests/test_p5_fixes.py index 6833141..fd924c7 100644 --- a/tests/test_p5_fixes.py +++ b/tests/test_p5_fixes.py @@ -61,3 +61,114 @@ def test_pipeline_set_neutral_stores_value() -> None: assert p._neutral[1] == -5.0 assert p._neutral[2] == 2.0 assert p._recenter_requested is False + + +def test_pipeline_set_ui_active_toggles_flag() -> None: + """The visibility gate defaults to active (no behavior change) and + flips off when the window reports itself hidden/minimized.""" + from openfov.output.manager import OutputManager + from openfov.runtime.pipeline import PipelineThread + from openfov.tracker.debug_tracker import DebugSineTracker + + p = PipelineThread(DebugSineTracker(), OutputManager(), camera_index=0) + assert p._ui_active is True + p.set_ui_active(False) + assert p._ui_active is False + p.set_ui_active(True) + assert p._ui_active is True + + +def test_pipeline_set_preview_active_is_independent_of_ui_active() -> None: + """Pausing the preview must not touch the visibility gate, and vice + versa — they gate different emits (frame_ready vs. everything).""" + from openfov.output.manager import OutputManager + from openfov.runtime.pipeline import PipelineThread + from openfov.tracker.debug_tracker import DebugSineTracker + + p = PipelineThread(DebugSineTracker(), OutputManager(), camera_index=0) + assert p._preview_active is True + p.set_preview_active(False) + assert p._preview_active is False + assert p._ui_active is True # visibility gate untouched + p.set_preview_active(True) + assert p._preview_active is True + + +def test_main_window_pause_preview_toggles_pipeline(qapp, monkeypatch, tmp_path) -> None: + """The Pause-preview button flips the pipeline's preview gate and its + own label without disturbing the visibility gate.""" + monkeypatch.setenv("OPENFOV_APPDATA", str(tmp_path)) + from openfov.output.manager import OutputManager + from openfov.persistence.profiles import Profile + from openfov.runtime.pipeline import PipelineThread + from openfov.tracker.debug_tracker import DebugSineTracker + from openfov.ui.main_window import MainWindow + + win = MainWindow(initial_profile=Profile(name="Default")) + pipeline = PipelineThread(DebugSineTracker(), OutputManager(), camera_index=0) + win.attach_pipeline(pipeline) + + win._btn_pause_preview.setChecked(True) # fires toggled(True) + assert win._preview_paused is True + assert pipeline._preview_active is False + assert win._btn_pause_preview.text() == "Resume preview" + + win._btn_pause_preview.setChecked(False) + assert win._preview_paused is False + assert pipeline._preview_active is True + assert win._btn_pause_preview.text() == "Pause preview" + + win.allow_close() + win.close() + + +def test_camera_reader_joins_cleanly() -> None: + """Regression: the reader's stop Event must not be named `_stop`, which + shadowed threading.Thread._stop — an internal method join() calls during + teardown. The shadowing made every shutdown raise 'Event object is not + callable' and skip the rest of pipeline cleanup.""" + import threading + + from openfov.runtime.pipeline import _CameraReader, _FrameSlot + + stop = threading.Event() + reader = _CameraReader(lambda: None, _FrameSlot(), stop) + reader.start() + stop.set() + reader.join(timeout=2.0) # raised TypeError before the fix + assert not reader.is_alive() + + +def test_pipeline_toggle_tracking_flips_flag() -> None: + """The toggle hotkey target flips inference on/off; set_tracking_enabled + sets it directly. PipelineStats defaults to enabled.""" + from openfov.output.manager import OutputManager + from openfov.runtime.pipeline import PipelineStats, PipelineThread + from openfov.tracker.debug_tracker import DebugSineTracker + + p = PipelineThread(DebugSineTracker(), OutputManager(), camera_index=0) + assert p._tracking_enabled is True + p.toggle_tracking() + assert p._tracking_enabled is False + p.toggle_tracking() + assert p._tracking_enabled is True + p.set_tracking_enabled(False) + assert p._tracking_enabled is False + assert PipelineStats().tracking_enabled is True + + +def test_pipeline_accepts_anti_contention_kwargs() -> None: + """Constructor surface for the OpenCV thread cap + affinity mode.""" + from openfov.output.manager import OutputManager + from openfov.runtime.pipeline import PipelineThread + from openfov.tracker.debug_tracker import DebugSineTracker + + p = PipelineThread( + DebugSineTracker(), + OutputManager(), + camera_index=0, + cv_thread_cap=2, + cpu_affinity_mode="isolate", + ) + assert p._cv_thread_cap == 2 + assert p._cpu_affinity_mode == "isolate" diff --git a/tests/test_perf_config.py b/tests/test_perf_config.py index 9b57bc4..6821544 100644 --- a/tests/test_perf_config.py +++ b/tests/test_perf_config.py @@ -97,3 +97,45 @@ def test_unknown_preset_name_does_not_crash() -> None: than throwing — defensive against future renames.""" cfg = AppConfig(performance_preset="nonexistent") assert preset_values_match("nonexistent", cfg) is False + + +# --- Anti-contention knobs (OpenCV thread cap + CPU affinity) --------- + + +def test_anti_contention_defaults() -> None: + """Fresh config caps OpenCV to a couple threads and leaves affinity + to the OS. These are the safe out-of-the-box values.""" + cfg = AppConfig() + assert cfg.inference_thread_cap == 2 + assert cfg.cpu_affinity_mode == "auto" + + +def test_anti_contention_fields_roundtrip() -> None: + cfg = AppConfig(inference_thread_cap=4, cpu_affinity_mode="isolate") + save_app_config(cfg) + restored = load_app_config() + assert restored.inference_thread_cap == 4 + assert restored.cpu_affinity_mode == "isolate" + + +def test_anti_contention_knobs_are_preset_independent() -> None: + """Setting an affinity mode / thread cap must not knock a config off + its named preset — they're orthogonal to resolution + downscale.""" + cfg = AppConfig(inference_thread_cap=1, cpu_affinity_mode="isolate") + assert preset_values_match("performance", cfg) + + +def test_bad_affinity_string_falls_back_to_auto() -> None: + """A typo in a hand-edited TOML must not leave us in an unknown + affinity mode — load sanitizes it back to 'auto'.""" + cfg = AppConfig(cpu_affinity_mode="garbage") + save_app_config(cfg) + restored = load_app_config() + assert restored.cpu_affinity_mode == "auto" + + +def test_negative_thread_cap_clamps_to_zero() -> None: + cfg = AppConfig(inference_thread_cap=-5) + save_app_config(cfg) + restored = load_app_config() + assert restored.inference_thread_cap == 0 diff --git a/tests/test_persistence.py b/tests/test_persistence.py index 2515826..030845d 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -76,11 +76,30 @@ def test_profile_default_shape() -> None: assert p.axes["x"].enabled is False assert p.axes["y"].enabled is False assert p.axes["z"].enabled is False - # Sensitivity defaults to 0.75 — slightly below 1:1 so first-time - # users don't over-rotate. - assert p.axes["yaw"].sensitivity == pytest.approx(0.75) + # Yaw ships with soft-center + 3x sensitivity (fine control near + # forward view, fast swing at the edges); pitch/roll keep the gentle + # 0.75 linear default so they feel tame when enabled. + assert p.axes["yaw"].sensitivity == pytest.approx(3.0) assert p.axes["pitch"].sensitivity == pytest.approx(0.75) assert p.axes["roll"].sensitivity == pytest.approx(0.75) + # Soft-center is shallow near zero, so for a small input its output is + # below the linear (pitch) response — confirms yaw got the soft curve. + assert p.axes["yaw"].curve(10.0) < p.axes["pitch"].curve(10.0) + + +def test_app_config_toggle_hotkey_default() -> None: + from openfov.persistence.config import AppConfig + + assert AppConfig().hotkey_toggle_tracking == "" + + +def test_app_config_toggle_hotkey_roundtrip(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("OPENFOV_APPDATA", str(tmp_path)) + from openfov.persistence.config import AppConfig, load_app_config, save_app_config + + cfg = AppConfig(hotkey_toggle_tracking="") + save_app_config(cfg) + assert load_app_config().hotkey_toggle_tracking == "" def test_profile_enabled_field_roundtrip(monkeypatch, tmp_path) -> None: From 83ccf1e37005bfbc1f87c62af9b4e690daf02816 Mon Sep 17 00:00:00 2001 From: epalosh Date: Fri, 29 May 2026 14:44:10 -0500 Subject: [PATCH 2/3] Name installer OpenFOV--setup.exe; document packaging fixes The .iss OutputBaseFilename produced -win-x64.exe, but release.yml, build_installer.ps1, the README, and the winget manifest all expect -setup.exe -- so the CI release's upload step (if-no-files-found: error) would have failed to find the artifact. Align the .iss to -setup. Also record the packaging fixes (libmediapipe + NPClient/TrackIR bundling, bundled_bin_dir Nuitka detection) in the changelog. --- CHANGELOG.md | 13 +++++++++++++ installer/openfov.iss | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09ac9d4..3452df3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,19 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). thread's stop Event shadowed `threading.Thread._stop`, so every exit threw and skipped pipeline cleanup. Renamed the attribute. +### Packaging +- Bundle MediaPipe's `libmediapipe.dll` (~27 MB). It's loaded via `dlopen` + at runtime, so Nuitka never saw it and `--include-package-data` skips + DLLs — the standalone imported MediaPipe fine but the tracker failed to + initialize ("Could not find module libmediapipe.dll"). +- Bundle `NPClient64.dll` + `TrackIR.exe`. Nuitka's `--include-data-dir` + silently skips `.dll`/`.exe`, so the installed app couldn't deliver + tracking to iRacing (the registry pointed at a missing DLL). +- `bundled_bin_dir()` now detects a Nuitka build (`__compiled__`) so it + resolves `/resources/bin` instead of over-shooting to `dist/`. +- Installer output renamed to `OpenFOV--setup.exe` to match the + release workflow, the winget manifest, and the docs. + ### Tests - **Full suite: 158/158 passing.** diff --git a/installer/openfov.iss b/installer/openfov.iss index 438e5cb..7b57953 100644 --- a/installer/openfov.iss +++ b/installer/openfov.iss @@ -44,7 +44,7 @@ DisableProgramGroupPage=yes LicenseFile=..\LICENSE PrivilegesRequired=admin PrivilegesRequiredOverridesAllowed=dialog commandline -OutputBaseFilename=OpenFOV-{#MyAppVersion}-win-x64 +OutputBaseFilename=OpenFOV-{#MyAppVersion}-setup SetupIconFile=..\resources\icons\openfov.ico Compression=lzma2/ultra SolidCompression=yes From 2c97054d3c9c9844ee58df0023d524bbc57d63cf Mon Sep 17 00:00:00 2001 From: epalosh Date: Fri, 29 May 2026 14:54:51 -0500 Subject: [PATCH 3/3] Fix CI release: stage VC++ redist + gcc fallback for NPClient build Two issues that would break the tag-triggered release (never exercised end-to-end before; v0.1.0 was built locally): - release.yml invoked iscc directly, skipping the VC++ redist download. openfov.iss requires redist\vc_redist.x64.exe as a bundled source file (gitignored, ~25 MB), so ISCC would fail on the missing file. Now calls build_installer.ps1, which downloads the redist before compiling. - npclient-vendor/build.ps1 hard-required the triplet-named x86_64-w64-mingw32-gcc, which local WinLibs provides but CI's `choco install mingw` does not (it exposes plain `gcc`). Falls back to `gcc` so the NPClient build works in both environments. --- .github/workflows/release.yml | 11 +++++++---- npclient-vendor/build.ps1 | 6 +++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3becda0..652a78b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,11 +93,14 @@ jobs: id: installer shell: pwsh run: | - $iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" - if (-not (Test-Path $iscc)) { throw "ISCC.exe not found" } - & $iscc /DMyAppVersion="${{ steps.version.outputs.version }}" installer/openfov.iss - if ($LASTEXITCODE -ne 0) { throw "Inno Setup failed" } + # Use build_installer.ps1 rather than calling iscc directly: it + # downloads the VC++ redist that openfov.iss bundles as a required + # source file (redist\vc_redist.x64.exe, gitignored). Calling iscc + # directly skips that download and ISCC fails on the missing file. + pwsh ./installer/build_installer.ps1 -Version "${{ steps.version.outputs.version }}" + if ($LASTEXITCODE -ne 0) { throw "Installer build failed" } $installer = Get-ChildItem installer/Output/OpenFOV-*-setup.exe | Select-Object -First 1 + if (-not $installer) { throw "Installer not found in installer/Output" } Write-Output "name=$($installer.Name)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append Write-Host "Installer: $($installer.FullName)" Write-Host "Size: $($installer.Length) bytes" diff --git a/npclient-vendor/build.ps1 b/npclient-vendor/build.ps1 index 8be9a27..d0ef751 100644 --- a/npclient-vendor/build.ps1 +++ b/npclient-vendor/build.ps1 @@ -63,7 +63,11 @@ function Get-Tool { } $gcc32 = Get-Tool $Compiler32 # optional; many WinLibs builds are 64-only -$gcc64 = Get-Tool $Compiler64 -Required +# Local WinLibs toolchains expose the triplet-prefixed name; CI's +# `choco install mingw` exposes plain `gcc`. Try the triplet first, then +# fall back to `gcc` so the NPClient build works in both environments. +$gcc64 = Get-Tool $Compiler64 +if (-not $gcc64) { $gcc64 = Get-Tool "gcc" -Required } $commonCFlags = @( "-O2", "-Os",