Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
58 changes: 58 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,64 @@
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.

### 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 `<exe>/resources/bin` instead of over-shooting to `dist/`.
- Installer output renamed to `OpenFOV-<ver>-setup.exe` to match the
release workflow, the winget manifest, and the docs.

### Tests
- **Full suite: 158/158 passing.**

## [0.1.0] — First public release

### Added
Expand Down
26 changes: 24 additions & 2 deletions build/nuitka_build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,7 +30,7 @@
[CmdletBinding()]
param(
[string]$OutDir = "",
[string]$Version = "0.1.0.0",
[string]$Version = "0.2.0.0",
[string]$PythonExe = ""
)

Expand Down Expand Up @@ -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.
#
Expand Down Expand Up @@ -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() -> <exe>/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",
Expand Down
4 changes: 2 additions & 2 deletions installer/build_installer.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions installer/openfov.iss
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion npclient-vendor/build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/openfov/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""OpenFOV — webcam head tracking for sim/flight games."""

__version__ = "0.1.0"
__version__ = "0.2.0"
36 changes: 29 additions & 7 deletions src/openfov/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions src/openfov/output/npclient_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
38 changes: 38 additions & 0 deletions src/openfov/persistence/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<f9>"
# 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 = "<f10>"
# 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.
Expand All @@ -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,
Expand All @@ -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,
},
}
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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", "<f9>")) if isinstance(hk, dict) else "<f9>",
hotkey_toggle_tracking=(
str(hk.get("toggle_tracking", "<f10>")) if isinstance(hk, dict) else "<f10>"
),
hotkey_pause=str(hk.get("pause", "")) if isinstance(hk, dict) else "",
)

Expand Down
Loading
Loading