diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml
index f05bf3f5..ae2bb3cc 100644
--- a/.gitea/workflows/build.yml
+++ b/.gitea/workflows/build.yml
@@ -111,7 +111,7 @@ jobs:
run: |
dnf install -y git rpmdevtools meson ninja-build python3-devel \
systemd-rpm-macros vulkan-loader-devel redhat-rpm-config nodejs npm \
- gcc gettext
+ gcc gettext mingw64-gcc
- name: Setup rpmbuild environment
run: |
@@ -150,7 +150,7 @@ jobs:
run: |
apt-get update
apt-get install -y build-essential debhelper dpkg-dev gettext git \
- libvulkan-dev meson ninja-build nodejs npm \
+ libvulkan-dev meson mingw-w64 ninja-build nodejs npm \
pkgconf python3
- name: Checkout
diff --git a/.gitea/workflows/package-build.yml b/.gitea/workflows/package-build.yml
index 5cf2e21c..303361ae 100644
--- a/.gitea/workflows/package-build.yml
+++ b/.gitea/workflows/package-build.yml
@@ -137,7 +137,7 @@ jobs:
run: |
dnf install -y git rpmdevtools meson ninja-build python3-devel \
systemd-rpm-macros vulkan-loader-devel redhat-rpm-config nodejs npm \
- gcc gettext
+ gcc gettext mingw64-gcc
- name: Setup rpmbuild environment
run: |
@@ -257,7 +257,7 @@ jobs:
run: |
apt-get update
apt-get install -y build-essential debhelper dpkg-dev gettext git \
- libvulkan-dev meson ninja-build nodejs npm \
+ libvulkan-dev meson mingw-w64 ninja-build nodejs npm \
pkgconf python3
- name: Checkout PR
diff --git a/.gitignore b/.gitignore
index 2304ffa2..61938e02 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,5 +41,8 @@ Thumbs.db
# Compiled gettext catalogs
portprotonqt/locales/*/LC_MESSAGES/*.mo
+# Local MinGW toolchain and built DLL (CI builds these)
+.toolchain/
+data/NPClient64.dll
# AI
.codex
diff --git a/build-aux/PKGBUILD b/build-aux/PKGBUILD
index 96f0f701..2cbe149a 100644
--- a/build-aux/PKGBUILD
+++ b/build-aux/PKGBUILD
@@ -6,8 +6,8 @@ arch=('any')
url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"
license=('GPL-3.0')
depends=('python-requests' 'python-babel' 'python-evdev' 'python-pygame' 'python-orjson'
- 'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'jq' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3')
-makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext')
+ 'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'python-opencv' 'python-numpy' 'cabextract' 'unzip' 'curl' 'jq' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3')
+makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext' 'mingw-w64-gcc')
conflicts=("portprotonqt-steam-compat")
provides=('portprotonqt-steam-compat')
replaces=('portprotonqt-steam-compat')
diff --git a/build-aux/PKGBUILD-git b/build-aux/PKGBUILD-git
index 60c26738..de0cc240 100644
--- a/build-aux/PKGBUILD-git
+++ b/build-aux/PKGBUILD-git
@@ -6,7 +6,7 @@ arch=('any')
url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"
license=('GPL-3.0')
depends=('python-requests' 'python-babel' 'python-evdev' 'python-pygame' 'python-orjson'
- 'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'jq' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3')
+ 'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'python-opencv' 'python-numpy' 'cabextract' 'unzip' 'curl' 'jq' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3')
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext')
conflicts=("portprotonqt-steam-compat")
provides=('portprotonqt-steam-compat')
diff --git a/build-aux/fedora-git.spec b/build-aux/fedora-git.spec
index 7fa1a11d..5bf76702 100644
--- a/build-aux/fedora-git.spec
+++ b/build-aux/fedora-git.spec
@@ -23,6 +23,7 @@ BuildRequires: gettext
BuildRequires: systemd-rpm-macros
BuildRequires: vulkan-loader-devel
BuildRequires: gcc
+BuildRequires: mingw64-gcc
Obsoletes: python3-%{pypi_name}-git < %{version}-%{release}
Provides: python3-%{pypi_name}-git = %{version}-%{release}
@@ -42,6 +43,8 @@ Requires: python3-pillow
Requires: python3-pillow-qt
Requires: python3-rapidfuzz
Requires: python3-libarchive-c
+Requires: python3-opencv
+Requires: python3-numpy
Requires: perl-Image-ExifTool
Requires: SDL3
Requires: qt6-qtsvg
@@ -118,6 +121,7 @@ install -Dpm 0644 ./completions/_portprotonqt -t %{buildroot}%{zsh_completions_d
%{_datadir}/portproton/scripts/
%{_datadir}/portproton/conf/
%{_datadir}/portproton/img/
+%{_datadir}/%{pypi_name}/
%{bash_completions_dir}/portprotonqt
%{fish_completions_dir}/portprotonqt.fish
%{zsh_completions_dir}/_portprotonqt
diff --git a/build-aux/fedora.spec b/build-aux/fedora.spec
index 9f8ceb8b..343a7bd9 100644
--- a/build-aux/fedora.spec
+++ b/build-aux/fedora.spec
@@ -20,6 +20,7 @@ BuildRequires: gettext
BuildRequires: systemd-rpm-macros
BuildRequires: vulkan-loader-devel
BuildRequires: gcc
+BuildRequires: mingw64-gcc
Obsoletes: python3-%{pypi_name} < %{version}-%{release}
Provides: python3-%{pypi_name} = %{version}-%{release}
@@ -39,6 +40,8 @@ Requires: python3-pillow
Requires: python3-pillow-qt
Requires: python3-rapidfuzz
Requires: python3-libarchive-c
+Requires: python3-opencv
+Requires: python3-numpy
Requires: perl-Image-ExifTool
Requires: SDL3
Requires: qt6-qtsvg
@@ -116,6 +119,7 @@ install -Dpm 0644 ./completions/_portprotonqt -t %{buildroot}%{zsh_completions_d
%{_datadir}/portproton/scripts/
%{_datadir}/portproton/conf/
%{_datadir}/portproton/img/
+%{_datadir}/%{pypi_name}/
%{bash_completions_dir}/portprotonqt
%{fish_completions_dir}/portprotonqt.fish
%{zsh_completions_dir}/_portprotonqt
diff --git a/build-aux/npclient/LICENSE b/build-aux/npclient/LICENSE
new file mode 100644
index 00000000..80a942ce
--- /dev/null
+++ b/build-aux/npclient/LICENSE
@@ -0,0 +1,26 @@
+NPClient64.dll for PortProtonQt
+Copyright (C) 2026 PortProtonQt contributors
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+
+---
+
+The NPClient API implemented in this DLL is based on publicly
+available documentation and open-source implementations, including:
+
+- linuxtrack-wine by John Tsiombikas (GPLv3)
+ https://github.com/jtsiomb/linuxtrack-wine
+
+- opentrack by Stanislaw Halik and contributors (ISC)
+ https://github.com/opentrack/opentrack
diff --git a/build-aux/npclient/Makefile b/build-aux/npclient/Makefile
new file mode 100644
index 00000000..5d1de24e
--- /dev/null
+++ b/build-aux/npclient/Makefile
@@ -0,0 +1,20 @@
+# Makefile for NPClient64.dll
+# Build with: make NPClient64.dll
+# Requires mingw-w64 toolchain (x86_64-w64-mingw32-gcc)
+
+CC = x86_64-w64-mingw32-gcc
+CFLAGS = -O2 -Wall -shared
+LDFLAGS = -Wl,--subsystem,windows
+
+TARGET = NPClient64.dll
+SRC = npclient64.c
+
+.PHONY: all clean
+
+all: $(TARGET)
+
+$(TARGET): $(SRC)
+ $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
+
+clean:
+ rm -f $(TARGET)
diff --git a/build-aux/npclient/npclient64.c b/build-aux/npclient/npclient64.c
new file mode 100644
index 00000000..f0deee2c
--- /dev/null
+++ b/build-aux/npclient/npclient64.c
@@ -0,0 +1,210 @@
+/*
+ * NPClient64.dll - TrackIR-compatible head tracking client for Wine.
+ *
+ * Implements the NaturalPoint TrackIR NPClient API, reading head tracking
+ * data from the Wine shared memory file "facetracknoir-wine-shm" written
+ * by PortProtonQt.
+ *
+ * The NPClient API was reverse-engineered from publicly available
+ * documentation and open-source implementations. This file is released
+ * under the GPL-3.0 license as part of PortProtonQt.
+ */
+
+#include
+#include
+#include
+
+#define WINE_SHM_NAME "facetracknoir-wine-shm"
+#define WINE_MTX_NAME "facetracknoir-wine-mtx"
+
+#pragma pack(push, 1)
+struct WineSHM {
+ double data[6];
+ int32_t gameid;
+ int32_t gameid2;
+ unsigned char table[8];
+ BOOL stop;
+};
+
+struct NPData {
+ int16_t status;
+ int16_t frame;
+ uint32_t checksum;
+ float roll, pitch, yaw;
+ float tx, ty, tz;
+ float padding[9];
+};
+
+struct NPSignature {
+ char dllsig[200];
+ char appsig[200];
+};
+#pragma pack(pop)
+
+static HANDLE hShm = NULL;
+static struct WineSHM *shm = NULL;
+static HANDLE hMtx = NULL;
+static int16_t frame_counter = 0;
+static int initialized = 0;
+static int transmitting = 0;
+
+static struct NPSignature sigdata = {
+ "precise head tracking\n put your head into the game\n now go look around\n\n "
+ "Copyright EyeControl Technologies",
+
+ "hardware camera\n software processing data\n track user movement\n\n "
+ "Copyright EyeControl Technologies"
+};
+
+static BOOL impl_create_mapping(void)
+{
+ if (shm != NULL)
+ return TRUE;
+
+ hShm = CreateFileMappingA(INVALID_HANDLE_VALUE,
+ NULL,
+ PAGE_READWRITE,
+ 0,
+ sizeof(struct WineSHM),
+ (LPCSTR)WINE_SHM_NAME);
+ if (hShm == NULL)
+ return FALSE;
+
+ shm = (struct WineSHM *)MapViewOfFile(hShm, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(struct WineSHM));
+ hMtx = CreateMutexA(NULL, FALSE, WINE_MTX_NAME);
+
+ return shm != NULL;
+}
+
+__declspec(dllexport) int __stdcall NP_GetData(struct NPData *data)
+{
+ if (data == NULL)
+ return 1;
+
+ memset(data, 0, sizeof(struct NPData));
+
+ if (!initialized || !transmitting)
+ return 0;
+
+ if (impl_create_mapping() == FALSE || shm == NULL)
+ return 1;
+
+ if (hMtx && WaitForSingleObject(hMtx, 16) == WAIT_OBJECT_0) {
+ double x = shm->data[0];
+ double y = shm->data[1];
+ double z = shm->data[2];
+ double yaw = shm->data[3];
+ double pitch = shm->data[4];
+ double roll = shm->data[5];
+ ReleaseMutex(hMtx);
+
+ data->yaw = (float)(-(yaw * 180.0 / 3.14159265358979323846) / 180.0 * 16384.0);
+ data->pitch = (float)(-(pitch * 180.0 / 3.14159265358979323846) / 180.0 * 16384.0);
+ data->roll = (float)(-(roll * 180.0 / 3.14159265358979323846) / 180.0 * 16384.0);
+
+ data->tx = (float)(-x * 64.0f / 10.0f);
+ data->ty = (float)(y * 64.0f / 10.0f);
+ data->tz = (float)(z * 64.0f / 10.0f);
+ }
+
+ data->status = 0;
+ data->frame = frame_counter++;
+ data->checksum = 0;
+
+ return 0;
+}
+
+__declspec(dllexport) int __stdcall NP_GetSignature(struct NPSignature *sig)
+{
+ if (sig == NULL)
+ return 1;
+ memcpy(sig, &sigdata, sizeof(sigdata));
+ return 0;
+}
+
+__declspec(dllexport) int __stdcall NP_QueryVersion(short *ver)
+{
+ if (ver == NULL)
+ return 1;
+ *ver = 0x0400;
+ return 0;
+}
+
+__declspec(dllexport) int __stdcall NP_ReCenter(void)
+{
+ return 0;
+}
+
+__declspec(dllexport) int __stdcall NP_RegisterWindowHandle(void *handle)
+{
+ (void)handle;
+ return 0;
+}
+
+__declspec(dllexport) int __stdcall NP_UnregisterWindowHandle(void)
+{
+ return 0;
+}
+
+__declspec(dllexport) int __stdcall NP_RegisterProgramProfileID(short id)
+{
+ (void)id;
+ initialized = 1;
+ return 0;
+}
+
+__declspec(dllexport) int __stdcall NP_RequestData(short data)
+{
+ (void)data;
+ return 0;
+}
+
+__declspec(dllexport) int __stdcall NP_StopCursor(void)
+{
+ return 0;
+}
+
+__declspec(dllexport) int __stdcall NP_StartCursor(void)
+{
+ return 0;
+}
+
+__declspec(dllexport) int __stdcall NP_StartDataTransmission(void)
+{
+ transmitting = 1;
+ return 0;
+}
+
+__declspec(dllexport) int __stdcall NP_StopDataTransmission(void)
+{
+ transmitting = 0;
+ return 0;
+}
+
+BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
+{
+ (void)hinstDLL;
+ (void)lpvReserved;
+
+ switch (fdwReason) {
+ case DLL_PROCESS_ATTACH:
+ break;
+ case DLL_PROCESS_DETACH:
+ if (shm) {
+ UnmapViewOfFile(shm);
+ shm = NULL;
+ }
+ if (hShm) {
+ CloseHandle(hShm);
+ hShm = NULL;
+ }
+ if (hMtx) {
+ CloseHandle(hMtx);
+ hMtx = NULL;
+ }
+ break;
+ default:
+ break;
+ }
+ return TRUE;
+}
diff --git a/data/face_detection_yunet_2023mar.onnx b/data/face_detection_yunet_2023mar.onnx
new file mode 100644
index 00000000..f9beb304
Binary files /dev/null and b/data/face_detection_yunet_2023mar.onnx differ
diff --git a/debian/control b/debian/control
index 8b576a97..8eb1b1d9 100644
--- a/debian/control
+++ b/debian/control
@@ -7,6 +7,7 @@ Build-Depends:
gettext,
libvulkan-dev,
meson,
+ mingw-w64,
ninja-build,
pkgconf,
python3,
@@ -34,6 +35,8 @@ Depends:
python3-dbus-fast,
python3-evdev,
python3-libarchive-c,
+ python3-numpy,
+ python3-opencv,
python3-orjson,
python3-pefile,
python3-pil,
diff --git a/debian/copyright b/debian/copyright
index 5142d332..40481c2a 100644
--- a/debian/copyright
+++ b/debian/copyright
@@ -12,6 +12,11 @@ Copyright:
2026 PortProtonQt contributors
License: GPL-3.0
+Files: data/face_detection_yunet_2023mar.onnx
+Copyright:
+ 2023 OpenCV team
+License: Apache-2.0
+
License: GPL-3.0
This package is free software; you can redistribute it and/or modify it
under the terms of the GNU General Public License version 3.
diff --git a/debian/portprotonqt.install b/debian/portprotonqt.install
index 137259fe..06a012d4 100644
--- a/debian/portprotonqt.install
+++ b/debian/portprotonqt.install
@@ -11,4 +11,5 @@ usr/share/metainfo/ru.linux_gaming.PortProtonQt.metainfo.xml
usr/share/mime/packages/ru.linux_gaming.PortProtonQt.xml
usr/share/polkit-1/rules.d/ru.linux_gaming.PortProtonQt.rules
usr/share/portproton/
+usr/share/portprotonqt/
usr/share/zsh/site-functions/_portprotonqt
diff --git a/meson.build b/meson.build
index 16f98074..eb9c5519 100644
--- a/meson.build
+++ b/meson.build
@@ -102,6 +102,27 @@ install_data(
install_dir: datadir / 'metainfo',
)
+# Install YuNet face detection model for head tracking
+install_data(
+ 'data/face_detection_yunet_2023mar.onnx',
+ install_dir: datadir / meson.project_name(),
+)
+
+# Build NPClient64.dll for TrackIR-compatible games (optional, requires mingw-w64)
+mingw_cc = find_program('x86_64-w64-mingw32-gcc', required: false)
+if mingw_cc.found()
+ npclient64_dll = custom_target('npclient64_dll',
+ input: 'build-aux/npclient/npclient64.c',
+ output: 'NPClient64.dll',
+ command: [mingw_cc, '-O2', '-Wall', '-shared', '-Wl,--subsystem,windows',
+ '@INPUT@', '-o', '@OUTPUT@'],
+ install: true,
+ install_dir: datadir / meson.project_name(),
+ )
+else
+ warning('mingw-w64 not found, skipping NPClient64.dll build. Install mingw-w64 to enable TrackIR game support.')
+endif
+
# Install PortProton scripts
install_subdir(
'build-aux/share/portproton/scripts',
diff --git a/portprotonqt/dialogs/settings_dialog.py b/portprotonqt/dialogs/settings_dialog.py
index a2a45887..d1116de7 100644
--- a/portprotonqt/dialogs/settings_dialog.py
+++ b/portprotonqt/dialogs/settings_dialog.py
@@ -3,7 +3,7 @@
import os
import re
import subprocess
-from typing import cast, TYPE_CHECKING
+from typing import cast
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, QProcess, QTimer, QUrl
from PySide6.QtGui import QColor, QContextMenuEvent, QDesktopServices, QGuiApplication, QIcon
@@ -26,9 +26,6 @@ from PySide6.QtWidgets import (
QWidget,
)
-if TYPE_CHECKING:
- from portprotonqt.main_window import MainWindow
-
from portprotonqt.config import (
get_portproton_location,
get_portproton_scripts_path,
@@ -42,6 +39,7 @@ from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update
from portprotonqt.dialogs.settings_mangohud import MANGOHUD_ENV_KEYS, MangoHudSettingsMixin
from portprotonqt.dialogs.settings_gamescope import GAMESCOPE_ENV_KEYS, GamescopeSettingsMixin
from portprotonqt.dialogs.settings_vkbasalt import VKBASALT_ENV_KEYS, VkBasaltSettingsMixin
+from portprotonqt.dialogs.settings_headtracking import HeadTrackingSettingsMixin, HT_ALL_KEYS
from portprotonqt.localization import _, format_setting_name_for_display
from portprotonqt.logger import get_logger
from portprotonqt.preloader import Preloader
@@ -132,6 +130,7 @@ class ExeSettingsDialog(
MangoHudSettingsMixin,
GamescopeSettingsMixin,
VkBasaltSettingsMixin,
+ HeadTrackingSettingsMixin,
):
"""Dialog for configuring executable-specific settings."""
@@ -179,11 +178,13 @@ class ExeSettingsDialog(
self.init_mangohud_state()
self.init_gamescope_state()
self.init_vkbasalt_state()
+ self.init_headtracking_state()
self.blocked_keys = set()
self.numa_nodes = {}
self.locale_options = []
self.logical_core_options = []
self._gamepad_tooltip_map = {}
+ self._closing = False
self.setWindowTitle(_("Exe Settings"))
self.setModal(True)
@@ -203,7 +204,7 @@ class ExeSettingsDialog(
parent_obj = self.parent()
while parent_obj:
if hasattr(parent_obj, 'input_manager'):
- self.input_manager = cast("MainWindow", parent_obj).input_manager
+ self.input_manager = parent_obj.input_manager # type: ignore[union-attr]
self.main_window = parent_obj
parent_obj = parent_obj.parent()
@@ -212,6 +213,7 @@ class ExeSettingsDialog(
if self.input_manager:
self.input_manager.enable_settings_mode(self)
self.input_manager.gamepad_hotplug.connect(self._update_vkbasalt_toggle_key_visibility)
+ self.input_manager.gamepad_hotplug.connect(self._check_gamepad_and_block_headtracking)
self.hints_widget, self.hints_labels = create_dialog_hints_widget(
self.theme, self.main_window, self.input_manager, context='settings'
@@ -290,6 +292,8 @@ class ExeSettingsDialog(
self.vkbasalt_tab_layout = QVBoxLayout(self.vkbasalt_tab)
self.gamescope_tab = QWidget()
self.gamescope_tab_layout = QVBoxLayout(self.gamescope_tab)
+ self.headtracking_tab = QWidget()
+ self.headtracking_tab_layout = QVBoxLayout(self.headtracking_tab)
self.tab_widget.addTab(self.main_tab, _("Main"))
self.tab_widget.addTab(self.advanced_tab, _("Advanced"))
@@ -297,7 +301,9 @@ class ExeSettingsDialog(
self.tab_widget.addTab(self.vkbasalt_tab, "vkBasalt")
if self.gamescope_available:
self.tab_widget.addTab(self.gamescope_tab, "Gamescope")
+ self.tab_widget.addTab(self.headtracking_tab, _("Head Tracking"))
self.tab_widget.currentChanged.connect(self.on_table_selection_changed)
+ self.tab_widget.currentChanged.connect(lambda: self._on_headtracking_tab_changed())
self.settings_table = QTableWidget()
self.settings_table.setAlternatingRowColors(True)
@@ -421,6 +427,7 @@ class ExeSettingsDialog(
self.setup_vkbasalt_tab()
if self.gamescope_available:
self.setup_gamescope_tab()
+ self.setup_headtracking_tab()
self.main_layout.addWidget(self.tab_widget)
@@ -473,6 +480,8 @@ class ExeSettingsDialog(
def on_show_ppdb_finished(self, exit_code, exit_status):
"""Handle --show-ppdb output."""
+ if self._closing:
+ return
process = cast(QProcess, self.sender())
self.current_settings = {}
self.blocked_keys = set()
@@ -504,6 +513,7 @@ class ExeSettingsDialog(
or key in GAMESCOPE_ENV_KEYS
or key in VKBASALT_ENV_KEYS
or key in TOGGLE_BOOL_KEYS
+ or key in HT_ALL_KEYS
):
if val.startswith('"') and val.endswith('"') and len(val) >= 2:
val = val[1:-1]
@@ -564,6 +574,8 @@ class ExeSettingsDialog(
self.populate_vkbasalt()
if self.gamescope_available:
self.populate_gamescope()
+ self.populate_headtracking()
+ self._on_headtracking_tab_changed()
self.populate_favorites(select_tab=True)
self.settings_container.setCurrentIndex(1)
@@ -1228,9 +1240,11 @@ class ExeSettingsDialog(
gamescope_changes = []
if self.gamescope_available:
gamescope_changes = self._collect_gamescope_changes()
+ headtracking_changes = self._collect_headtracking_changes()
changes.extend(mangohud_changes)
changes.extend(self._collect_vkbasalt_changes())
changes.extend(gamescope_changes)
+ changes.extend(headtracking_changes)
# Check if PW_GAMESCOPE toggle changes are already in the list
has_gamescope_toggle = any(change.startswith("PW_GAMESCOPE=") for change in gamescope_changes)
@@ -1267,14 +1281,17 @@ class ExeSettingsDialog(
QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
logger.error(f"Failed to apply changes: {error_output}")
else:
- self.load_current_settings()
+ self._closing = True
+ self.cleanup_headtracking()
self.close()
def closeEvent(self, event):
+ self._closing = True
if hasattr(self, 'keyboard') and self.keyboard.isVisible():
self.keyboard.hide()
if self.input_manager:
self.input_manager.disable_settings_mode()
+ self.cleanup_headtracking()
super().closeEvent(event)
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
@@ -1474,17 +1491,21 @@ class ExeSettingsDialog(
self.show_gamepad_tooltip(show=False)
def reject(self):
+ self._closing = True
if hasattr(self, 'keyboard') and self.keyboard.isVisible():
self.keyboard.hide()
self.gamepad_tooltip.setVisible(False)
if self.input_manager:
self.input_manager.disable_settings_mode()
+ self.cleanup_headtracking()
super().reject()
def accept(self):
+ self._closing = True
if hasattr(self, 'keyboard') and self.keyboard.isVisible():
self.keyboard.hide()
self.gamepad_tooltip.setVisible(False)
if self.input_manager:
self.input_manager.disable_settings_mode()
+ self.cleanup_headtracking()
super().accept()
diff --git a/portprotonqt/dialogs/settings_headtracking.py b/portprotonqt/dialogs/settings_headtracking.py
new file mode 100644
index 00000000..680c76cf
--- /dev/null
+++ b/portprotonqt/dialogs/settings_headtracking.py
@@ -0,0 +1,1017 @@
+"""Head Tracking settings mixin for executable settings dialog."""
+
+import os
+import subprocess
+from typing import Any
+
+import cv2
+from PySide6.QtCore import Qt, QTimer
+from PySide6.QtGui import QImage, QPixmap
+from PySide6.QtWidgets import (
+ QComboBox,
+ QGroupBox,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QPushButton,
+ QScrollArea,
+ QSlider,
+ QVBoxLayout,
+ QWidget,
+)
+
+from portprotonqt.head_tracking.tracker_base import PoseData
+from portprotonqt.head_tracking.tracker_camera import CameraTracker
+from portprotonqt.head_tracking.tracker_manager import TrackerManager
+from portprotonqt.localization import _
+from portprotonqt.logger import get_logger
+
+logger = get_logger(__name__)
+
+# Settings keys
+HT_ENABLED_KEY = "PW_HEADTRACKING"
+HT_CAMERA_KEY = "PW_HEADTRACKING_CAMERA"
+HT_SENS_X_KEY = "PW_HEADTRACKING_SENS_X"
+HT_SENS_Y_KEY = "PW_HEADTRACKING_SENS_Y"
+HT_SENS_Z_KEY = "PW_HEADTRACKING_SENS_Z"
+HT_SENS_YAW_KEY = "PW_HEADTRACKING_SENS_YAW"
+HT_SENS_PITCH_KEY = "PW_HEADTRACKING_SENS_PITCH"
+HT_SENS_ROLL_KEY = "PW_HEADTRACKING_SENS_ROLL"
+HT_OUTPUT_KEY = "PW_HEADTRACKING_OUTPUT"
+HT_MOUSE_SENS_X_KEY = "PW_HEADTRACKING_MOUSE_SENS_X"
+HT_MOUSE_SENS_Y_KEY = "PW_HEADTRACKING_MOUSE_SENS_Y"
+HT_MOUSE_DEADZONE_KEY = "PW_HEADTRACKING_MOUSE_DEADZONE"
+HT_UDP_HOST_KEY = "PW_HEADTRACKING_UDP_HOST"
+HT_UDP_PORT_KEY = "PW_HEADTRACKING_UDP_PORT"
+
+DEFAULT_SENSITIVITY = 1.0
+DEFAULT_MOUSE_SENS = 10.0
+DEFAULT_MOUSE_DEADZONE = 0.05
+DEFAULT_UDP_HOST = "127.0.0.1"
+DEFAULT_UDP_PORT = 4242
+
+OUTPUT_MODES = [
+ ("shm", _("Wine SHM (TrackIR/FreeTrack)")),
+ ("mouse", _("Mouse emulation")),
+ ("joystick", _("Joystick emulation")),
+ ("npclient", _("TrackIR / FreeTrack (NPClient64)")),
+ ("udp", _("UDP over network")),
+ ("flightgear", _("FlightGear UDP")),
+]
+
+# All head tracking setting keys for PPDB parsing
+HT_ALL_KEYS = [
+ HT_ENABLED_KEY,
+ HT_CAMERA_KEY,
+ HT_SENS_X_KEY,
+ HT_SENS_Y_KEY,
+ HT_SENS_Z_KEY,
+ HT_SENS_YAW_KEY,
+ HT_SENS_PITCH_KEY,
+ HT_SENS_ROLL_KEY,
+ HT_OUTPUT_KEY,
+ HT_MOUSE_SENS_X_KEY,
+ HT_MOUSE_SENS_Y_KEY,
+ HT_MOUSE_DEADZONE_KEY,
+ HT_UDP_HOST_KEY,
+ HT_UDP_PORT_KEY,
+]
+
+
+def _get_camera_name(index: int) -> str | None:
+ """Read camera name from sysfs V4L2 and udev."""
+ # Try udev vendor + model first (more descriptive)
+ try:
+ result = subprocess.run(
+ ["udevadm", "info", "-q", "all", "-n", f"/dev/video{index}"],
+ capture_output=True,
+ text=True,
+ timeout=2,
+ )
+ if result.returncode == 0:
+ vendor = ""
+ model = ""
+ for line in result.stdout.splitlines():
+ if line.startswith("E: ID_VENDOR="):
+ vendor = line.split("=", 1)[1].strip()
+ elif line.startswith("E: ID_MODEL="):
+ model = line.split("=", 1)[1].strip()
+ if vendor and model:
+ # Clean up vendor string (replace underscores with spaces)
+ vendor_clean = vendor.replace("_", " ")
+ return f"{vendor_clean} {model}"
+ elif model:
+ return model
+ except (OSError, subprocess.TimeoutExpired):
+ pass
+
+ # Fallback to sysfs V4L2 name
+ sysfs_path = f"/sys/class/video4linux/video{index}/name"
+ try:
+ with open(sysfs_path, encoding="utf-8") as f:
+ return f.read().strip()
+ except (OSError, UnicodeDecodeError):
+ return None
+
+
+def _is_capture_device(index: int) -> bool:
+ """Check if /dev/video{index} is a video capture device (not metadata)."""
+ try:
+ result = subprocess.run(
+ ["v4l2-ctl", "-d", f"/dev/video{index}", "--all"],
+ capture_output=True,
+ text=True,
+ timeout=2,
+ )
+ if result.returncode == 0:
+ output = result.stdout
+ # Must have "Format Video Capture" and NOT "Format Metadata Capture"
+ has_video_capture = "Format Video Capture" in output
+ has_metadata = "Format Metadata Capture" in output
+ return has_video_capture and not has_metadata
+ except (OSError, subprocess.TimeoutExpired):
+ pass
+ # Fallback: try to open with OpenCV
+ return True
+
+
+# Module-level cache for camera list
+_camera_cache: list[tuple[int, str]] | None = None
+
+
+def _count_v4l_devices() -> int:
+ """Count available V4L2 video devices."""
+ try:
+ return len(os.listdir("/sys/class/video4linux"))
+ except OSError:
+ return 10
+
+
+def _list_cameras(max_check: int = 10) -> list[tuple[int, str]]:
+ """Enumerate available camera devices with real names."""
+ global _camera_cache
+ if _camera_cache is not None:
+ return _camera_cache
+
+ # Limit to actual number of V4L devices to avoid OpenCV warnings
+ max_check = min(max_check, _count_v4l_devices())
+
+ cameras = []
+ seen_names: set[str] = set()
+ for i in range(max_check):
+ if not _is_capture_device(i):
+ continue
+ cap = cv2.VideoCapture(i)
+ if cap.isOpened():
+ name = _get_camera_name(i)
+ if not name:
+ backend = (
+ cap.getBackendName()
+ if hasattr(cap, "getBackendName")
+ else "Camera"
+ )
+ name = f"{backend} {i}"
+ # Deduplicate: same physical camera exposes multiple /dev/video*
+ if name not in seen_names:
+ seen_names.add(name)
+ cameras.append((i, name))
+ cap.release()
+ _camera_cache = cameras
+ return cameras
+
+
+class HeadTrackingSettingsMixin:
+ """Mixin with Head Tracking settings UI and logic."""
+
+ theme: Any
+ tab_widget: Any
+ current_settings: dict[str, str]
+ start_sh: list[str] | None
+
+ def init_headtracking_state(self) -> None:
+ """Initialize head tracking state."""
+ self.headtracking_tab: QWidget | None = None
+ self.headtracking_tab_layout: QVBoxLayout | None = None
+ self.headtracking_enable_button: QPushButton | None = None
+ self.headtracking_warning_group: QGroupBox | None = None
+ self.headtracking_gamepad_warning_label: QLabel | None = None
+ self.headtracking_output_group: QGroupBox | None = None
+ self.headtracking_preview_group: QGroupBox | None = None
+ self.headtracking_camera_combo: QComboBox | None = None
+ self.headtracking_preview_label: QLabel | None = None
+ self.headtracking_pose_label: QLabel | None = None
+ self.headtracking_sens_sliders: dict[str, QSlider] = {}
+ self.headtracking_sens_labels: dict[str, QLabel] = {}
+ self.headtracking_output_combo: QComboBox | None = None
+ self.headtracking_mouse_sens_x_slider: QSlider | None = None
+ self.headtracking_mouse_sens_x_label: QLabel | None = None
+ self.headtracking_mouse_sens_x_name_label: QLabel | None = None
+ self.headtracking_mouse_sens_y_slider: QSlider | None = None
+ self.headtracking_mouse_sens_y_label: QLabel | None = None
+ self.headtracking_mouse_sens_y_name_label: QLabel | None = None
+ self.headtracking_mouse_deadzone_slider: QSlider | None = None
+ self.headtracking_mouse_deadzone_label: QLabel | None = None
+ self.headtracking_mouse_deadzone_name_label: QLabel | None = None
+ self.headtracking_udp_host_input: Any | None = None
+ self.headtracking_udp_port_input: Any | None = None
+ self.headtracking_udp_host_label: QLabel | None = None
+ self.headtracking_udp_port_label: QLabel | None = None
+ self.headtracking_preview_timer: QTimer | None = None
+ self.headtracking_tracker_manager = TrackerManager()
+ self.headtracking_preview_active = False
+
+ def setup_headtracking_tab(self) -> None:
+ """Create Head Tracking tab widgets."""
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFocusPolicy(Qt.FocusPolicy.NoFocus)
+ scroll.setStyleSheet(
+ self.theme.SCROLL_STYLE + self.theme.TRANSPARENT_BACKGROUND_STYLE
+ )
+
+ container = QWidget()
+ container.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
+ layout = QVBoxLayout(container)
+ layout.setContentsMargins(10, 10, 10, 10)
+ layout.setSpacing(self.theme.exeSettingsGroupBoxBlockSpacing)
+
+ self._add_headtracking_enable_group(layout)
+ self._add_headtracking_warning_group(layout)
+ self._add_headtracking_output_group(layout)
+
+ # Preview group is created but hidden until tracking is enabled
+ self._add_headtracking_preview_group(layout)
+
+ layout.addStretch()
+ scroll.setWidget(container)
+
+ if self.headtracking_tab_layout:
+ self.headtracking_tab_layout.addWidget(scroll)
+
+ def _add_headtracking_enable_group(self, parent_layout: QVBoxLayout) -> None:
+ """Add enable/disable button group."""
+ group = QGroupBox(_("Tracking"))
+ group.setStyleSheet(self.theme.QGROUP_BOX_STYLE)
+ layout = QVBoxLayout(group)
+
+ self.headtracking_enable_button = QPushButton()
+ self.headtracking_enable_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
+ self.headtracking_enable_button.setCheckable(True)
+ self.headtracking_enable_button.clicked.connect(
+ self._on_headtracking_toggle
+ )
+
+ layout.addWidget(self.headtracking_enable_button)
+ parent_layout.addWidget(group)
+
+ def _add_headtracking_warning_group(self, parent_layout: QVBoxLayout) -> None:
+ """Add gamepad incompatibility warning group."""
+ self.headtracking_warning_group = QGroupBox(_("Warning"))
+ self.headtracking_warning_group.setStyleSheet(self.theme.QGROUP_BOX_STYLE)
+ layout = QVBoxLayout(self.headtracking_warning_group)
+
+ self.headtracking_gamepad_warning_label = QLabel()
+ self.headtracking_gamepad_warning_label.setStyleSheet(
+ getattr(self.theme, "WARNING_LABEL_STYLE", "")
+ )
+ self.headtracking_gamepad_warning_label.setWordWrap(True)
+ layout.addWidget(self.headtracking_gamepad_warning_label)
+
+ parent_layout.addWidget(self.headtracking_warning_group)
+
+ def _add_headtracking_output_group(self, parent_layout: QVBoxLayout) -> None:
+ """Add output method selection group."""
+ self.headtracking_output_group = QGroupBox(_("Output Method"))
+ self.headtracking_output_group.setStyleSheet(self.theme.QGROUP_BOX_STYLE)
+ layout = QVBoxLayout(self.headtracking_output_group)
+
+ # Output mode selector
+ mode_row = QHBoxLayout()
+ mode_lbl = QLabel(_("Mode:"))
+ mode_lbl.setStyleSheet(self.theme.CONTENT_STYLE)
+
+ self.headtracking_output_combo = QComboBox()
+ self.headtracking_output_combo.setStyleSheet(
+ getattr(self.theme, "COMBOBOX_STYLE", "")
+ )
+ for value, label in OUTPUT_MODES:
+ self.headtracking_output_combo.addItem(label, value)
+ self.headtracking_output_combo.currentIndexChanged.connect(
+ self._on_output_mode_changed
+ )
+
+ mode_row.addWidget(mode_lbl)
+ mode_row.addWidget(self.headtracking_output_combo, stretch=1)
+ layout.addLayout(mode_row)
+
+ # Mouse sensitivity X
+ (
+ self.headtracking_mouse_sens_x_name_label,
+ self.headtracking_mouse_sens_x_slider,
+ self.headtracking_mouse_sens_x_label,
+ ) = self._add_mouse_slider(layout, _("Mouse X speed"), 500, DEFAULT_MOUSE_SENS)
+ self.headtracking_mouse_sens_x_slider.valueChanged.connect(
+ lambda v: self._on_mouse_sens_changed(
+ HT_MOUSE_SENS_X_KEY, v, self.headtracking_mouse_sens_x_label
+ )
+ )
+
+ # Mouse sensitivity Y
+ (
+ self.headtracking_mouse_sens_y_name_label,
+ self.headtracking_mouse_sens_y_slider,
+ self.headtracking_mouse_sens_y_label,
+ ) = self._add_mouse_slider(layout, _("Mouse Y speed"), 500, DEFAULT_MOUSE_SENS)
+ self.headtracking_mouse_sens_y_slider.valueChanged.connect(
+ lambda v: self._on_mouse_sens_changed(
+ HT_MOUSE_SENS_Y_KEY, v, self.headtracking_mouse_sens_y_label
+ )
+ )
+
+ # Mouse deadzone
+ (
+ self.headtracking_mouse_deadzone_name_label,
+ self.headtracking_mouse_deadzone_slider,
+ self.headtracking_mouse_deadzone_label,
+ ) = self._add_mouse_slider(layout, _("Mouse deadzone"), 100, DEFAULT_MOUSE_DEADZONE * 100)
+ self.headtracking_mouse_deadzone_slider.valueChanged.connect(
+ lambda v: self._on_mouse_deadzone_changed(v)
+ )
+
+ # UDP host input
+ udp_host_row = QHBoxLayout()
+ udp_host_lbl = QLabel(_("UDP host:"))
+ udp_host_lbl.setStyleSheet(self.theme.CONTENT_STYLE)
+ udp_host_lbl.setMinimumWidth(160)
+ self.headtracking_udp_host_label = udp_host_lbl
+
+ self.headtracking_udp_host_input = QLineEdit(DEFAULT_UDP_HOST)
+ self.headtracking_udp_host_input.setStyleSheet(
+ getattr(self.theme, "LINEEDIT_STYLE", "")
+ )
+ self.headtracking_udp_host_input.textChanged.connect(
+ lambda: self._update_tracker_output_settings()
+ )
+
+ udp_host_row.addWidget(udp_host_lbl)
+ udp_host_row.addWidget(self.headtracking_udp_host_input, stretch=1)
+ layout.addLayout(udp_host_row)
+
+ # UDP port input
+ udp_port_row = QHBoxLayout()
+ udp_port_lbl = QLabel(_("UDP port:"))
+ udp_port_lbl.setStyleSheet(self.theme.CONTENT_STYLE)
+ udp_port_lbl.setMinimumWidth(160)
+ self.headtracking_udp_port_label = udp_port_lbl
+
+ self.headtracking_udp_port_input = QLineEdit(str(DEFAULT_UDP_PORT))
+ self.headtracking_udp_port_input.setStyleSheet(
+ getattr(self.theme, "LINEEDIT_STYLE", "")
+ )
+ self.headtracking_udp_port_input.textChanged.connect(
+ lambda: self._update_tracker_output_settings()
+ )
+
+ udp_port_row.addWidget(udp_port_lbl)
+ udp_port_row.addWidget(self.headtracking_udp_port_input, stretch=1)
+ layout.addLayout(udp_port_row)
+
+ parent_layout.addWidget(self.headtracking_output_group)
+
+ def _add_mouse_slider(
+ self,
+ parent_layout: QVBoxLayout,
+ label: str,
+ max_value: int,
+ default_value: float,
+ ) -> tuple[QLabel, QSlider, QLabel]:
+ """Add a slider row for mouse settings."""
+ row = QHBoxLayout()
+ lbl = QLabel(label)
+ lbl.setStyleSheet(self.theme.CONTENT_STYLE)
+ lbl.setMinimumWidth(160)
+
+ slider = QSlider(Qt.Orientation.Horizontal)
+ slider.setRange(0, max_value)
+ slider.setValue(int(default_value))
+ slider.setStyleSheet(
+ getattr(self.theme, "SLIDER_STYLE", getattr(self.theme, "SLIDER_SIZE_STYLE", ""))
+ )
+
+ val_lbl = QLabel(f"{default_value:.0f}")
+ val_lbl.setStyleSheet(self.theme.CONTENT_STYLE)
+ val_lbl.setMinimumWidth(50)
+
+ row.addWidget(lbl)
+ row.addWidget(slider)
+ row.addWidget(val_lbl)
+ parent_layout.addLayout(row)
+ return lbl, slider, val_lbl
+
+ def _on_output_mode_changed(self) -> None:
+ """Show/hide mouse settings based on selected output mode."""
+ self._update_output_settings_visibility()
+
+ def _update_output_settings_visibility(self) -> None:
+ """Update visibility of output-specific settings."""
+ mode = self._get_selected_output_mode()
+ mouse_visible = mode == "mouse"
+ udp_visible = mode in ("udp", "flightgear")
+ for widget in (
+ self.headtracking_mouse_sens_x_slider,
+ self.headtracking_mouse_sens_y_slider,
+ self.headtracking_mouse_deadzone_slider,
+ ):
+ if widget:
+ widget.setVisible(mouse_visible)
+ for widget in (
+ self.headtracking_mouse_sens_x_label,
+ self.headtracking_mouse_sens_y_label,
+ self.headtracking_mouse_deadzone_label,
+ self.headtracking_mouse_sens_x_name_label,
+ self.headtracking_mouse_sens_y_name_label,
+ self.headtracking_mouse_deadzone_name_label,
+ ):
+ if widget:
+ widget.setVisible(mouse_visible)
+ for widget in (
+ self.headtracking_udp_host_input,
+ self.headtracking_udp_port_input,
+ self.headtracking_udp_host_label,
+ self.headtracking_udp_port_label,
+ ):
+ if widget:
+ widget.setVisible(udp_visible)
+
+ def _get_selected_output_mode(self) -> str:
+ """Get currently selected output mode."""
+ if self.headtracking_output_combo is None:
+ return "shm"
+ mode = self.headtracking_output_combo.currentData()
+ return mode if mode is not None else "shm"
+
+ def _on_mouse_sens_changed(self, key: str, value: int, label: QLabel | None) -> None:
+ """Handle mouse sensitivity slider change."""
+ if label:
+ label.setText(f"{value:.0f}")
+ self._update_tracker_output_settings()
+
+ def _on_mouse_deadzone_changed(self, value: int) -> None:
+ """Handle mouse deadzone slider change."""
+ if self.headtracking_mouse_deadzone_label:
+ self.headtracking_mouse_deadzone_label.setText(f"{value / 100.0:.2f}")
+ self._update_tracker_output_settings()
+
+ def _update_tracker_output_settings(self) -> None:
+ """Apply output settings to tracker manager."""
+ mouse_sens_x = (
+ self.headtracking_mouse_sens_x_slider.value()
+ if self.headtracking_mouse_sens_x_slider
+ else int(DEFAULT_MOUSE_SENS)
+ )
+ mouse_sens_y = (
+ self.headtracking_mouse_sens_y_slider.value()
+ if self.headtracking_mouse_sens_y_slider
+ else int(DEFAULT_MOUSE_SENS)
+ )
+ mouse_deadzone = (
+ self.headtracking_mouse_deadzone_slider.value() / 100.0
+ if self.headtracking_mouse_deadzone_slider
+ else DEFAULT_MOUSE_DEADZONE
+ )
+ udp_host = (
+ self.headtracking_udp_host_input.text()
+ if self.headtracking_udp_host_input
+ else DEFAULT_UDP_HOST
+ )
+ udp_port = DEFAULT_UDP_PORT
+ if self.headtracking_udp_port_input:
+ try:
+ udp_port = int(self.headtracking_udp_port_input.text())
+ except ValueError:
+ udp_port = DEFAULT_UDP_PORT
+ # Preview always uses SHM output to avoid interfering with real mouse/joystick
+ self.headtracking_tracker_manager.set_output_mode(
+ "shm",
+ mouse_sens_yaw=float(mouse_sens_x),
+ mouse_sens_pitch=float(mouse_sens_y),
+ mouse_deadzone=mouse_deadzone,
+ udp_host=udp_host,
+ udp_port=udp_port,
+ )
+
+ def populate_headtracking(self) -> None:
+ """Update head tracking widgets from current_settings."""
+ if self.headtracking_enable_button is None:
+ return
+
+ # Stop any active preview before repopulating
+ self._stop_preview()
+
+ # Enable button
+ enabled = self.current_settings.get(HT_ENABLED_KEY) == "1"
+ self.headtracking_enable_button.setChecked(enabled)
+ self._update_enable_button_text(enabled)
+
+ # Camera combo
+ if self.headtracking_camera_combo is not None:
+ self._populate_camera_combo()
+ saved_camera = self.current_settings.get(HT_CAMERA_KEY, "0")
+ idx = self.headtracking_camera_combo.findData(int(saved_camera))
+ if idx >= 0:
+ self.headtracking_camera_combo.setCurrentIndex(idx)
+
+ # Sensitivity sliders
+ for key, slider in self.headtracking_sens_sliders.items():
+ saved = self.current_settings.get(key, str(DEFAULT_SENSITIVITY))
+ try:
+ val = int(float(saved) * 100)
+ slider.setValue(max(0, min(200, val)))
+ except ValueError:
+ slider.setValue(100)
+
+ # Output mode
+ if self.headtracking_output_combo is not None:
+ saved_output = self.current_settings.get(HT_OUTPUT_KEY, "shm")
+ idx = self.headtracking_output_combo.findData(saved_output)
+ if idx >= 0:
+ self.headtracking_output_combo.setCurrentIndex(idx)
+
+ # Mouse settings
+ if self.headtracking_mouse_sens_x_slider is not None:
+ saved = self.current_settings.get(
+ HT_MOUSE_SENS_X_KEY, str(DEFAULT_MOUSE_SENS)
+ )
+ try:
+ val = int(float(saved))
+ except ValueError:
+ val = int(DEFAULT_MOUSE_SENS)
+ self.headtracking_mouse_sens_x_slider.setValue(val)
+ if self.headtracking_mouse_sens_x_label:
+ self.headtracking_mouse_sens_x_label.setText(f"{val:.0f}")
+
+ if self.headtracking_mouse_sens_y_slider is not None:
+ saved = self.current_settings.get(
+ HT_MOUSE_SENS_Y_KEY, str(DEFAULT_MOUSE_SENS)
+ )
+ try:
+ val = int(float(saved))
+ except ValueError:
+ val = int(DEFAULT_MOUSE_SENS)
+ self.headtracking_mouse_sens_y_slider.setValue(val)
+ if self.headtracking_mouse_sens_y_label:
+ self.headtracking_mouse_sens_y_label.setText(f"{val:.0f}")
+
+ if self.headtracking_mouse_deadzone_slider is not None:
+ saved = self.current_settings.get(
+ HT_MOUSE_DEADZONE_KEY, str(DEFAULT_MOUSE_DEADZONE)
+ )
+ try:
+ val = int(float(saved) * 100)
+ val = max(0, min(100, val))
+ except ValueError:
+ val = int(DEFAULT_MOUSE_DEADZONE * 100)
+ self.headtracking_mouse_deadzone_slider.setValue(val)
+ if self.headtracking_mouse_deadzone_label:
+ self.headtracking_mouse_deadzone_label.setText(f"{val / 100.0:.2f}")
+
+ # UDP settings
+ if self.headtracking_udp_host_input is not None:
+ saved_host = self.current_settings.get(HT_UDP_HOST_KEY, DEFAULT_UDP_HOST)
+ self.headtracking_udp_host_input.setText(saved_host)
+
+ if self.headtracking_udp_port_input is not None:
+ saved_port = self.current_settings.get(HT_UDP_PORT_KEY, str(DEFAULT_UDP_PORT))
+ self.headtracking_udp_port_input.setText(saved_port)
+
+ self._update_output_settings_visibility()
+ self._update_tracker_output_settings()
+
+ # Output and preview group visibility
+ if self.headtracking_output_group is not None:
+ self.headtracking_output_group.setVisible(enabled)
+ if self.headtracking_preview_group is not None:
+ self.headtracking_preview_group.setVisible(enabled)
+
+ def _update_enable_button_text(self, enabled: bool) -> None:
+ """Update button text based on state."""
+ if self.headtracking_enable_button:
+ text = _("Head tracking ON") if enabled else _("Head tracking OFF")
+ self.headtracking_enable_button.setText(text)
+
+ def _add_headtracking_preview_group(self, parent_layout: QVBoxLayout) -> None:
+ """Add camera preview group with camera selector and sensitivity on the left."""
+ self.headtracking_preview_group = QGroupBox(_("Camera Preview"))
+ self.headtracking_preview_group.setStyleSheet(self.theme.QGROUP_BOX_STYLE)
+ layout = QVBoxLayout(self.headtracking_preview_group)
+
+ # Main area: left = camera list + sensitivity, right = preview + pose
+ preview_row = QHBoxLayout()
+
+ # Left side: camera selector + sensitivity sliders
+ left_layout = QVBoxLayout()
+
+ # Camera label + combo on one row
+ camera_row = QHBoxLayout()
+ camera_lbl = QLabel(_("Camera:"))
+ camera_lbl.setStyleSheet(self.theme.CONTENT_STYLE)
+
+ self.headtracking_camera_combo = QComboBox()
+ self.headtracking_camera_combo.setStyleSheet(
+ getattr(self.theme, "COMBOBOX_STYLE", "")
+ )
+ self._populate_camera_combo()
+
+ saved_camera = self.current_settings.get(HT_CAMERA_KEY, "0")
+ idx = self.headtracking_camera_combo.findData(int(saved_camera))
+ if idx >= 0:
+ self.headtracking_camera_combo.setCurrentIndex(idx)
+
+ camera_row.addWidget(camera_lbl)
+ camera_row.addWidget(self.headtracking_camera_combo, stretch=1)
+ left_layout.addLayout(camera_row)
+
+ # Camera change restarts preview if active
+ self.headtracking_camera_combo.currentIndexChanged.connect(
+ self._on_camera_changed
+ )
+
+ # Spacer between camera selector and sensitivity sliders
+ left_layout.addSpacing(10)
+
+ # Sensitivity sliders under camera selector
+ self._add_sensitivity_sliders(left_layout)
+
+ left_layout.addStretch()
+ preview_row.addLayout(left_layout, stretch=1)
+
+ # Right side: preview + pose + calibrate button
+ right_layout = QVBoxLayout()
+
+ self.headtracking_preview_label = QLabel()
+ self.headtracking_preview_label.setMinimumSize(280, 210)
+ self.headtracking_preview_label.setStyleSheet(
+ f"background-color: {self.theme.color_disabled_bg};"
+ )
+ self.headtracking_preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.headtracking_preview_label.setText(_("Camera off"))
+
+ self.headtracking_pose_label = QLabel(_("Pose: —"))
+ self.headtracking_pose_label.setStyleSheet(self.theme.CONTENT_STYLE)
+
+ btn_layout = QHBoxLayout()
+ self.headtracking_calib_button = QPushButton(_("Calibrate center"))
+ self.headtracking_calib_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
+ self.headtracking_calib_button.clicked.connect(
+ self._on_headtracking_calibrate
+ )
+
+ btn_layout.addWidget(self.headtracking_calib_button)
+ btn_layout.addStretch()
+
+ right_layout.addWidget(self.headtracking_preview_label)
+ right_layout.addWidget(self.headtracking_pose_label)
+ right_layout.addLayout(btn_layout)
+
+ preview_row.addLayout(right_layout, stretch=1)
+ layout.addLayout(preview_row)
+
+ parent_layout.addWidget(self.headtracking_preview_group)
+
+ # Hide by default if tracking is disabled
+ enabled = self.current_settings.get(HT_ENABLED_KEY) == "1"
+ self.headtracking_preview_group.setVisible(enabled)
+
+ def _populate_camera_combo(self) -> None:
+ """Fill camera dropdown with available devices."""
+ if self.headtracking_camera_combo is None:
+ return
+ self.headtracking_camera_combo.clear()
+ cameras = _list_cameras()
+ if not cameras:
+ self.headtracking_camera_combo.addItem(_("No cameras found"), -1)
+ return
+ for idx, name in cameras:
+ self.headtracking_camera_combo.addItem(name, idx)
+
+ def _add_sensitivity_sliders(self, parent_layout: QVBoxLayout) -> None:
+ """Add sensitivity sliders directly to the given layout."""
+ axes = [
+ (HT_SENS_X_KEY, _("X (left/right)")),
+ (HT_SENS_Y_KEY, _("Y (up/down)")),
+ (HT_SENS_Z_KEY, _("Z (forward/back)")),
+ (HT_SENS_YAW_KEY, _("Yaw (turn left/right)")),
+ (HT_SENS_PITCH_KEY, _("Pitch (look up/down)")),
+ (HT_SENS_ROLL_KEY, _("Roll (tilt head)")),
+ ]
+
+ for key, label in axes:
+ row = QHBoxLayout()
+ lbl = QLabel(label)
+ lbl.setStyleSheet(self.theme.CONTENT_STYLE)
+ lbl.setMinimumWidth(160)
+
+ slider = QSlider(Qt.Orientation.Horizontal)
+ slider.setRange(0, 200)
+ slider.setValue(100)
+ slider.setStyleSheet(
+ getattr(self.theme, "SLIDER_STYLE", getattr(self.theme, "SLIDER_SIZE_STYLE", ""))
+ )
+ slider.valueChanged.connect(
+ lambda v, k=key: self._on_sens_changed(k, v)
+ )
+
+ val_lbl = QLabel("1.00x")
+ val_lbl.setStyleSheet(self.theme.CONTENT_STYLE)
+ val_lbl.setMinimumWidth(50)
+
+ row.addWidget(lbl)
+ row.addWidget(slider)
+ row.addWidget(val_lbl)
+ parent_layout.addLayout(row)
+
+ self.headtracking_sens_sliders[key] = slider
+ self.headtracking_sens_labels[key] = val_lbl
+
+ # Load saved value
+ saved = self.current_settings.get(key, str(DEFAULT_SENSITIVITY))
+ try:
+ val = int(float(saved) * 100)
+ slider.setValue(max(0, min(200, val)))
+ except ValueError:
+ pass
+
+ def _on_headtracking_toggle(self) -> None:
+ """Handle enable/disable button click."""
+ if self.headtracking_enable_button is None:
+ return
+ enabled = self.headtracking_enable_button.isChecked()
+ self._update_enable_button_text(enabled)
+ if self.headtracking_output_group:
+ self.headtracking_output_group.setVisible(enabled)
+ if self.headtracking_preview_group:
+ self.headtracking_preview_group.setVisible(enabled)
+ if enabled:
+ self._start_preview()
+ else:
+ self._stop_preview()
+
+ def _on_camera_changed(self) -> None:
+ """Restart preview with newly selected camera."""
+ if not self.headtracking_preview_active:
+ return
+ self._stop_preview()
+ self._start_preview()
+
+ def _get_selected_camera_index(self) -> int:
+ """Get currently selected camera index from combo box."""
+ if self.headtracking_camera_combo is None:
+ return 0
+ idx = self.headtracking_camera_combo.currentData()
+ return idx if idx is not None else 0
+
+ def _start_preview(self) -> None:
+ """Start camera preview."""
+ if self.headtracking_preview_active:
+ return
+
+ camera_idx = self._get_selected_camera_index()
+ if camera_idx < 0:
+ logger.error("No camera selected")
+ if self.headtracking_enable_button:
+ self.headtracking_enable_button.setChecked(False)
+ self._update_enable_button_text(False)
+ return
+
+ tracker = CameraTracker(camera_index=camera_idx)
+ if not self.headtracking_tracker_manager.start(tracker):
+ logger.error("Failed to start head tracking preview")
+ if self.headtracking_enable_button:
+ self.headtracking_enable_button.setChecked(False)
+ self._update_enable_button_text(False)
+ return
+
+ self.headtracking_preview_active = True
+
+ # Apply current sensitivity values to preview tracker
+ self._update_tracker_sensitivity()
+
+ self.headtracking_preview_timer = QTimer() # type: ignore[arg-type]
+ self.headtracking_preview_timer.timeout.connect(self._update_preview)
+ self.headtracking_preview_timer.start(100) # ~10 FPS
+
+ def _stop_preview(self) -> None:
+ """Stop camera preview."""
+ self.headtracking_preview_active = False
+
+ if self.headtracking_preview_timer:
+ self.headtracking_preview_timer.stop()
+ self.headtracking_preview_timer = None
+
+ self.headtracking_tracker_manager.stop()
+
+ if self.headtracking_preview_label:
+ self.headtracking_preview_label.setPixmap(QPixmap())
+ self.headtracking_preview_label.setText(_("Camera off"))
+
+ if self.headtracking_pose_label:
+ self.headtracking_pose_label.setText(_("Pose: —"))
+
+ def _update_preview(self) -> None:
+ """Update preview frame and pose display."""
+ frame, pose = self.headtracking_tracker_manager.get_preview_frame()
+
+ if frame is not None and self.headtracking_preview_label:
+ # Convert BGR to RGB
+ rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
+ h, w, ch = rgb.shape
+ bytes_per_line = ch * w
+ qt_image = QImage(rgb.data, w, h, bytes_per_line, QImage.Format.Format_RGB888)
+ pixmap = QPixmap.fromImage(qt_image)
+ # Scale to fill label width, crop vertically to remove side bars
+ label_w = self.headtracking_preview_label.width()
+ label_h = self.headtracking_preview_label.height()
+ scaled = pixmap.scaled(
+ label_w,
+ label_h,
+ Qt.AspectRatioMode.KeepAspectRatioByExpanding,
+ Qt.TransformationMode.SmoothTransformation,
+ )
+ # Center-crop to label size
+ if scaled.width() > label_w:
+ x = (scaled.width() - label_w) // 2
+ scaled = scaled.copy(x, 0, label_w, scaled.height())
+ if scaled.height() > label_h:
+ y = (scaled.height() - label_h) // 2
+ scaled = scaled.copy(0, y, scaled.width(), label_h)
+ self.headtracking_preview_label.setPixmap(scaled)
+
+ if pose and self.headtracking_pose_label:
+ self.headtracking_pose_label.setText(
+ f"X:{pose.x:.1f} Y:{pose.y:.1f} Z:{pose.z:.1f} "
+ f"Y:{pose.yaw:.1f} P:{pose.pitch:.1f} R:{pose.roll:.1f}"
+ )
+
+ def _on_sens_changed(self, key: str, value: int) -> None:
+ """Handle sensitivity slider change."""
+ sens = value / 100.0
+ if key in self.headtracking_sens_labels:
+ self.headtracking_sens_labels[key].setText(f"{sens:.2f}x")
+
+ # Update live sensitivity on preview tracker
+ self._update_tracker_sensitivity()
+
+ def _update_tracker_sensitivity(self) -> None:
+ """Apply current slider values as sensitivity to preview tracker."""
+ sens = PoseData(
+ x=self._get_sens_slider_value(HT_SENS_X_KEY),
+ y=self._get_sens_slider_value(HT_SENS_Y_KEY),
+ z=self._get_sens_slider_value(HT_SENS_Z_KEY),
+ yaw=self._get_sens_slider_value(HT_SENS_YAW_KEY),
+ pitch=self._get_sens_slider_value(HT_SENS_PITCH_KEY),
+ roll=self._get_sens_slider_value(HT_SENS_ROLL_KEY),
+ )
+ self.headtracking_tracker_manager.set_sensitivity(sens)
+
+ def _get_sens_slider_value(self, key: str) -> float:
+ """Get slider value for sensitivity key, defaulting to 1.0."""
+ slider = self.headtracking_sens_sliders.get(key)
+ if slider is None:
+ return 1.0
+ return slider.value() / 100.0
+
+ def _on_headtracking_calibrate(self) -> None:
+ """Calibrate center position."""
+ logger.info("Head tracking calibration requested")
+ self.headtracking_tracker_manager.calibrate()
+ if self.headtracking_pose_label:
+ self.headtracking_pose_label.setText(
+ self.headtracking_pose_label.text() + " [CALIBRATED]"
+ )
+
+ def _collect_headtracking_changes(self) -> list[str]:
+ """Collect head tracking setting changes."""
+ changes = []
+
+ if not self.headtracking_enable_button:
+ return changes
+
+ enabled = "1" if self.headtracking_enable_button.isChecked() else "0"
+ orig_enabled = self.current_settings.get(HT_ENABLED_KEY, "0")
+ if enabled != orig_enabled:
+ changes.append(f"{HT_ENABLED_KEY}={enabled}")
+
+ # Camera selection
+ if self.headtracking_camera_combo:
+ camera_idx = self.headtracking_camera_combo.currentData()
+ if camera_idx is not None:
+ orig_camera = self.current_settings.get(HT_CAMERA_KEY, "0")
+ if str(camera_idx) != orig_camera:
+ changes.append(f"{HT_CAMERA_KEY}={camera_idx}")
+
+ # Sensitivity: always write all values so they exist in PPDB
+ for key, slider in self.headtracking_sens_sliders.items():
+ sens = slider.value() / 100.0
+ orig = self.current_settings.get(key)
+ if orig is not None:
+ try:
+ orig_val = float(orig)
+ except ValueError:
+ orig_val = DEFAULT_SENSITIVITY
+ if abs(sens - orig_val) > 0.001:
+ changes.append(f"{key}={sens:.2f}")
+ else:
+ # Key not in PPDB yet, write it
+ changes.append(f"{key}={sens:.2f}")
+
+ # Output mode
+ if self.headtracking_output_combo:
+ mode = self.headtracking_output_combo.currentData()
+ if mode is not None:
+ orig_mode = self.current_settings.get(HT_OUTPUT_KEY, "shm")
+ if mode != orig_mode:
+ changes.append(f"{HT_OUTPUT_KEY}={mode}")
+
+ # Mouse sensitivity X
+ if self.headtracking_mouse_sens_x_slider:
+ val = self.headtracking_mouse_sens_x_slider.value()
+ orig = self.current_settings.get(HT_MOUSE_SENS_X_KEY)
+ if orig is None or abs(float(orig) - val) > 0.001:
+ changes.append(f"{HT_MOUSE_SENS_X_KEY}={val}")
+
+ # Mouse sensitivity Y
+ if self.headtracking_mouse_sens_y_slider:
+ val = self.headtracking_mouse_sens_y_slider.value()
+ orig = self.current_settings.get(HT_MOUSE_SENS_Y_KEY)
+ if orig is None or abs(float(orig) - val) > 0.001:
+ changes.append(f"{HT_MOUSE_SENS_Y_KEY}={val}")
+
+ # Mouse deadzone
+ if self.headtracking_mouse_deadzone_slider:
+ val = self.headtracking_mouse_deadzone_slider.value() / 100.0
+ orig = self.current_settings.get(HT_MOUSE_DEADZONE_KEY)
+ if orig is None or abs(float(orig) - val) > 0.001:
+ changes.append(f"{HT_MOUSE_DEADZONE_KEY}={val:.2f}")
+
+ # UDP host
+ if self.headtracking_udp_host_input:
+ val = self.headtracking_udp_host_input.text()
+ orig = self.current_settings.get(HT_UDP_HOST_KEY, DEFAULT_UDP_HOST)
+ if val != orig:
+ changes.append(f"{HT_UDP_HOST_KEY}={val}")
+
+ # UDP port
+ if self.headtracking_udp_port_input:
+ val = self.headtracking_udp_port_input.text()
+ orig = self.current_settings.get(HT_UDP_PORT_KEY, str(DEFAULT_UDP_PORT))
+ if val != orig:
+ changes.append(f"{HT_UDP_PORT_KEY}={val}")
+
+ return changes
+
+ def _check_gamepad_and_block_headtracking(self, _action: str | None = None) -> None:
+ """Block head tracking if a gamepad is connected."""
+ input_manager = getattr(self, "input_manager", None)
+ has_gamepad = input_manager is not None and getattr(input_manager, "gamepad", None) is not None
+
+ if self.headtracking_enable_button is None:
+ return
+
+ if has_gamepad:
+ self.headtracking_enable_button.setEnabled(False)
+ self.headtracking_enable_button.setChecked(False)
+ self._update_enable_button_text(False)
+ if self.headtracking_output_group is not None:
+ self.headtracking_output_group.setVisible(False)
+ if self.headtracking_preview_group is not None:
+ self.headtracking_preview_group.setVisible(False)
+ if self.headtracking_warning_group is not None:
+ self.headtracking_warning_group.setVisible(True)
+ if self.headtracking_gamepad_warning_label is not None:
+ self.headtracking_gamepad_warning_label.setText(
+ _("Head tracking is disabled because a gamepad is connected. The two modes are incompatible.")
+ )
+ else:
+ self.headtracking_enable_button.setEnabled(True)
+ if self.headtracking_output_group is not None:
+ self.headtracking_output_group.setVisible(self.headtracking_enable_button.isChecked())
+ if self.headtracking_preview_group is not None:
+ self.headtracking_preview_group.setVisible(self.headtracking_enable_button.isChecked())
+ if self.headtracking_warning_group is not None:
+ self.headtracking_warning_group.setVisible(False)
+
+ def _on_headtracking_tab_changed(self) -> None:
+ """Start or stop preview when switching to/from head tracking tab."""
+ if self.headtracking_enable_button is None:
+ return
+ if self.tab_widget.currentWidget() == self.headtracking_tab:
+ self._check_gamepad_and_block_headtracking()
+ enabled = self.headtracking_enable_button.isChecked()
+ if enabled:
+ self._start_preview()
+ else:
+ self._stop_preview()
+
+ def cleanup_headtracking(self) -> None:
+ """Cleanup resources on dialog close."""
+ self._stop_preview()
diff --git a/portprotonqt/global_hotkey.py b/portprotonqt/global_hotkey.py
new file mode 100644
index 00000000..248824f9
--- /dev/null
+++ b/portprotonqt/global_hotkey.py
@@ -0,0 +1,209 @@
+"""Global hotkey support via evdev without grab (Linux).
+
+Reads keyboard events at the kernel level without grabbing devices,
+so all keys (including the hotkey) pass through to games normally.
+Works on Wayland, X11, and in games.
+"""
+
+import select
+import threading
+import time
+from collections.abc import Callable
+
+from PySide6.QtCore import QObject, Signal
+from evdev import InputDevice, ecodes, list_devices
+
+from portprotonqt.logger import get_logger
+
+logger = get_logger(__name__)
+
+# Keys that can be used as global hotkeys
+SUPPORTED_HOTKEYS = {
+ "scrolllock": ecodes.KEY_SCROLLLOCK,
+ "insert": ecodes.KEY_INSERT,
+}
+
+DEFAULT_HOTKEY = "insert"
+
+
+class GlobalHotkeyEvdev(QObject):
+ """Global hotkey using evdev without grab. Works on Wayland and X11."""
+
+ activated = Signal(str)
+
+ def __init__(self) -> None:
+ super().__init__()
+ self._callbacks: dict[str, Callable[[], None]] = {}
+ self._devices: list[InputDevice] = []
+ self._thread: threading.Thread | None = None
+ self._running = False
+ self._pressed: set[int] = set()
+ self._hotkey_codes: set[int] = {SUPPORTED_HOTKEYS[DEFAULT_HOTKEY]}
+
+ def _find_keyboard_devices(self) -> list[InputDevice]:
+ """Find all keyboard input devices."""
+ devices: list[InputDevice] = []
+ for path in list_devices():
+ try:
+ dev = InputDevice(path)
+ caps = dev.capabilities()
+ if ecodes.EV_KEY not in caps:
+ continue
+ keys = caps[ecodes.EV_KEY]
+ # Must have at least one hotkey and basic letter keys
+ if ecodes.KEY_A in keys and any(h in keys for h in self._hotkey_codes):
+ devices.append(dev)
+ logger.debug("Keyboard device: %s (%s)", path, dev.name)
+ except (OSError, PermissionError):
+ continue
+ logger.info("Found %d keyboard device(s)", len(devices))
+ return devices
+
+ def _scan_devices(self) -> None:
+ """Close old devices and open new ones."""
+ for dev in self._devices:
+ try:
+ dev.close()
+ except Exception:
+ pass
+ self._devices = self._find_keyboard_devices()
+
+ def register(
+ self,
+ shortcut_id: str,
+ _description: str,
+ callback: Callable[[], None],
+ hotkey: str | None = None,
+ ) -> bool:
+ """Register a global hotkey and start monitoring."""
+ self._callbacks[shortcut_id] = callback
+
+ if hotkey and hotkey.lower() in SUPPORTED_HOTKEYS:
+ self._hotkey_codes.add(SUPPORTED_HOTKEYS[hotkey.lower()])
+
+ # Already running — just updated hotkey codes
+ if self._running and self._thread is not None and self._thread.is_alive():
+ logger.info("Global hotkey '%s' (Insert) active", shortcut_id)
+ return True
+
+ self._devices = self._find_keyboard_devices()
+ if not self._devices:
+ logger.warning("No keyboard devices found for hotkey")
+ return False
+
+ self._running = True
+ self._thread = threading.Thread(target=self._run, daemon=True)
+ self._thread.start()
+
+ logger.info(
+ "Global hotkey '%s' (Insert) registered on %d device(s)",
+ shortcut_id,
+ len(self._devices),
+ )
+ return True
+
+ def _run(self) -> None:
+ """Main loop: wait for events with select() and dispatch."""
+ scan_interval = 30.0 # Rescan devices every 30 seconds
+ min_scan_interval = 5.0 # Minimum time between rescans
+ last_scan = time.time()
+
+ while self._running:
+ try:
+ now = time.time()
+ if now - last_scan > scan_interval:
+ self._scan_devices()
+ last_scan = now
+
+ if not self._devices:
+ time.sleep(1.0)
+ if now - last_scan > min_scan_interval:
+ self._scan_devices()
+ last_scan = time.time()
+ continue
+
+ # Build readable file descriptors
+ readable: list[int] = []
+ fd_map: dict[int, InputDevice] = {}
+ for dev in list(self._devices):
+ try:
+ fd = dev.fd
+ if fd is not None and fd >= 0:
+ readable.append(fd)
+ fd_map[fd] = dev
+ except Exception:
+ continue
+
+ if not readable:
+ time.sleep(0.5)
+ continue
+
+ # Wait for events with timeout
+ ready, _, _ = select.select(readable, [], [], 0.5)
+ for fd in ready:
+ dev = fd_map.get(fd)
+ if dev is None:
+ continue
+ try:
+ for event in dev.read():
+ self._handle_event(event)
+ except (OSError, PermissionError):
+ # Device disconnected or permission lost
+ logger.debug("Device %s unreadable, removing", dev.path)
+ try:
+ dev.close()
+ except Exception:
+ pass
+ if dev in self._devices:
+ self._devices.remove(dev)
+ except Exception as e:
+ logger.debug("Read error on %s: %s", dev.path, e)
+
+ except Exception as e:
+ logger.error("Hotkey monitor error: %s", e)
+ time.sleep(1.0)
+
+ def _handle_event(self, event) -> None:
+ """Process a single input event."""
+ if event.type != ecodes.EV_KEY:
+ return
+
+ code = event.code
+ value = event.value
+
+ # value: 0 = release, 1 = press, 2 = repeat
+ if value == 1 and code in self._hotkey_codes:
+ # Find hotkey name from code and call all registered callbacks
+ hotkey_name = None
+ for name, key_code in SUPPORTED_HOTKEYS.items():
+ if key_code == code:
+ hotkey_name = name
+ break
+ if hotkey_name is None:
+ return
+ logger.info("Hotkey %s pressed", hotkey_name)
+ for shortcut_id, cb in self._callbacks.items():
+ try:
+ cb()
+ except Exception as e:
+ logger.error("Hotkey callback error for '%s': %s", shortcut_id, e)
+ for shortcut_id in self._callbacks:
+ self.activated.emit(shortcut_id)
+
+ def unregister(self, shortcut_id: str) -> None:
+ """Unregister a global hotkey."""
+ self._callbacks.pop(shortcut_id, None)
+
+ def stop(self) -> None:
+ """Stop and cleanup."""
+ self._running = False
+ if self._thread is not None:
+ self._thread.join(timeout=2.0)
+ self._thread = None
+ for dev in self._devices:
+ try:
+ dev.close()
+ except Exception:
+ pass
+ self._devices = []
+ logger.info("Global hotkey stopped")
diff --git a/portprotonqt/head_tracking/__init__.py b/portprotonqt/head_tracking/__init__.py
new file mode 100644
index 00000000..52ac032c
--- /dev/null
+++ b/portprotonqt/head_tracking/__init__.py
@@ -0,0 +1,14 @@
+"""Head tracking module for PortProtonQt."""
+
+from portprotonqt.head_tracking.tracker_base import BaseTracker, PoseData
+from portprotonqt.head_tracking.tracker_camera import CameraTracker
+from portprotonqt.head_tracking.tracker_manager import TrackerManager
+from portprotonqt.head_tracking.tracking_worker import TrackingWorker
+
+__all__ = [
+ "BaseTracker",
+ "CameraTracker",
+ "PoseData",
+ "TrackerManager",
+ "TrackingWorker",
+]
diff --git a/portprotonqt/head_tracking/outputs/__init__.py b/portprotonqt/head_tracking/outputs/__init__.py
new file mode 100644
index 00000000..3c8dc831
--- /dev/null
+++ b/portprotonqt/head_tracking/outputs/__init__.py
@@ -0,0 +1,10 @@
+"""Output methods for head tracking data."""
+
+from portprotonqt.head_tracking.outputs.flightgear_output import FlightGearOutput
+from portprotonqt.head_tracking.outputs.joystick_output import JoystickOutput
+from portprotonqt.head_tracking.outputs.mouse_output import MouseOutput
+from portprotonqt.head_tracking.outputs.npclient_output import NpclientOutput
+from portprotonqt.head_tracking.outputs.shm_output import ShmOutput
+from portprotonqt.head_tracking.outputs.udp_output import UdpOutput
+
+__all__ = ["FlightGearOutput", "JoystickOutput", "MouseOutput", "NpclientOutput", "ShmOutput", "UdpOutput"]
diff --git a/portprotonqt/head_tracking/outputs/base_output.py b/portprotonqt/head_tracking/outputs/base_output.py
new file mode 100644
index 00000000..0e9191d3
--- /dev/null
+++ b/portprotonqt/head_tracking/outputs/base_output.py
@@ -0,0 +1,21 @@
+"""Base class for head tracking outputs."""
+
+from abc import ABC, abstractmethod
+
+from portprotonqt.head_tracking.tracker_base import PoseData
+
+
+class BaseOutput(ABC):
+ """Abstract base for head tracking output methods."""
+
+ @abstractmethod
+ def open(self) -> bool:
+ """Initialize output. Returns True on success."""
+
+ @abstractmethod
+ def close(self) -> None:
+ """Release output resources."""
+
+ @abstractmethod
+ def write(self, pose: PoseData) -> None:
+ """Write pose data to output."""
diff --git a/portprotonqt/head_tracking/outputs/flightgear_output.py b/portprotonqt/head_tracking/outputs/flightgear_output.py
new file mode 100644
index 00000000..315b00ef
--- /dev/null
+++ b/portprotonqt/head_tracking/outputs/flightgear_output.py
@@ -0,0 +1,75 @@
+"""FlightGear UDP output for head tracking.
+
+Sends pose data in FlightGear native protocol format.
+Compatible with opentrack FlightGear protocol.
+"""
+
+import socket
+import struct
+
+from portprotonqt.head_tracking.outputs.base_output import BaseOutput
+from portprotonqt.head_tracking.tracker_base import PoseData
+from portprotonqt.logger import get_logger
+
+logger = get_logger(__name__)
+
+# FlightGear datagram format:
+# x, y, z in meters (pose * 1e-2, with sign flips)
+# h, p, r in degrees (with sign flips)
+# status int32 = 1
+_FG_FORMAT = "<6dI"
+_DEFAULT_HOST = "127.0.0.1"
+_DEFAULT_PORT = 5542
+
+
+class FlightGearOutput(BaseOutput):
+ """Send head tracking data to FlightGear via UDP."""
+
+ def __init__(self, host: str = _DEFAULT_HOST, port: int = _DEFAULT_PORT) -> None:
+ self.host = host
+ self.port = port
+ self._sock: socket.socket | None = None
+
+ def open(self) -> bool:
+ """Open UDP socket."""
+ try:
+ self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ logger.info("FlightGear output opened: %s:%d", self.host, self.port)
+ return True
+ except Exception as exc:
+ logger.error("Failed to open FlightGear UDP socket: %s", exc)
+ return False
+
+ def close(self) -> None:
+ """Close UDP socket."""
+ if self._sock:
+ try:
+ self._sock.close()
+ except Exception:
+ pass
+ self._sock = None
+ logger.info("FlightGear output closed")
+
+ def write(self, pose: PoseData) -> None:
+ """Send pose data in FlightGear format."""
+ if self._sock is None:
+ return
+
+ # opentrack conversion:
+ # x = -headpose[TX] * 1e-2, y = headpose[TY] * 1e-2, z = headpose[TZ] * 1e-2
+ # h = -headpose[Yaw], p = headpose[Pitch], r = -headpose[Roll]
+ data = (
+ -pose.x * 1e-2,
+ pose.y * 1e-2,
+ pose.z * 1e-2,
+ -pose.yaw,
+ pose.pitch,
+ -pose.roll,
+ 1, # status
+ )
+ packet = struct.pack(_FG_FORMAT, *data)
+
+ try:
+ self._sock.sendto(packet, (self.host, self.port))
+ except Exception as exc:
+ logger.warning("FlightGear UDP send failed: %s", exc)
diff --git a/portprotonqt/head_tracking/outputs/joystick_output.py b/portprotonqt/head_tracking/outputs/joystick_output.py
new file mode 100644
index 00000000..541be200
--- /dev/null
+++ b/portprotonqt/head_tracking/outputs/joystick_output.py
@@ -0,0 +1,136 @@
+"""Joystick emulation output for head tracking."""
+
+import threading
+import time
+
+import evdev
+
+from portprotonqt.head_tracking.outputs.base_output import BaseOutput
+from portprotonqt.head_tracking.tracker_base import PoseData
+from portprotonqt.logger import get_logger
+
+logger = get_logger(__name__)
+
+# Linux input axis range for absolute axes
+_AXIS_MIN = -32768
+_AXIS_MAX = 32767
+
+
+def _degrees_to_axis(degrees: float, max_degrees: float = 45.0) -> int:
+ """Map degrees to joystick axis value."""
+ clamped = max(-max_degrees, min(max_degrees, degrees))
+ return int((clamped / max_degrees) * _AXIS_MAX)
+
+
+class JoystickOutput(BaseOutput):
+ """Emulates joystick axes from head tracking yaw/pitch/roll."""
+
+ def __init__(
+ self,
+ sens_yaw: float = 1.0,
+ sens_pitch: float = 1.0,
+ sens_roll: float = 1.0,
+ max_angle: float = 45.0,
+ ) -> None:
+ self.sens_yaw = sens_yaw
+ self.sens_pitch = sens_pitch
+ self.sens_roll = sens_roll
+ self.max_angle = max_angle
+ self._ui = None
+ self._running = False
+ self._thread: threading.Thread | None = None
+ self._lock = threading.Lock()
+ self._pose = PoseData()
+
+ def open(self) -> bool:
+ """Start joystick emulation thread."""
+ try:
+ import evdev.uinput
+ except ImportError:
+ logger.error("evdev not installed. Install: uv add evdev")
+ return False
+
+ with self._lock:
+ if self._running:
+ return True
+
+ try:
+ caps = {
+ evdev.ecodes.EV_ABS: [
+ (evdev.ecodes.ABS_X, evdev.AbsInfo(0, _AXIS_MIN, _AXIS_MAX, 0, 0, 0)),
+ (evdev.ecodes.ABS_Y, evdev.AbsInfo(0, _AXIS_MIN, _AXIS_MAX, 0, 0, 0)),
+ (evdev.ecodes.ABS_Z, evdev.AbsInfo(0, _AXIS_MIN, _AXIS_MAX, 0, 0, 0)),
+ (evdev.ecodes.ABS_RX, evdev.AbsInfo(0, _AXIS_MIN, _AXIS_MAX, 0, 0, 0)),
+ (evdev.ecodes.ABS_RY, evdev.AbsInfo(0, _AXIS_MIN, _AXIS_MAX, 0, 0, 0)),
+ (evdev.ecodes.ABS_RZ, evdev.AbsInfo(0, _AXIS_MIN, _AXIS_MAX, 0, 0, 0)),
+ ],
+ evdev.ecodes.EV_KEY: [evdev.ecodes.BTN_TRIGGER],
+ }
+ self._ui = evdev.uinput.UInput(
+ caps, name="PortProtonQt Head Tracking", version=0x3
+ )
+ except Exception as exc:
+ logger.error("Failed to create uinput joystick: %s", exc)
+ return False
+
+ self._running = True
+ self._thread = threading.Thread(target=self._run, daemon=True)
+ self._thread.start()
+ logger.info("Joystick output started")
+ return True
+
+ def close(self) -> None:
+ """Stop joystick emulation."""
+ with self._lock:
+ self._running = False
+ if self._ui:
+ try:
+ self._ui.close()
+ except Exception:
+ pass
+ self._ui = None
+
+ if self._thread:
+ self._thread.join(timeout=1.0)
+ self._thread = None
+ logger.info("Joystick output stopped")
+
+ def write(self, pose: PoseData) -> None:
+ """Update current head pose."""
+ with self._lock:
+ self._pose = pose
+
+ def _run(self) -> None:
+ """Main emulation loop."""
+ last_values: dict[int, int] = {}
+
+ while True:
+ with self._lock:
+ if not self._running:
+ break
+ pose = self._pose
+ ui = self._ui
+
+ if ui is None:
+ time.sleep(0.016)
+ continue
+
+ values = {
+ evdev.ecodes.ABS_X: _degrees_to_axis(pose.yaw * self.sens_yaw, self.max_angle),
+ evdev.ecodes.ABS_Y: _degrees_to_axis(pose.pitch * self.sens_pitch, self.max_angle),
+ evdev.ecodes.ABS_Z: _degrees_to_axis(pose.roll * self.sens_roll, self.max_angle),
+ evdev.ecodes.ABS_RX: _degrees_to_axis(pose.x * self.sens_yaw * 10, self.max_angle),
+ evdev.ecodes.ABS_RY: _degrees_to_axis(pose.y * self.sens_pitch * 10, self.max_angle),
+ evdev.ecodes.ABS_RZ: _degrees_to_axis(pose.z * self.sens_roll * 10, self.max_angle),
+ }
+
+ try:
+ for axis, val in values.items():
+ if last_values.get(axis) != val:
+ ui.write(evdev.ecodes.EV_ABS, axis, val)
+ last_values[axis] = val
+ ui.syn()
+ except Exception as exc:
+ logger.warning("Joystick output write failed: %s", exc)
+
+ time.sleep(0.016)
diff --git a/portprotonqt/head_tracking/outputs/mouse_output.py b/portprotonqt/head_tracking/outputs/mouse_output.py
new file mode 100644
index 00000000..dd9e3900
--- /dev/null
+++ b/portprotonqt/head_tracking/outputs/mouse_output.py
@@ -0,0 +1,128 @@
+"""Mouse emulation output for head tracking."""
+
+import math
+import threading
+import time
+from collections.abc import Sequence
+
+import evdev
+
+from portprotonqt.head_tracking.outputs.base_output import BaseOutput
+from portprotonqt.head_tracking.tracker_base import PoseData
+from portprotonqt.logger import get_logger
+
+logger = get_logger(__name__)
+
+
+class MouseOutput(BaseOutput):
+ """Emulates mouse movement from head tracking yaw/pitch."""
+
+ def __init__(
+ self,
+ sens_yaw: float = 10.0,
+ sens_pitch: float = 10.0,
+ deadzone: float = 0.05,
+ ) -> None:
+ self.sens_yaw = sens_yaw
+ self.sens_pitch = sens_pitch
+ self.deadzone = deadzone
+ self._ui = None
+ self._running = False
+ self._thread: threading.Thread | None = None
+ self._lock = threading.Lock()
+ self._pose = PoseData()
+
+ def open(self) -> bool:
+ """Start mouse emulation thread."""
+ try:
+ import evdev.uinput
+ except ImportError:
+ logger.error("evdev not installed. Install: uv add evdev")
+ return False
+
+ with self._lock:
+ if self._running:
+ return True
+
+ try:
+ caps: dict[int, Sequence[int]] = {
+ evdev.ecodes.EV_REL: [evdev.ecodes.REL_X, evdev.ecodes.REL_Y],
+ evdev.ecodes.EV_KEY: [evdev.ecodes.BTN_LEFT],
+ }
+ self._ui = evdev.uinput.UInput(
+ caps, name="PortProtonQt Head Tracking"
+ )
+ except Exception as exc:
+ logger.error("Failed to create uinput mouse: %s", exc)
+ return False
+
+ self._running = True
+ self._thread = threading.Thread(target=self._run, daemon=True)
+ self._thread.start()
+ logger.info("Mouse output started")
+ return True
+
+ def close(self) -> None:
+ """Stop mouse emulation."""
+ with self._lock:
+ self._running = False
+ if self._ui:
+ try:
+ self._ui.close()
+ except Exception:
+ pass
+ self._ui = None
+
+ if self._thread:
+ self._thread.join(timeout=1.0)
+ self._thread = None
+ logger.info("Mouse output stopped")
+
+ def write(self, pose: PoseData) -> None:
+ """Update current head pose."""
+ with self._lock:
+ self._pose = pose
+
+ def _run(self) -> None:
+ """Main emulation loop."""
+ accum_x = 0.0
+ accum_y = 0.0
+
+ while True:
+ with self._lock:
+ if not self._running:
+ break
+ pose = self._pose
+ ui = self._ui
+
+ if ui is None:
+ time.sleep(0.016)
+ continue
+
+ yaw = pose.yaw if abs(pose.yaw) > self.deadzone else 0.0
+ pitch = pose.pitch if abs(pose.pitch) > self.deadzone else 0.0
+
+ # Reset accumulation when head is centered to prevent drift
+ if yaw == 0.0:
+ accum_x = 0.0
+ if pitch == 0.0:
+ accum_y = 0.0
+
+ # Accumulate fractional movement to avoid jitter at low speeds
+ accum_x += -math.radians(yaw) * self.sens_yaw
+ accum_y += math.radians(pitch) * self.sens_pitch
+
+ dx = int(accum_x)
+ dy = int(accum_y)
+
+ if dx != 0 or dy != 0:
+ try:
+ ui.write(evdev.ecodes.EV_REL, evdev.ecodes.REL_X, dx)
+ ui.write(evdev.ecodes.EV_REL, evdev.ecodes.REL_Y, dy)
+ ui.syn()
+ accum_x -= dx
+ accum_y -= dy
+ except Exception as exc:
+ logger.warning("Mouse output write failed: %s", exc)
+
+ time.sleep(0.016)
diff --git a/portprotonqt/head_tracking/outputs/npclient_output.py b/portprotonqt/head_tracking/outputs/npclient_output.py
new file mode 100644
index 00000000..d21e5d0d
--- /dev/null
+++ b/portprotonqt/head_tracking/outputs/npclient_output.py
@@ -0,0 +1,124 @@
+"""NPClient64 DLL output for native TrackIR SDK games.
+
+This output writes head tracking data to the Wine SHM file and ensures
+that a compatible NPClient64.dll is present in the game's Wine prefix.
+Games using the TrackIR SDK (e.g., DCS World) load this DLL to receive
+tracking data.
+
+The actual DLL implementation is provided as a separate MinGW-built
+binary placed next to the PortProtonQt data files.
+"""
+
+import os
+import shutil
+import subprocess
+
+from portprotonqt.head_tracking.outputs.base_output import BaseOutput
+from portprotonqt.head_tracking.outputs.shm_output import ShmOutput
+from portprotonqt.head_tracking.tracker_base import PoseData
+from portprotonqt.logger import get_logger
+
+logger = get_logger(__name__)
+
+_DLL_NAME = "NPClient64.dll"
+_DLL_SOURCE_CANDIDATES = [
+ # Development / user data
+ "data/NPClient64.dll",
+ # Build output
+ "builddir/build-aux/npclient/NPClient64.dll",
+ # Installed system paths
+ "/usr/share/portprotonqt/NPClient64.dll",
+ "/usr/local/share/portprotonqt/NPClient64.dll",
+]
+
+
+class NpclientOutput(BaseOutput):
+ """Output via NPClient64.dll for TrackIR-native games."""
+
+ def __init__(self, prefix_path: str | None = None) -> None:
+ self.prefix_path = prefix_path
+ self._shm = ShmOutput()
+
+ def open(self) -> bool:
+ """Open SHM and deploy DLL if prefix is known."""
+ if not self._shm.open():
+ return False
+ if self.prefix_path:
+ self._deploy_dll(self.prefix_path)
+ return True
+
+ def close(self) -> None:
+ """Close SHM output."""
+ self._shm.close()
+
+ def write(self, pose: PoseData) -> None:
+ """Write pose to Wine SHM (DLL reads from there)."""
+ self._shm.write(pose)
+
+ def _deploy_dll(self, prefix_path: str) -> None:
+ """Copy NPClient64.dll into Wine prefix and set registry keys."""
+ source = self._find_dll_source()
+ if not source:
+ logger.warning("NPClient64.dll not found; game may not detect tracker")
+ return
+
+ target = os.path.join(prefix_path, "drive_c", "windows", "system32", _DLL_NAME)
+ try:
+ os.makedirs(os.path.dirname(target), exist_ok=True)
+ shutil.copy2(source, target)
+ logger.info("Deployed %s to %s", _DLL_NAME, target)
+ except Exception as exc:
+ logger.warning("Failed to deploy %s to %s: %s", _DLL_NAME, target, exc)
+ return
+
+ self._setup_registry(prefix_path, os.path.dirname(target))
+
+ def _setup_registry(self, prefix_path: str, dll_dir: str) -> None:
+ """Set Wine registry keys so games find the DLL."""
+ wine_path = self._find_wine(prefix_path)
+ if not wine_path:
+ logger.warning("Wine not found; cannot set NPClient registry keys")
+ return
+
+ env = os.environ.copy()
+ env["WINEPREFIX"] = prefix_path
+
+ # Use forward slashes and add trailing slash as expected by games
+ dll_dir_fwd = dll_dir.replace("\\", "/")
+ if not dll_dir_fwd.endswith("/"):
+ dll_dir_fwd += "/"
+
+ keys = [
+ (r"HKCU\Software\NaturalPoint\NATURALPOINT\NPClient Location", "Path", dll_dir_fwd),
+ (r"HKCU\Software\Freetrack\FreeTrackClient", "Path", dll_dir_fwd),
+ ]
+
+ for key, value_name, value in keys:
+ try:
+ subprocess.run(
+ [wine_path, "reg", "add", key, "/v", value_name, "/d", value, "/f"],
+ env=env,
+ check=False,
+ capture_output=True,
+ timeout=30,
+ )
+ logger.info("Set registry key %s\\%s", key, value_name)
+ except Exception as exc:
+ logger.warning("Failed to set registry key %s: %s", key, exc)
+
+ @staticmethod
+ def _find_wine(prefix_path: str) -> str | None:
+ """Locate wine binary for the given prefix."""
+ candidates = ["wine", "/usr/bin/wine", "/usr/local/bin/wine"]
+ for candidate in candidates:
+ if shutil.which(candidate):
+ return candidate
+ return None
+
+ @staticmethod
+ def _find_dll_source() -> str | None:
+ """Locate bundled NPClient64.dll."""
+ for candidate in _DLL_SOURCE_CANDIDATES:
+ if os.path.exists(candidate):
+ return candidate
+ return None
diff --git a/portprotonqt/head_tracking/outputs/shm_output.py b/portprotonqt/head_tracking/outputs/shm_output.py
new file mode 100644
index 00000000..15f8940a
--- /dev/null
+++ b/portprotonqt/head_tracking/outputs/shm_output.py
@@ -0,0 +1,89 @@
+"""Wine SHM output for head tracking."""
+
+import math
+import mmap
+import os
+import struct
+
+from portprotonqt.head_tracking.outputs.base_output import BaseOutput
+from portprotonqt.head_tracking.tracker_base import PoseData
+from portprotonqt.logger import get_logger
+
+logger = get_logger(__name__)
+
+# Wine SHM protocol constants (TrackIR/FreeTrack compatible)
+WINE_SHM_NAME = "/facetracknoir-wine-shm"
+_SHM_FORMAT = "6d2i8b?"
+_SHM_SIZE = struct.calcsize(_SHM_FORMAT)
+
+
+class ShmOutput(BaseOutput):
+ """Writes head tracking data to Wine shared memory file."""
+
+ def __init__(self) -> None:
+ self._shm: mmap.mmap | None = None
+
+ def open(self) -> bool:
+ """Open shared memory file compatible with Wine."""
+ if self._shm is not None:
+ return True
+
+ wine_shm_path = "/dev/shm/facetracknoir-wine-shm"
+ try:
+ fd = os.open(wine_shm_path, os.O_CREAT | os.O_RDWR, 0o666)
+ os.ftruncate(fd, _SHM_SIZE)
+ self._shm = mmap.mmap(fd, _SHM_SIZE)
+ os.close(fd)
+ self._write_pose(PoseData(), gameid=0, stop=False)
+ logger.info("Wine SHM opened: %s", wine_shm_path)
+ return True
+ except Exception as exc:
+ logger.error("Failed to open Wine SHM: %s", exc)
+ return False
+
+ def close(self) -> None:
+ """Close shared memory."""
+ if self._shm:
+ try:
+ self._write_pose(PoseData(), gameid=0, stop=True)
+ except Exception:
+ pass
+ self._shm.close()
+ self._shm = None
+
+ wine_shm_path = "/dev/shm/facetracknoir-wine-shm"
+ try:
+ if os.path.exists(wine_shm_path):
+ os.unlink(wine_shm_path)
+ except Exception:
+ pass
+ logger.info("Wine SHM closed")
+
+ def write(self, pose: PoseData, gameid: int = 0) -> None:
+ """Write pose data to shared memory."""
+ self._write_pose(pose, gameid, stop=False)
+
+ def _write_pose(
+ self,
+ pose: PoseData,
+ gameid: int,
+ stop: bool,
+ ) -> None:
+ """Internal write."""
+ if self._shm is None:
+ return
+
+ data = (
+ pose.x * 10.0,
+ pose.y * 10.0,
+ pose.z * 10.0,
+ math.radians(pose.yaw),
+ math.radians(pose.pitch),
+ math.radians(pose.roll),
+ gameid,
+ 0,
+ 0, 0, 0, 0, 0, 0, 0, 0,
+ stop,
+ )
+ self._shm.seek(0)
+ self._shm.write(struct.pack(_SHM_FORMAT, *data))
diff --git a/portprotonqt/head_tracking/outputs/udp_output.py b/portprotonqt/head_tracking/outputs/udp_output.py
new file mode 100644
index 00000000..2b0c892c
--- /dev/null
+++ b/portprotonqt/head_tracking/outputs/udp_output.py
@@ -0,0 +1,71 @@
+"""UDP output for head tracking.
+
+Sends 6-DOF pose data as 6 doubles over UDP.
+Compatible with opentrack UDP protocol.
+"""
+
+import math
+import socket
+import struct
+
+from portprotonqt.head_tracking.outputs.base_output import BaseOutput
+from portprotonqt.head_tracking.tracker_base import PoseData
+from portprotonqt.logger import get_logger
+
+logger = get_logger(__name__)
+
+# opentrack UDP format: 6 doubles (little-endian)
+# x, y, z in mm (pose * 10)
+# yaw, pitch, roll in radians
+_UDP_FORMAT = "<6d"
+_DEFAULT_HOST = "127.0.0.1"
+_DEFAULT_PORT = 4242
+
+
+class UdpOutput(BaseOutput):
+ """Send head tracking data over UDP."""
+
+ def __init__(self, host: str = _DEFAULT_HOST, port: int = _DEFAULT_PORT) -> None:
+ self.host = host
+ self.port = port
+ self._sock: socket.socket | None = None
+
+ def open(self) -> bool:
+ """Open UDP socket."""
+ try:
+ self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ logger.info("UDP output opened: %s:%d", self.host, self.port)
+ return True
+ except Exception as exc:
+ logger.error("Failed to open UDP socket: %s", exc)
+ return False
+
+ def close(self) -> None:
+ """Close UDP socket."""
+ if self._sock:
+ try:
+ self._sock.close()
+ except Exception:
+ pass
+ self._sock = None
+ logger.info("UDP output closed")
+
+ def write(self, pose: PoseData) -> None:
+ """Send pose data over UDP."""
+ if self._sock is None:
+ return
+
+ data = (
+ pose.x * 10.0,
+ pose.y * 10.0,
+ pose.z * 10.0,
+ math.radians(pose.yaw),
+ math.radians(pose.pitch),
+ math.radians(pose.roll),
+ )
+ packet = struct.pack(_UDP_FORMAT, *data)
+
+ try:
+ self._sock.sendto(packet, (self.host, self.port))
+ except Exception as exc:
+ logger.warning("UDP send failed: %s", exc)
diff --git a/portprotonqt/head_tracking/tracker_base.py b/portprotonqt/head_tracking/tracker_base.py
new file mode 100644
index 00000000..e978cf0f
--- /dev/null
+++ b/portprotonqt/head_tracking/tracker_base.py
@@ -0,0 +1,69 @@
+"""Base tracker interface for head tracking."""
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ import numpy as np
+
+
+@dataclass
+class PoseData:
+ """6-DOF head pose data."""
+
+ x: float = 0.0 # Translation X (cm)
+ y: float = 0.0 # Translation Y (cm)
+ z: float = 0.0 # Translation Z (cm)
+ yaw: float = 0.0 # Rotation Yaw (degrees)
+ pitch: float = 0.0 # Rotation Pitch (degrees)
+ roll: float = 0.0 # Rotation Roll (degrees)
+
+ def apply_offset(self, offset: "PoseData") -> "PoseData":
+ """Subtract offset from this pose."""
+ return PoseData(
+ x=self.x - offset.x,
+ y=self.y - offset.y,
+ z=self.z - offset.z,
+ yaw=self.yaw - offset.yaw,
+ pitch=self.pitch - offset.pitch,
+ roll=self.roll - offset.roll,
+ )
+
+ def apply_sensitivity(self, sens: "PoseData") -> "PoseData":
+ """Apply per-axis sensitivity multipliers."""
+ return PoseData(
+ x=self.x * sens.x,
+ y=self.y * sens.y,
+ z=self.z * sens.z,
+ yaw=self.yaw * sens.yaw,
+ pitch=self.pitch * sens.pitch,
+ roll=self.roll * sens.roll,
+ )
+
+
+class BaseTracker(ABC):
+ """Abstract base class for head trackers."""
+
+ @abstractmethod
+ def start(self) -> bool:
+ """Start tracking. Returns True on success."""
+
+ @abstractmethod
+ def stop(self) -> None:
+ """Stop tracking and release resources."""
+
+ @abstractmethod
+ def get_pose(self) -> PoseData | None:
+ """Get current head pose. Returns None if tracking lost."""
+
+ def get_frame(self) -> tuple["np.ndarray | None", PoseData | None]:
+ """Get current camera frame with landmarks drawn and pose.
+
+ Default implementation returns no frame; subclasses may override.
+ """
+ return None, self.get_pose()
+
+ @abstractmethod
+ def is_running(self) -> bool:
+ """Check if tracker is active."""
diff --git a/portprotonqt/head_tracking/tracker_camera.py b/portprotonqt/head_tracking/tracker_camera.py
new file mode 100644
index 00000000..01966cfd
--- /dev/null
+++ b/portprotonqt/head_tracking/tracker_camera.py
@@ -0,0 +1,350 @@
+"""Camera-based head tracker using OpenCV YuNet DNN."""
+
+import math
+import os
+import time
+
+import cv2
+import numpy as np
+
+from portprotonqt.head_tracking.tracker_base import BaseTracker, PoseData
+from portprotonqt.logger import get_logger
+
+# Module-level shared VideoCapture to avoid V4L2 device busy issues
+_shared_cap: cv2.VideoCapture | None = None
+_shared_cap_index: int = -1
+
+logger = get_logger(__name__)
+
+
+def _find_yunet_model() -> str | None:
+ """Find YuNet model file in known install locations."""
+ try:
+ import portprotonqt
+ module_dir = os.path.dirname(portprotonqt.__file__)
+ except ImportError:
+ module_dir = ""
+
+ project_root = os.path.dirname(module_dir) if module_dir else ""
+
+ candidates = []
+
+ if project_root:
+ candidates.append(
+ os.path.join(project_root, "data", "face_detection_yunet_2023mar.onnx")
+ )
+
+ xdg_data_dirs = os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share")
+ for d in xdg_data_dirs.split(":"):
+ candidates.append(
+ os.path.join(d, "portprotonqt", "face_detection_yunet_2023mar.onnx")
+ )
+
+ candidates.append("/usr/share/portprotonqt/face_detection_yunet_2023mar.onnx")
+ candidates.append("/usr/local/share/portprotonqt/face_detection_yunet_2023mar.onnx")
+
+ for c in candidates:
+ if os.path.exists(c):
+ return c
+
+ return None
+
+
+def _create_detector(model_path: str) -> cv2.FaceDetectorYN | None:
+ """Create YuNet face detector from model file."""
+ try:
+ detector = cv2.FaceDetectorYN.create(
+ model=model_path,
+ config="",
+ input_size=(320, 320),
+ score_threshold=0.3,
+ nms_threshold=0.3,
+ top_k=5000,
+ )
+ return detector
+ except Exception as exc:
+ logger.error("Failed to create YuNet detector: %s", exc)
+ return None
+
+
+class CameraTracker(BaseTracker):
+ """Head tracker using webcam and OpenCV YuNet DNN."""
+
+ def __init__(self, camera_index: int = 0) -> None:
+ self.camera_index = camera_index
+ self._cap: cv2.VideoCapture | None = None
+ self._detector: cv2.FaceDetectorYN | None = None
+ self._running = False
+ self._last_pose = PoseData()
+ self._last_face: tuple[int, int, int, int] | None = None
+ self._last_landmarks: list[tuple[int, int]] | None = None
+ self._pose_history: list[PoseData] = []
+ self._history_size = 5
+ self._lost_frames = 0
+ self._max_lost_frames = 5
+ # Calibration: neutral pose at start
+ self._calibrated = False
+ self._neutral_yaw = 0.0
+ self._neutral_pitch = 0.0
+ self._neutral_roll = 0.0
+ self._neutral_x = 0.0
+ self._neutral_y = 0.0
+
+ def _get_or_create_detector(self) -> cv2.FaceDetectorYN | None:
+ """Get or create shared YuNet detector."""
+ if self._detector is not None:
+ return self._detector
+
+ model_path = _find_yunet_model()
+ if not model_path:
+ logger.error("YuNet model not found")
+ return None
+
+ self._detector = _create_detector(model_path)
+ return self._detector
+
+ def start(self) -> bool:
+ """Start camera and initialize YuNet detector."""
+ global _shared_cap, _shared_cap_index
+
+ detector = self._get_or_create_detector()
+ if detector is None:
+ return False
+
+ if (
+ _shared_cap is not None
+ and _shared_cap_index == self.camera_index
+ and _shared_cap.isOpened()
+ ):
+ self._cap = _shared_cap
+ self._running = True
+ logger.info("Camera tracker reused existing capture")
+ return True
+
+ if _shared_cap is not None:
+ _shared_cap.release()
+ _shared_cap = None
+
+ for attempt in range(5):
+ _shared_cap = cv2.VideoCapture(self.camera_index)
+ if _shared_cap.isOpened():
+ break
+ if _shared_cap:
+ _shared_cap.release()
+ _shared_cap = None
+ if attempt < 4:
+ time.sleep(0.5)
+ else:
+ logger.error("Cannot open camera %s after retries", self.camera_index)
+ return False
+
+ _shared_cap_index = self.camera_index
+ self._cap = _shared_cap
+ self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
+ self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
+
+ self._running = True
+ self._calibrated = False
+ logger.info("Camera tracker started")
+ return True
+
+ def stop(self) -> None:
+ """Stop tracking and release camera capture."""
+ global _shared_cap, _shared_cap_index
+ self._running = False
+ self._detector = None
+ self._cap = None
+ self._calibrated = False
+ if _shared_cap is not None:
+ _shared_cap.release()
+ _shared_cap = None
+ _shared_cap_index = -1
+ logger.info("Camera tracker stopped (capture released)")
+
+ def is_running(self) -> bool:
+ return self._running and self._cap is not None and self._cap.isOpened()
+
+ def get_pose(self) -> PoseData | None:
+ """Capture frame and compute head pose."""
+ if not self.is_running() or self._cap is None:
+ return None
+
+ ret, frame = self._cap.read()
+ if not ret or frame is None:
+ return None
+
+ pose = self._process_frame(frame)
+ if pose:
+ self._last_pose = pose
+ return pose
+
+ def get_frame(self) -> tuple[np.ndarray | None, PoseData | None]:
+ """Get current camera frame with landmarks drawn and pose."""
+ if not self.is_running() or self._cap is None:
+ return None, None
+
+ ret, frame = self._cap.read()
+ if not ret or frame is None:
+ return None, None
+
+ pose = self._process_frame(frame)
+ if pose:
+ self._last_pose = pose
+
+ if self._last_landmarks:
+ for x, y in self._last_landmarks:
+ cv2.circle(frame, (x, y), 2, (0, 255, 0), -1)
+
+ return frame, pose
+
+ def _detect_face(self, frame: np.ndarray) -> np.ndarray | None:
+ """Detect face using YuNet, return face data array."""
+ if self._detector is None:
+ return None
+
+ h, w = frame.shape[:2]
+ self._detector.setInputSize((w, h))
+ retval, faces = self._detector.detect(frame)
+
+ if not retval or faces is None or len(faces) == 0:
+ return None
+
+ # Pick face closest to previous position, or highest score
+ if self._last_face is not None:
+ lx, ly, lw, lh = self._last_face
+ lcx = lx + lw / 2
+ lcy = ly + lh / 2
+ best = min(
+ faces,
+ key=lambda f: (f[0] + f[2] / 2 - lcx) ** 2
+ + (f[1] + f[3] / 2 - lcy) ** 2,
+ )
+ else:
+ best = max(faces, key=lambda f: f[14])
+
+ return best
+
+ def _estimate_pose(
+ self, frame_w: int, frame_h: int, face: np.ndarray
+ ) -> PoseData | None:
+ """Estimate head pose from YuNet face landmarks.
+
+ Uses geometric relationships between facial landmarks
+ rather than solvePnP for stability.
+ """
+ # YuNet: [x, y, w, h, re_x, re_y, le_x, le_y, nt_x, nt_y,
+ # rcm_x, rcm_y, lcm_x, lcm_y, score]
+ re_x, re_y = face[4], face[5] # right eye
+ le_x, le_y = face[6], face[7] # left eye
+ nt_x = face[8] # nose tip x
+
+ # Face center
+ face_cx = face[0] + face[2] / 2
+ face_cy = face[1] + face[3] / 2
+
+ # Eye center
+ eye_cx = (re_x + le_x) / 2
+
+ # Inter-ocular distance (distance between eyes)
+ iod = math.sqrt((le_x - re_x) ** 2 + (le_y - re_y) ** 2)
+ if iod < 1:
+ return None
+
+ # Roll from eye line angle
+ roll = math.degrees(math.atan2(le_y - re_y, le_x - re_x))
+
+ # Yaw from nose position relative to eye center
+ # When looking right, nose is to the right of eye center
+ nose_offset_x = nt_x - eye_cx
+ yaw = math.degrees(math.asin(max(-1.0, min(1.0, nose_offset_x / (iod * 0.8)))))
+
+ # Pitch from vertical shift of face center relative to image center
+ # When looking up, face center moves down on image (nose goes up)
+ face_center_y = face_cy
+ image_center_y = frame_h / 2
+ pitch = (face_center_y - image_center_y) / (frame_h / 2) * 30.0
+
+ # X, Y translation from face center offset
+ dx = face_cx - frame_w / 2
+ dy = face_cy - frame_h / 2
+ # Z from face size (inverse of inter-ocular distance)
+ z = 500.0 / max(iod, 1.0)
+ # Scale X/Y by Z
+ fov_half = 30.0
+ fx_len = frame_w / (2 * math.tan(math.radians(fov_half)))
+ x = dx * z / fx_len
+ y = dy * z / fx_len
+
+ return PoseData(x=x, y=y, z=z, yaw=yaw, pitch=pitch, roll=roll)
+
+ def _process_frame(self, frame: np.ndarray) -> PoseData | None:
+ """Process frame and return smoothed pose."""
+ if self._detector is None:
+ return None
+
+ face = self._detect_face(frame)
+ if face is None:
+ self._lost_frames += 1
+ if self._lost_frames >= self._max_lost_frames:
+ self._last_face = None
+ self._last_landmarks = None
+ return None
+
+ self._lost_frames = 0
+ self._last_face = (int(face[0]), int(face[1]), int(face[2]), int(face[3]))
+ self._last_landmarks = [
+ (int(face[i]), int(face[i + 1])) for i in range(4, 14, 2)
+ ]
+
+ pose = self._estimate_pose(frame.shape[1], frame.shape[0], face)
+ if pose is None:
+ return None
+
+ # Auto-calibrate on first valid frame
+ if not self._calibrated:
+ self._neutral_yaw = pose.yaw
+ self._neutral_pitch = pose.pitch
+ self._neutral_roll = pose.roll
+ self._neutral_x = pose.x
+ self._neutral_y = pose.y
+ self._calibrated = True
+ logger.info(
+ "Head tracking calibrated: yaw=%.1f, pitch=%.1f, roll=%.1f",
+ self._neutral_yaw,
+ self._neutral_pitch,
+ self._neutral_roll,
+ )
+
+ # Subtract neutral pose
+ pose = PoseData(
+ x=pose.x - self._neutral_x,
+ y=pose.y - self._neutral_y,
+ z=pose.z,
+ yaw=pose.yaw - self._neutral_yaw,
+ pitch=pose.pitch - self._neutral_pitch,
+ roll=pose.roll - self._neutral_roll,
+ )
+
+ self._pose_history.append(pose)
+ if len(self._pose_history) > self._history_size:
+ self._pose_history.pop(0)
+
+ return self._smooth_poses(self._pose_history)
+
+ @staticmethod
+ def _smooth_poses(history: list[PoseData]) -> PoseData:
+ """Compute moving average of pose history."""
+ n = len(history)
+ if n == 0:
+ return PoseData()
+ if n == 1:
+ return history[0]
+
+ return PoseData(
+ x=sum(p.x for p in history) / n,
+ y=sum(p.y for p in history) / n,
+ z=sum(p.z for p in history) / n,
+ yaw=sum(p.yaw for p in history) / n,
+ pitch=sum(p.pitch for p in history) / n,
+ roll=sum(p.roll for p in history) / n,
+ )
diff --git a/portprotonqt/head_tracking/tracker_manager.py b/portprotonqt/head_tracking/tracker_manager.py
new file mode 100644
index 00000000..8e535e3f
--- /dev/null
+++ b/portprotonqt/head_tracking/tracker_manager.py
@@ -0,0 +1,190 @@
+"""Manager for head tracking lifecycle."""
+
+import threading
+
+import numpy as np
+
+from portprotonqt.head_tracking.outputs.base_output import BaseOutput
+from portprotonqt.head_tracking.outputs.flightgear_output import FlightGearOutput
+from portprotonqt.head_tracking.outputs.joystick_output import JoystickOutput
+from portprotonqt.head_tracking.outputs.mouse_output import MouseOutput
+from portprotonqt.head_tracking.outputs.npclient_output import NpclientOutput
+from portprotonqt.head_tracking.outputs.shm_output import ShmOutput
+from portprotonqt.head_tracking.outputs.udp_output import UdpOutput
+from portprotonqt.head_tracking.tracker_base import BaseTracker, PoseData
+from portprotonqt.head_tracking.tracking_worker import TrackingWorker
+from portprotonqt.logger import get_logger
+
+logger = get_logger(__name__)
+
+
+class TrackerManager:
+ """Manages tracker and output lifecycle."""
+
+ def __init__(self, prefix_path: str | None = None) -> None:
+ self.tracker: BaseTracker | None = None
+ self.output: BaseOutput | None = None
+ self.worker: TrackingWorker | None = None
+ self._lock = threading.Lock()
+ self.calibration_offset: PoseData | None = None
+ self.sensitivity: PoseData = PoseData(1.0, 1.0, 1.0, 1.0, 1.0, 1.0)
+ self.output_mode: str = "shm"
+ self.mouse_sens_yaw: float = 10.0
+ self.mouse_sens_pitch: float = 10.0
+ self.mouse_deadzone: float = 0.05
+ self.udp_host: str = "127.0.0.1"
+ self.udp_port: int = 4242
+ self.prefix_path = prefix_path
+
+ def _create_output(self) -> BaseOutput | None:
+ """Create output based on current mode."""
+ if self.output_mode == "mouse":
+ return MouseOutput(
+ sens_yaw=self.mouse_sens_yaw,
+ sens_pitch=self.mouse_sens_pitch,
+ deadzone=self.mouse_deadzone,
+ )
+ if self.output_mode == "joystick":
+ return JoystickOutput()
+ if self.output_mode == "npclient":
+ return NpclientOutput(prefix_path=self.prefix_path)
+ if self.output_mode == "udp":
+ return UdpOutput(host=self.udp_host, port=self.udp_port)
+ if self.output_mode == "flightgear":
+ return FlightGearOutput(host=self.udp_host, port=self.udp_port)
+ return ShmOutput()
+
+ def start(self, tracker: BaseTracker) -> bool:
+ """Start tracking with given tracker."""
+ with self._lock:
+ if self.is_running():
+ logger.warning("Tracker already running")
+ return False
+
+ if not tracker.start():
+ return False
+
+ output = self._create_output()
+ if output is None or not output.open():
+ tracker.stop()
+ return False
+
+ self.tracker = tracker
+ self.output = output
+ self.worker = TrackingWorker(
+ tracker,
+ output,
+ calibration_offset=self.calibration_offset,
+ sensitivity=self.sensitivity,
+ )
+ self.worker.start()
+ logger.info("Tracker manager started (mode=%s)", self.output_mode)
+ return True
+
+ def stop(self) -> None:
+ """Stop tracking and cleanup."""
+ with self._lock:
+ if self.tracker:
+ self.tracker.stop()
+
+ if self.worker:
+ self.worker.stop()
+ self.worker = None
+
+ self.tracker = None
+ if self.output:
+ self.output.close()
+ self.output = None
+
+ logger.info("Tracker manager stopped")
+
+ def is_running(self) -> bool:
+ """Check if tracking is active."""
+ return self.worker is not None and self.worker.is_alive()
+
+ def get_preview_frame(self) -> tuple[np.ndarray | None, PoseData | None]:
+ """Get preview frame and calibrated pose for UI."""
+ if self.tracker is None:
+ return None, None
+
+ frame, pose = self.tracker.get_frame()
+
+ if pose:
+ pose = self._apply_calibration_and_sensitivity(pose)
+
+ return frame, pose
+
+ def _apply_calibration_and_sensitivity(self, pose: PoseData) -> PoseData:
+ """Apply calibration offset and sensitivity to pose."""
+ if self.calibration_offset:
+ pose = pose.apply_offset(self.calibration_offset)
+ return pose.apply_sensitivity(self.sensitivity)
+
+ def calibrate(self) -> None:
+ """Set current pose as zero offset."""
+ if self.tracker is None:
+ return
+ raw = self.tracker.get_pose()
+ if raw:
+ self.calibration_offset = raw
+ if self.worker is not None:
+ self.worker.calibration_offset = raw
+ logger.info("Head tracking calibrated: %s", raw)
+
+ def set_sensitivity(self, sens: PoseData) -> None:
+ """Set per-axis sensitivity multipliers."""
+ self.sensitivity = sens
+ if self.worker is not None:
+ self.worker.sensitivity = sens
+
+ def set_output_mode(
+ self,
+ mode: str,
+ mouse_sens_yaw: float = 10.0,
+ mouse_sens_pitch: float = 10.0,
+ mouse_deadzone: float = 0.05,
+ udp_host: str = "127.0.0.1",
+ udp_port: int = 4242,
+ ) -> None:
+ """Set output mode and parameters; restart output if running."""
+ with self._lock:
+ changed = (
+ self.output_mode != mode
+ or self.mouse_sens_yaw != mouse_sens_yaw
+ or self.mouse_sens_pitch != mouse_sens_pitch
+ or self.mouse_deadzone != mouse_deadzone
+ or self.udp_host != udp_host
+ or self.udp_port != udp_port
+ )
+ self.output_mode = mode
+ self.mouse_sens_yaw = mouse_sens_yaw
+ self.mouse_sens_pitch = mouse_sens_pitch
+ self.mouse_deadzone = mouse_deadzone
+ self.udp_host = udp_host
+ self.udp_port = udp_port
+
+ if changed and self.is_running() and self.tracker is not None:
+ # Restart output with new settings
+ if self.worker:
+ self.worker.stop()
+ self.worker = None
+ if self.output:
+ self.output.close()
+ self.output = None
+
+ output = self._create_output()
+ if output is None or not output.open():
+ logger.error("Failed to restart output in mode %s", mode)
+ self.tracker.stop()
+ self.tracker = None
+ return
+
+ self.output = output
+ self.worker = TrackingWorker(
+ self.tracker,
+ output,
+ calibration_offset=self.calibration_offset,
+ sensitivity=self.sensitivity,
+ )
+ self.worker.start()
+ logger.info("Tracker output restarted (mode=%s)", mode)
diff --git a/portprotonqt/head_tracking/tracking_worker.py b/portprotonqt/head_tracking/tracking_worker.py
new file mode 100644
index 00000000..e3607ac9
--- /dev/null
+++ b/portprotonqt/head_tracking/tracking_worker.py
@@ -0,0 +1,51 @@
+"""Background worker for head tracking outputs."""
+
+import threading
+
+from portprotonqt.head_tracking.outputs.base_output import BaseOutput
+from portprotonqt.head_tracking.tracker_base import BaseTracker, PoseData
+from portprotonqt.logger import get_logger
+
+logger = get_logger(__name__)
+
+
+class TrackingWorker(threading.Thread):
+ """Background worker that reads tracker and writes to active output."""
+
+ def __init__(
+ self,
+ tracker: BaseTracker,
+ output: BaseOutput,
+ update_interval: float = 0.016, # ~60 FPS
+ calibration_offset: PoseData | None = None,
+ sensitivity: PoseData | None = None,
+ ) -> None:
+ super().__init__(daemon=True)
+ self.tracker = tracker
+ self.output = output
+ self.update_interval = update_interval
+ self.calibration_offset = calibration_offset
+ self.sensitivity = sensitivity or PoseData(1.0, 1.0, 1.0, 1.0, 1.0, 1.0)
+ self._stop_event = threading.Event()
+
+ def stop(self) -> None:
+ """Request worker to stop."""
+ self._stop_event.set()
+ self.join(timeout=2.0)
+
+ def _apply_calibration_and_sensitivity(self, pose: PoseData) -> PoseData:
+ """Apply calibration offset and sensitivity."""
+ if self.calibration_offset:
+ pose = pose.apply_offset(self.calibration_offset)
+ return pose.apply_sensitivity(self.sensitivity)
+
+ def run(self) -> None:
+ """Main worker loop."""
+ logger.info("Tracking worker started")
+ while not self._stop_event.is_set():
+ pose = self.tracker.get_pose()
+ if pose:
+ pose = self._apply_calibration_and_sensitivity(pose)
+ self.output.write(pose)
+ self._stop_event.wait(self.update_interval)
+ logger.info("Tracking worker stopped")
diff --git a/portprotonqt/input_manager.py b/portprotonqt/input_manager.py
index 7a5d6faa..613cde43 100644
--- a/portprotonqt/input_manager.py
+++ b/portprotonqt/input_manager.py
@@ -59,6 +59,8 @@ class MainWindowProtocol(Protocol):
current_add_game_dialog: AddGameDialog | None
game_library_manager: Any # GameLibraryManager - using Any to avoid circular import
auto_size_slider: QSlider | None
+ def toggleHeadtracking(self) -> None:
+ ...
PAD_BUTTON_SOUTH = 1000
PAD_BUTTON_EAST = 1001
@@ -3705,6 +3707,9 @@ class InputManager(QObject):
if key == pygame.K_F11 and not self._is_gamescope_session:
self.toggle_fullscreen.emit(not self._is_fullscreen)
return True
+ if key == pygame.K_SCROLLLOCK:
+ self._parent.toggleHeadtracking()
+ return True
return False
def _handle_pygame_file_explorer_key(self, key: int) -> bool:
diff --git a/portprotonqt/main_window.py b/portprotonqt/main_window.py
index 62257b65..467b66a8 100644
--- a/portprotonqt/main_window.py
+++ b/portprotonqt/main_window.py
@@ -9,7 +9,11 @@ from queue import Empty, Queue
from threading import Thread
from portprotonqt.logger import get_logger
from portprotonqt.icon_extractor import generate_thumbnail
-from portprotonqt.dialogs import FileExplorer, ExeSettingsDialog
+from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog, ExeSettingsDialog
+from portprotonqt.head_tracking.tracker_base import PoseData
+from portprotonqt.head_tracking.tracker_camera import CameraTracker
+from portprotonqt.head_tracking.tracker_manager import TrackerManager
+from portprotonqt.global_hotkey import GlobalHotkeyEvdev
from portprotonqt.game_card import GameCard
from portprotonqt.animations import DetailPageAnimations
from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel
@@ -60,9 +64,9 @@ from portprotonqt.tabs.wine_tab import MainWindowWineTabMixin
from portprotonqt.tabs.workers import MainWindowWorkersMixin
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox,
- QMessageBox, QApplication, QPushButton, QCheckBox)
-from PySide6.QtCore import Qt, QEvent, QUrl, Signal, QTimer, Slot, QProcess, QFileSystemWatcher, QObject
-from PySide6.QtGui import QColor, QDesktopServices, QHideEvent, QShowEvent, QGuiApplication
+ QDialog, QFormLayout, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout, QScrollArea, QScroller, QSlider, QFrame)
+from PySide6.QtCore import Qt, QAbstractAnimation, QEvent, QUrl, Signal, QTimer, Slot, QProcess, QProcessEnvironment, QFileSystemWatcher, QStandardPaths, QObject
+from PySide6.QtGui import QIcon, QColor, QDesktopServices, QHideEvent, QShowEvent, QKeySequence, QShortcut, QGuiApplication
from typing import cast
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
@@ -248,6 +252,27 @@ class MainWindow(
self.portproton_location = get_portproton_location()
self.start_sh = get_portproton_start_command()
self.launch_exe = launch_exe # Store launch_exe path
+ self.headtracking_manager = None
+ self._headtracking_settings: dict[str, str] | None = None
+ self._headtracking_exe_path: str | None = None
+
+ # Hotkey for toggling head tracking (Insert or Scroll Lock)
+ self._headtracking_shortcut = QShortcut(QKeySequence("Insert"), self)
+ self._headtracking_shortcut.activated.connect(self._toggle_headtracking)
+
+ # Global hotkey via evdev without grab (works in games on Wayland/X11)
+ self._global_hotkey = GlobalHotkeyEvdev()
+ registered = False
+ for key in ("insert", "scrolllock"):
+ if self._global_hotkey.register("toggle_headtracking", "Toggle Head Tracking",
+ self.toggleHeadtracking,
+ hotkey=key,
+ ):
+ registered = True
+ if registered:
+ logger.info("Global hotkey registered via evdev (Insert, Scroll Lock)")
+ else:
+ logger.warning("Failed to register global hotkey via evdev")
self.game_library_manager = GameLibraryManager(self, self.theme, None)
@@ -1893,6 +1918,11 @@ class MainWindow(
self.game_library_manager.update_game_grid(focus_first_card=False)
self._refresh_current_detail_time()
+ # Stop head tracking
+ self._stop_headtracking()
+ self._headtracking_settings = None
+ self._headtracking_exe_path = None
+
def _has_running_game_process(self) -> bool:
return any(proc.poll() is None for proc in self.game_processes)
@@ -1953,8 +1983,132 @@ class MainWindow(
self.checkProcessTimer.deleteLater()
self.checkProcessTimer = None
self.resetPlayButton()
+ # Stop head tracking on manual stop
+ self._stop_headtracking()
+ self._headtracking_settings = None
+ self._headtracking_exe_path = None
+
return True
+ def _start_headtracking_if_enabled(self, exe_path: str) -> None:
+ """Read head tracking settings for this executable."""
+ if not self.portproton_location or not self.start_sh:
+ logger.info("Cannot read HT settings: portproton_location=%s, start_sh=%s",
+ bool(self.portproton_location), bool(self.start_sh))
+ return
+
+ try:
+ result = subprocess.run(
+ self.start_sh + ["cli", "--show-ppdb", exe_path],
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )
+ logger.info("PPDB query for %s: rc=%s, stdout=%s", exe_path, result.returncode, result.stdout[:200])
+ if result.returncode != 0:
+ return
+
+ settings = {}
+ for line in result.stdout.strip().splitlines():
+ if "=" in line:
+ key, value = line.split("=", 1)
+ settings[key.strip()] = value.strip().strip('"\'')
+
+ self._headtracking_settings = settings
+ self._headtracking_exe_path = exe_path
+ logger.info("HT settings saved for %s: PW_HEADTRACKING=%s", exe_path, settings.get("PW_HEADTRACKING"))
+ except Exception as exc:
+ logger.error("Error reading head tracking settings: %s", exc)
+
+ def _gamepad_blocks_headtracking(self) -> bool:
+ """Check if a gamepad is connected and should block head tracking."""
+ return (
+ getattr(self, "input_manager", None) is not None
+ and getattr(self.input_manager, "gamepad", None) is not None
+ )
+
+ def _start_headtracking_with_settings(
+ self, exe_path: str, settings: dict[str, str]
+ ) -> None:
+ """Start head tracking with parsed settings."""
+ if self._gamepad_blocks_headtracking():
+ logger.info("Head tracking blocked: gamepad is connected")
+ return
+ self._stop_headtracking()
+ mode = settings.get("PW_HEADTRACKING_OUTPUT", "shm")
+ mouse_sens_x = float(settings.get("PW_HEADTRACKING_MOUSE_SENS_X", "10"))
+ mouse_sens_y = float(settings.get("PW_HEADTRACKING_MOUSE_SENS_Y", "10"))
+ mouse_deadzone = float(settings.get("PW_HEADTRACKING_MOUSE_DEADZONE", "0.05"))
+
+ prefix_path = None
+ if self.portproton_location:
+ prefix_name = get_prefix_name(exe_path)
+ if prefix_name:
+ candidate = os.path.join(
+ self.portproton_location, "data", "prefixes", prefix_name
+ )
+ if os.path.isdir(candidate):
+ prefix_path = candidate
+
+ self.headtracking_manager = TrackerManager(prefix_path=prefix_path)
+ self.headtracking_manager.set_output_mode(
+ mode,
+ mouse_sens_yaw=mouse_sens_x,
+ mouse_sens_pitch=mouse_sens_y,
+ mouse_deadzone=mouse_deadzone,
+ )
+
+ camera_idx = int(settings.get("PW_HEADTRACKING_CAMERA", "0"))
+ sens = PoseData(
+ x=float(settings.get("PW_HEADTRACKING_SENS_X", "1.0")),
+ y=float(settings.get("PW_HEADTRACKING_SENS_Y", "1.0")),
+ z=float(settings.get("PW_HEADTRACKING_SENS_Z", "1.0")),
+ yaw=float(settings.get("PW_HEADTRACKING_SENS_YAW", "1.0")),
+ pitch=float(settings.get("PW_HEADTRACKING_SENS_PITCH", "1.0")),
+ roll=float(settings.get("PW_HEADTRACKING_SENS_ROLL", "1.0")),
+ )
+ self.headtracking_manager.set_sensitivity(sens)
+
+ tracker = CameraTracker(camera_index=camera_idx)
+ if self.headtracking_manager.start(tracker):
+ logger.info("Head tracking started for %s (mode=%s)", exe_path, mode)
+ else:
+ logger.warning("Failed to start head tracking for %s", exe_path)
+ self.headtracking_manager = None
+
+ def _stop_headtracking(self) -> None:
+ """Stop head tracking if running."""
+ if self.headtracking_manager:
+ self.headtracking_manager.stop()
+ self.headtracking_manager = None
+ logger.info("Head tracking stopped")
+
+ def toggleHeadtracking(self) -> None:
+ """Toggle head tracking on/off via hotkey (public for InputManager)."""
+ self._toggle_headtracking()
+
+ def _toggle_headtracking(self) -> None:
+ """Toggle head tracking on/off via Scroll Lock hotkey."""
+ logger.info("Toggle headtracking called, manager=%s, settings=%s, exe=%s",
+ self.headtracking_manager, bool(self._headtracking_settings), self._headtracking_exe_path)
+ if self.headtracking_manager and self.headtracking_manager.is_running():
+ self._stop_headtracking()
+ logger.info("Head tracking toggled off")
+ return
+
+ if not self._headtracking_settings or not self._headtracking_exe_path:
+ logger.info("Cannot toggle: no settings or exe path")
+ return
+
+ if self._headtracking_settings.get("PW_HEADTRACKING") != "1":
+ logger.info("Cannot toggle: head tracking is disabled in settings")
+ return
+
+ self._start_headtracking_with_settings(
+ self._headtracking_exe_path, self._headtracking_settings
+ )
+ logger.info("Head tracking toggled on")
+
def _check_missing_prefix_by_name_before_launch(self, prefix_name: str, env_vars: dict[str, str]) -> None:
"""Check prefix presence and optionally disable default recommended libs."""
if not prefix_name or not self.portproton_location:
@@ -2116,6 +2270,12 @@ class MainWindow(
self.checkProcessTimer = QTimer(self)
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
self.checkProcessTimer.start(500)
+
+ # Start head tracking if enabled for this exe
+ self._headtracking_exe_path = file_to_check
+ self._start_headtracking_if_enabled(file_to_check)
+
+
except Exception as e:
logger.error(f"Failed to launch game {exe_name}: {e}")
QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e)))
@@ -2162,6 +2322,13 @@ class MainWindow(
self.disc_image_manager.cleanup_iso_rw_paths()
self._stopBackgroundWorkers()
+ self._stop_headtracking()
+ self._headtracking_settings = None
+ self._headtracking_exe_path = None
+
+ # Stop global hotkey
+ if hasattr(self, "_global_hotkey") and self._global_hotkey:
+ self._global_hotkey.stop()
# Universal stop and delete timers
timers = [
diff --git a/portprotonqt/meson.build b/portprotonqt/meson.build
index c8db989a..061ca3e3 100644
--- a/portprotonqt/meson.build
+++ b/portprotonqt/meson.build
@@ -10,6 +10,7 @@ install_data(
'downloader.py',
'game_card.py',
'game_library_manager.py',
+ 'global_hotkey.py',
'howlongtobeat_api.py',
'image_utils.py',
'icon_extractor.py',
@@ -71,6 +72,7 @@ install_data(
'dialogs/settings_mangohud.py',
'dialogs/settings_gamescope.py',
'dialogs/settings_vkbasalt.py',
+ 'dialogs/settings_headtracking.py',
'dialogs/proton_manager.py',
'dialogs/prefix_backup.py',
'dialogs/wine_loader.py',
@@ -165,6 +167,25 @@ install_data(
install_dir: pythondir / meson.project_name() / 'config',
)
+# Install head_tracking package
+install_data(
+ ['head_tracking/__init__.py',
+ 'head_tracking/tracker_base.py',
+ 'head_tracking/tracker_camera.py',
+ 'head_tracking/tracker_manager.py',
+ 'head_tracking/tracking_worker.py',
+ 'head_tracking/outputs/__init__.py',
+ 'head_tracking/outputs/base_output.py',
+ 'head_tracking/outputs/shm_output.py',
+ 'head_tracking/outputs/mouse_output.py',
+ 'head_tracking/outputs/joystick_output.py',
+ 'head_tracking/outputs/npclient_output.py',
+ 'head_tracking/outputs/udp_output.py',
+ 'head_tracking/outputs/flightgear_output.py',
+ ],
+ install_dir: pythondir / meson.project_name() / 'head_tracking',
+)
+
# Install themes
install_subdir(
'themes',
diff --git a/portprotonqt/port_data_path_selector.py b/portprotonqt/port_data_path_selector.py
index 98c8c606..0b768481 100644
--- a/portprotonqt/port_data_path_selector.py
+++ b/portprotonqt/port_data_path_selector.py
@@ -168,6 +168,9 @@ class _BootstrapInputHost(MainWindowControlHintsMixin, QWidget):
def handleSystemGamepadAction(self, action: str) -> bool:
return False
+ def toggleHeadtracking(self) -> None:
+ return
+
def _show_portdata_warning(parent: QWidget, theme: Any, text: str, allow_choose: bool = False) -> bool:
message_box = QMessageBox(parent)
message_box.setIcon(QMessageBox.Icon.Warning)
diff --git a/portprotonqt/themes/standart/styles/base.py b/portprotonqt/themes/standart/styles/base.py
index 764ec375..2251377b 100644
--- a/portprotonqt/themes/standart/styles/base.py
+++ b/portprotonqt/themes/standart/styles/base.py
@@ -284,6 +284,14 @@ CONTENT_STYLE = f"""
padding-bottom: 15px;
}}
"""
+WARNING_LABEL_STYLE = f"""
+ QLabel {{
+ font-family: '{font_family}';
+ font-size: {font_size_a};
+ color: {color_warning};
+ background-color: none;
+ }}
+"""
PREVIEW_WIDGET_STYLE = f"""
QWidget {{
margin-top: 3px;
diff --git a/portprotonqt/themes/standart/styles/constants.py b/portprotonqt/themes/standart/styles/constants.py
index 16dc653f..bc934a3b 100644
--- a/portprotonqt/themes/standart/styles/constants.py
+++ b/portprotonqt/themes/standart/styles/constants.py
@@ -75,6 +75,7 @@ color_accent_blue = "#007AFF"
color_preloader = "#007AFF"
color_gamepad_supported = "#00ff00"
color_white = "#ffffff"
+color_warning = "#FFD700"
missing_exe_cover_opacity = 0.45
# === Widget State Colors ===
diff --git a/pyproject.toml b/pyproject.toml
index f0c539cd..61c24c19 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -26,6 +26,8 @@ dependencies = [
"babel>=2.18.0",
"evdev>=1.9.3",
"libarchive-c>=5.3",
+ "numpy>=2.2.0",
+ "opencv-python>=4.11.0",
"orjson>=3.11.8",
"pillow>=12.2.0",
"psutil>=7.2.2",
diff --git a/uv.lock b/uv.lock
index 100d5b70..69b83236 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,6 +1,11 @@
version = 1
revision = 3
requires-python = ">=3.10"
+resolution-markers = [
+ "python_full_version >= '3.12'",
+ "python_full_version == '3.11.*'",
+ "python_full_version < '3.11'",
+]
[[package]]
name = "babel"
@@ -13,11 +18,11 @@ wheels = [
[[package]]
name = "certifi"
-version = "2026.5.20"
+version = "2026.6.17"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
]
[[package]]
@@ -145,62 +150,62 @@ wheels = [
[[package]]
name = "dbus-fast"
-version = "5.0.4"
+version = "5.0.22"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b1/c7/490f9ca9da84d1d47e021585cfc6888519f5c12ae48db90cdb4ba631be93/dbus_fast-5.0.4.tar.gz", hash = "sha256:ef372607f967870780c2063e5d39037fdd27e253680aef76687a8dc6cd9413f3", size = 80559, upload-time = "2026-05-23T18:31:31.027Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/db/b621610e50b1bc46ff63534d75239553c1bf33256de6096b58214fd9808a/dbus_fast-5.0.22.tar.gz", hash = "sha256:34dc67d7d21a12399828dd13e63b352750580beea54ea7c729e708f2d2905fef", size = 83224, upload-time = "2026-06-05T18:47:59.171Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3e/e7/35d25d8652ded1787c198d87a3771a24c71ca04d544365e77e0babd019ba/dbus_fast-5.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7339765555cc40586fa8ee25b5a9b51c6f397c420fd9d42833ee8aec42c86bc", size = 694169, upload-time = "2026-05-23T18:38:34.461Z" },
- { url = "https://files.pythonhosted.org/packages/a0/75/dfef781f2de8c5f97c4360cba5d8abbccf617628d2feae031642e003946d/dbus_fast-5.0.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8cffe3f31d9e0108c4f0cf59e104037630fddbcf410954d821259e0cd71acf8", size = 842006, upload-time = "2026-05-23T18:38:37.269Z" },
- { url = "https://files.pythonhosted.org/packages/a0/f8/39d2f6a72cd572ccf857ce7567db881113785655a5c25fd95cdf50d32121/dbus_fast-5.0.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f01e98f38215e1abbb320e0f6e0875c2ed3c17891eef3176fed2c0de8ffb7954", size = 885667, upload-time = "2026-05-23T18:38:38.939Z" },
- { url = "https://files.pythonhosted.org/packages/f7/20/73f8c0b2b17fab681680065a3a3853f992d26dbe00ab966241d114989aed/dbus_fast-5.0.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe8dd98f504af78f92ea4377839d1ca22dca9b1b7242e86a7655e866173b5df8", size = 884493, upload-time = "2026-05-23T18:38:41.528Z" },
- { url = "https://files.pythonhosted.org/packages/fd/75/d46e40d7308599a3c1df7a5b60f6e557f91cd2fbe56efc0ab2acf5a63236/dbus_fast-5.0.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6f819880a12e31f489fa81beedea14c87dd14fcb92817e9b88d2527a8989ac9f", size = 848095, upload-time = "2026-05-23T18:38:43.468Z" },
- { url = "https://files.pythonhosted.org/packages/2c/b5/fa9f4399c78c237dbfdbe3e71eaccd1049e027fba1da312d3612533a96c2/dbus_fast-5.0.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bf8ff7ceb5cac93c0c6869f8dc2ce5a84200337503d66bf83a3b88a9346ea76e", size = 882021, upload-time = "2026-05-23T18:38:45.479Z" },
- { url = "https://files.pythonhosted.org/packages/c0/9d/321c26379acfbfb605a68550a404c829b3498f01563e86091e2408ab1c68/dbus_fast-5.0.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fe088a16d33054607021400043f3daeff566fc0cdd45e789d331d24e99f13e93", size = 891620, upload-time = "2026-05-23T18:38:47.113Z" },
- { url = "https://files.pythonhosted.org/packages/f6/17/9297c320ab6363cbb3c0f349565f78e7e1adc0e6fb356bc37dba16bcfd91/dbus_fast-5.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:20c2d83950f2a3b4536f51da0ad51dfa063ffc92dec084f80e9c7661582edeb9", size = 692498, upload-time = "2026-05-23T18:38:49.267Z" },
- { url = "https://files.pythonhosted.org/packages/31/b6/c63d13b45bff8619e3479be45429459728870d697fe037367ef78bb208fd/dbus_fast-5.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aca56a03b2d46263c1a0b6483677dbf1fa84f6516db9e1c032b4d57ad757cfc2", size = 842099, upload-time = "2026-05-23T18:38:51.425Z" },
- { url = "https://files.pythonhosted.org/packages/35/85/5f9162efc2c1e6f9a9ed6732d4e4778a62f851482fb847afc82726d97f6a/dbus_fast-5.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:864b659724fb011356d4a5439551e17e03fa8420e5e14b1ae7cf5baf541c61e4", size = 885967, upload-time = "2026-05-23T18:38:53.591Z" },
- { url = "https://files.pythonhosted.org/packages/a9/c6/704494d8bf28e4b5126a43c21d600af13b3e075c1c53e122e4f95a832174/dbus_fast-5.0.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d00901d51ba4e5f2eb421f83fb6e4faf9466788fc7d7c4cfaf464bd97737bc52", size = 885377, upload-time = "2026-05-23T18:38:55.236Z" },
- { url = "https://files.pythonhosted.org/packages/e8/82/8dd56dad8a32050a74261ead6bf8536823f2bf35a95cd5e2d50b4e8e585e/dbus_fast-5.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e4d4a0f108555f94ce76c2d9be58a1a0d72f3881b83ce919e418ece575e24e88", size = 848562, upload-time = "2026-05-23T18:38:57.86Z" },
- { url = "https://files.pythonhosted.org/packages/83/aa/b12b378005538f4355a8a376783fa7477a60adb111947a565edf12101675/dbus_fast-5.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0a56f1b933d6f0333653b7968cb5b38ff27e42f50c60a35801f6d27a94cc1fb9", size = 882106, upload-time = "2026-05-23T18:38:59.994Z" },
- { url = "https://files.pythonhosted.org/packages/2c/12/61c28663229b2fa3a8833d74d086c8eb7bad57efa03d9b719a4a72b6f675/dbus_fast-5.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6e28e4d08361697afe0be0815d2e649bdf5bc96bf012db61b3e5c6aa991bd7c3", size = 892440, upload-time = "2026-05-23T18:39:02.345Z" },
- { url = "https://files.pythonhosted.org/packages/fc/d5/8a9dae63f6e68eafed65c2fc23aa1172bd840b93cb564f52035fe5bde409/dbus_fast-5.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06fdd44811c938bf8d10bb8a5d1d09831e337eeff325b17ad7ef45640c2c88c2", size = 689936, upload-time = "2026-05-23T18:39:04.404Z" },
- { url = "https://files.pythonhosted.org/packages/d0/2a/67d7a011dfe5054bca33ff7faee6d60daae343e5d07e3b6f4182e5cbcecf/dbus_fast-5.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d51094e6a80a7bfa6e67c59826a5604c7824e166958e6a029d0020f98287c00e", size = 803359, upload-time = "2026-05-23T18:39:06.39Z" },
- { url = "https://files.pythonhosted.org/packages/bb/15/3ed2ab01ac5fd025378e6528ca4b3067333fcaa56f2f7338111fc879c702/dbus_fast-5.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1a3d8f0aa43d34f19588ccdfd12e332258ab6c234b419166fa938bbae8afd52", size = 855303, upload-time = "2026-05-23T18:39:08.041Z" },
- { url = "https://files.pythonhosted.org/packages/cd/ca/88b86f5f065311eee071f30559538acd39b5dfb3b1298c4d7e599accbe8d/dbus_fast-5.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff5f30056c3a68944729b88686551b56b79ec50fe27657e4e172afef4856458", size = 836412, upload-time = "2026-05-23T18:39:09.995Z" },
- { url = "https://files.pythonhosted.org/packages/0b/74/13de0346943bed77e322479a6823293712d3525c953bb130476069a4f6da/dbus_fast-5.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1582355810e2224736a008e277bab8780bd3e6a7bbf865dd9d78fe6cd4aab961", size = 810524, upload-time = "2026-05-23T18:39:12.109Z" },
- { url = "https://files.pythonhosted.org/packages/c6/58/515cafb4ecae652ac8d289f9e7e73fff26e3f4052751f014ccff0c761742/dbus_fast-5.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:176ea1a9c736f65262d923f9f8303824f1d1029d80bbfbdefe37a80e8a50b242", size = 833967, upload-time = "2026-05-23T18:39:13.899Z" },
- { url = "https://files.pythonhosted.org/packages/c4/13/5f369f6f388a19b662785c8e156795dd8f4df08cb49a3e2220464b69d7ee/dbus_fast-5.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:17ff032d12dc4f3dbc3b29cd3756ce9094fd4cfc8f8984efa214072f680f70b1", size = 863709, upload-time = "2026-05-23T18:39:15.799Z" },
- { url = "https://files.pythonhosted.org/packages/43/be/0e88b7c1263583fda6213ef3370e259d19735b2f7f6e8746b77b08fb0080/dbus_fast-5.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:00fe9b762c9974304e50f73abfd75210ae769207b78d8f3a97783d7281108200", size = 685726, upload-time = "2026-05-23T18:39:18.176Z" },
- { url = "https://files.pythonhosted.org/packages/15/e5/609bbd7e8e59753236a06838b5d561498695c457f08883348a7b034399c0/dbus_fast-5.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527edcecb06a6a1676c8e7eb0c167a234701715913c66431377d38da7acaa5b3", size = 800170, upload-time = "2026-05-23T18:39:19.968Z" },
- { url = "https://files.pythonhosted.org/packages/4c/a1/9542cd0fbf86be24aa4c0865667cf8bf742df90c93b5063c3364d10f8dc3/dbus_fast-5.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:556ffba0fd56e9e872ed031dab579dab99c537c702e7c3d28f460c39e686ad8a", size = 850999, upload-time = "2026-05-23T18:39:21.808Z" },
- { url = "https://files.pythonhosted.org/packages/5e/8e/aaf3ff0e66df530807af0381e7a9ec92a8ebd9783d54f00c1cbfaab35dc5/dbus_fast-5.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d7bb1465c2d06ddb6c38f9c7344c5a70beb09adfee8a00344322c7b2a90a5f4", size = 830440, upload-time = "2026-05-23T18:39:23.662Z" },
- { url = "https://files.pythonhosted.org/packages/29/ca/1cbf6e7928134fa642857222c4a684f4ffd5cc0d16fca51416a659cdc9be/dbus_fast-5.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ca2a8e94610705fae090aa1c318b810da9f9df47b990da9b6e06a8005a52fb91", size = 807074, upload-time = "2026-05-23T18:39:25.667Z" },
- { url = "https://files.pythonhosted.org/packages/ef/f2/a5f8e20fdef07a3afdf6e50fb98df5dc266c6ff43346dd66d71cde300dd8/dbus_fast-5.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f4e47a01c1e196d0aac0654850911eade720e9e571f2e68ee363dfeb69699e4e", size = 830159, upload-time = "2026-05-23T18:39:27.465Z" },
- { url = "https://files.pythonhosted.org/packages/01/d7/a2307c0b32c297728d325c05b9978caf7e9a1787d259d5172cfb90946b73/dbus_fast-5.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c3587f50a3089e5cc1a09b9a72cad964b88565f3d6dcfab3681981bca81a3fac", size = 859531, upload-time = "2026-05-23T18:39:29.755Z" },
- { url = "https://files.pythonhosted.org/packages/10/33/1dabccf932cee9408f189306bd6efdfab8cc063fddebcb218fd36d991ce1/dbus_fast-5.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:aa519fd8536ea8d100347f15af8a9d1a2d209fa5d353dadb64dfa13ecf707b3c", size = 692866, upload-time = "2026-05-23T18:39:31.622Z" },
- { url = "https://files.pythonhosted.org/packages/5d/46/a56b19ef369e55fe723c8b33fcdd18b730d77442585bd325972d2c07780f/dbus_fast-5.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6139737eda772a9a5de13368862a15e486d61570be7d3a5e6bd64c7a23131d36", size = 814255, upload-time = "2026-05-23T18:39:33.423Z" },
- { url = "https://files.pythonhosted.org/packages/01/f2/e0397cdfa0aefb5b4c44658bd6ba8ce532b997e81bff3d9714bbd36ee858/dbus_fast-5.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ce788791929923dbcb3eee889c61b8898eed41c77e334ac73bacd24d6e5c68a", size = 856800, upload-time = "2026-05-23T18:39:35.352Z" },
- { url = "https://files.pythonhosted.org/packages/ef/49/d11afde6602cefb2fdb43dd15a1f6b6f36a0e86cd046c99b4bafb071eb30/dbus_fast-5.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e36da263ce03faf48c5f40b9cc33949a8bbb67db1ef09b7cdb89a8b807aaaf7", size = 836628, upload-time = "2026-05-23T18:39:37.152Z" },
- { url = "https://files.pythonhosted.org/packages/8d/d8/0d711a54e4523cad9a4b08753c7c49a32580fe90a2a0949109fa8cdccc1b/dbus_fast-5.0.4-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:2eeb8dc52aa377074133fd8cd15733e8de27c4fef62f9c66be90fe0f4822aaa3", size = 854900, upload-time = "2026-05-23T18:31:27.897Z" },
- { url = "https://files.pythonhosted.org/packages/e6/38/59d700c6540136740b102f66c94147dc707e917d8136e32fc7fcef649032/dbus_fast-5.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d3d3bddcec3df4e213dce8de6e456e8bf4053a5e6dc9544fa13d2803c7ade3e8", size = 820912, upload-time = "2026-05-23T18:39:38.941Z" },
- { url = "https://files.pythonhosted.org/packages/34/58/961d5d6d7c71347bfc6d6e6fdf4501b8773eab46bad02dc0f57d1005ab66/dbus_fast-5.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e2c53a20bc6756d2d70b5a189b2adc76bb5d9856a1abd9564f160cd03f5d63bb", size = 837066, upload-time = "2026-05-23T18:39:40.754Z" },
- { url = "https://files.pythonhosted.org/packages/47/e6/49122a2b58ceca179627b2cae1c6f536336303f00cff2d6ad37c08ce0094/dbus_fast-5.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2b0f0d98fcabc838589eb05b29322d20378bd3c98999ace909ed63bed28a92e8", size = 864390, upload-time = "2026-05-23T18:39:43.091Z" },
- { url = "https://files.pythonhosted.org/packages/17/9b/55aed4a5e2905c3767dfa92b052272fa3459e2a7c46b1a97ca968163c2e9/dbus_fast-5.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2142c9be443711c8e185de57ad19781f6ff231fa2f6ebbe9312734efd7a72762", size = 1356783, upload-time = "2026-05-23T18:39:45.079Z" },
- { url = "https://files.pythonhosted.org/packages/45/8d/df4488525e22a8e843e12656f0718d760528f56709401af979c27a6c151c/dbus_fast-5.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a80a3114dc11fc463ef5b5562de6fa7fb5615acd488efa31a157dd83d510a247", size = 1545147, upload-time = "2026-05-23T18:39:46.933Z" },
- { url = "https://files.pythonhosted.org/packages/fd/75/c18803c88e85164ce60c28f59a7fdb7819901b5e5971bbc505bcd438d02d/dbus_fast-5.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340d36759a18d8b67deeb6bbd60647e0294f92950778a02e753832840b93f899", size = 1620877, upload-time = "2026-05-23T18:39:48.703Z" },
- { url = "https://files.pythonhosted.org/packages/c0/5c/a8154e4ba68c822c96233302551c7280198fd56ad6709baacc804a51f7e8/dbus_fast-5.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbbd41b9a641fc47e92d923331750a28c3a5001947bc1271d2392247c8fba327", size = 829639, upload-time = "2026-05-23T18:39:50.734Z" },
- { url = "https://files.pythonhosted.org/packages/54/6b/13ef6c4fddcd7beeb15e8425e8d90dd5f06d973e959397153d53e12ec385/dbus_fast-5.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00ef02cfdc2423b50756e32e90af8f1c3d63ad41f3af8a3d0f7cea9315e0005", size = 1560002, upload-time = "2026-05-23T18:39:52.571Z" },
- { url = "https://files.pythonhosted.org/packages/e9/54/010f9a9673ca7bf67a393486278a617b9930fead5c04563bf710bb6dc21d/dbus_fast-5.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2ba075dc3c12af888ecae09226dbc2225c75b0e105666e1f0630f95d2ca6ab84", size = 831734, upload-time = "2026-05-23T18:39:54.736Z" },
- { url = "https://files.pythonhosted.org/packages/cf/ad/14b5ed792988db43b257b9c262e39b43d8b1fdb361b96da005a5cd3e5b48/dbus_fast-5.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:210ba9eef1f63d256111207f98c5e2264b946ff8db1bf3c82ab064c7d1fb2dae", size = 1639471, upload-time = "2026-05-23T18:39:57.115Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/5c/fa3b060e06e0322ddd5c21a4f63e6c3e59a427a92db1553b930816d40e97/dbus_fast-5.0.22-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:027b358d55164f722d7432d6734506751c89b5cee8d516e7282eff3eaa763f05", size = 694683, upload-time = "2026-06-05T18:55:38.241Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/69/9235af850e2ee0cec23b4e863612492964a9c40b65f3002e4a9f32e1ffa2/dbus_fast-5.0.22-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8244561e503cffcd5e6b1ff6544a44aa0b65b6856f2f64d04345ebf82f8ac48", size = 840236, upload-time = "2026-06-05T18:55:41.007Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/d1/08ed17331dd23a8b91b80db9acca48301c4879352024dc7882e2c5893fa2/dbus_fast-5.0.22-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32869678240f233ece5a1137960c0337699dbb39acc02c155929f52543f03fb", size = 884309, upload-time = "2026-06-05T18:55:42.447Z" },
+ { url = "https://files.pythonhosted.org/packages/34/ed/02458af54460e55cf26fdf0a778aa9b3217ac8d37e6280aa68e19f176650/dbus_fast-5.0.22-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f74700b1b4fd58acb76f8790e2cd475084d0e59149e0cc035abd239435a339b7", size = 885179, upload-time = "2026-06-05T18:55:43.801Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/79/4958c807e59106558a4415212e6be019775f5be9775ffc79c6bc7248b388/dbus_fast-5.0.22-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:246780dcda59d26dd2fb1d9a15991bc8483e9c241cee8713920a39a363ad4d39", size = 847693, upload-time = "2026-06-05T18:55:45.271Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/cc/a5322fbc172ee099f98186224cf7e3bf48fc8bcb08270951da8006b556ba/dbus_fast-5.0.22-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:58e4d41f9fea22cfd6dacdf03d53b932bbe15a825af5abad3ca79b91493a15c3", size = 882773, upload-time = "2026-06-05T18:55:46.712Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/e7/e068899c1d9a034ed9d5fc8519b8e2247aa8c28a6a67229162db64163b59/dbus_fast-5.0.22-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d8ca39f3f2ab34f88bb343db87c7cf8b07eefa046b23d8c38b3387f150ac609e", size = 891573, upload-time = "2026-06-05T18:55:48.34Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/1d/f6020228675338b7184ce5491e4535fb64c7badc0197151b9776f345a1a1/dbus_fast-5.0.22-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f0926e4cf49989b4ec8233e8fd462eb35a640fcfe81bb75d91675dd47489022b", size = 693158, upload-time = "2026-06-05T18:55:49.874Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/84/dfb014de75a3a854dccaae1cce8f840e4312e3efc781768eedd60d25d9ef/dbus_fast-5.0.22-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846f9a6602b4383f989201f7851459fb225a8912cd24b38e63894748545c3040", size = 838836, upload-time = "2026-06-05T18:55:51.51Z" },
+ { url = "https://files.pythonhosted.org/packages/70/c2/be41bcc678e97092d44ba22d09ce687f76c955b3367a7e6863377b1cfea5/dbus_fast-5.0.22-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886b43446b6fdc3986befbbb88db1365b14e49dd0a7edf84c2c67ac66c7160a4", size = 883163, upload-time = "2026-06-05T18:55:53Z" },
+ { url = "https://files.pythonhosted.org/packages/17/cf/336c08f88fdd813a39fc1603a10a15aec67115e08a895e7c9840df54d4d7/dbus_fast-5.0.22-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a699fca957acc845ddb12b47f741dba23ce147fdb93583e0c7e7bad3e9b2355", size = 886852, upload-time = "2026-06-05T18:55:54.434Z" },
+ { url = "https://files.pythonhosted.org/packages/04/f1/7c1aa53f25252a2317f21ad6e8eaa125246d2585ea4611f3f10a6feaeeb1/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9a5b05fd4973862042e5bee2c5e8c5a15297e0b33a975bf25b44becf7bcb3618", size = 846497, upload-time = "2026-06-05T18:55:56.193Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/32/a981ef2305f1bf41e538e02fa0cd69614f9043fd00ca965bf3044416c79b/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:6c4dae5292a7924ec062815c34b49043d8386cd22e165f9fb4012de00997cdf1", size = 882275, upload-time = "2026-06-05T18:55:57.653Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/a0/78800a172f4ca32e19a70a36e175f54831f33a497c9644f3a3fa4dce01ea/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b7d90a52be79acbaef257f3a81d5b9b9dec40f1bad29429ac5c7802684fb9b84", size = 890566, upload-time = "2026-06-05T18:55:59.407Z" },
+ { url = "https://files.pythonhosted.org/packages/52/81/ffc155f700c45191673e7f7620a28cbbbf5f116ff74a99f765895baa6f9c/dbus_fast-5.0.22-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f72b77be63f7bb24cf42936ad10994d40f43fed691f857f7854b5882d6a5227c", size = 690171, upload-time = "2026-06-05T18:56:01.151Z" },
+ { url = "https://files.pythonhosted.org/packages/24/06/233b0bc13919474f70320bf389cb81ce02811956d6cf86c45e84679b63c3/dbus_fast-5.0.22-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f0bcad7f71d2304a68a5b0bc0d24c3fcc14710a2ffcf5f2a27521e3aece71ca", size = 799464, upload-time = "2026-06-05T18:56:02.617Z" },
+ { url = "https://files.pythonhosted.org/packages/68/e9/77bc23a6f5aebfb8f2c34489795e8517aed7eca31738438e1a4c4a4891d3/dbus_fast-5.0.22-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ffcf16034f71a801bd2108aeffb6337d104c9459e8b1a218d16a917c8a2d2e9", size = 852687, upload-time = "2026-06-05T18:56:04.433Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/04/56769d0936d1273d1801ef574ec426ccb3f61f4b0a7a0eeb9eb2b8ccafa5/dbus_fast-5.0.22-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98de6d2c200d8182e1fd0bdde3206fa556b8fa14ebb752a044cd8daa87b4658c", size = 833814, upload-time = "2026-06-05T18:56:05.87Z" },
+ { url = "https://files.pythonhosted.org/packages/06/ca/964f0d39a3be03b12a98f39519d34ad95b74360c7e3adf4cd4907dc25fd6/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b013437b66dc22b8d9aca5e0b0d46bf1980208a143409469fe482d9684a2a717", size = 806891, upload-time = "2026-06-05T18:56:07.623Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/25/57fe6ab509ad9da2e190498fa9c37f868e38ac521d940a9edb9ba6b6c657/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:855f15b7f7805171da2b82de1c317d01cfbb9fb8ac61fcc1e8dec54d8c69fab7", size = 830867, upload-time = "2026-06-05T18:56:09.473Z" },
+ { url = "https://files.pythonhosted.org/packages/be/2b/da036e9f4aeb776833139575fe0774544aa6cd13ba997eea7fbc4ab99852/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7d1c42963235cfc015a2d2b8c5fe42b65387493b4ad4ce0ec122601c805e6742", size = 860651, upload-time = "2026-06-05T18:56:10.952Z" },
+ { url = "https://files.pythonhosted.org/packages/25/1d/ebd02ae707328286c24582ac9189e4ed9c344399bc3b6a4b74e9687088eb/dbus_fast-5.0.22-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:26e26b409e1e6edf5e2a4df8d192625fb38876b074fb5c7d0a5b15c5792e549d", size = 685921, upload-time = "2026-06-05T18:56:12.674Z" },
+ { url = "https://files.pythonhosted.org/packages/76/5a/fa81a6685c763ea488ad93228cb6e036adc9af6a560f4c31643691f4cfd8/dbus_fast-5.0.22-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de10ff3b3cb2acb1c09fe17158a470519000d37bb5ee5fd69c4075e81ce8dcf5", size = 798472, upload-time = "2026-06-05T18:56:14.141Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/34/6b272e6df60be1aa4d575aa30220175a52c002a649c951d9950bfa3a72d6/dbus_fast-5.0.22-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:979761985fe343c701f2b7575285d6e370123f7231d4656209ef7824bb686bbb", size = 850312, upload-time = "2026-06-05T18:56:16.36Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/5c/9045c3595ddcd4069e6b5d051df06bf11a2b022592f721c9acea1e0e4d22/dbus_fast-5.0.22-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb73f1d8374253b7c17d69e902cf2ded1bfb089cb6ae67c10b4e0bdfe1b8fe08", size = 828366, upload-time = "2026-06-05T18:56:17.786Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/78/ca6881442b8fa29edbe6d99bec4b535b0b2e2f423075d015ff5b719c4e2c/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b67a02037eb58bcf9e445df60ea0d9d7346fd334abde3aa62e03c75823b53979", size = 806036, upload-time = "2026-06-05T18:56:19.501Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/c6/458728eb1caa26171e6a8ae1d0d99bd29aaeac67ad7824bbd95d7f854a41/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:83940ea00d7ee2f0c5bcb5d19d7d05e7949e52d467616a0b735d72e7285402ec", size = 828353, upload-time = "2026-06-05T18:56:21.346Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/1d/830b1569264780210d44898e5b0d95cffe2830b952c2ee21ea481274cd81/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:279d212e9fb262d595af2e4b5b9e951bc00c73a5c8eeb50f158caa13705b9c84", size = 857743, upload-time = "2026-06-05T18:56:22.9Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/b6/171f8e92775254e2f42afe9e7501a57ace47f32de2969cb694a375dc9dce/dbus_fast-5.0.22-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:511655d915692f55b8c5f5a535acf80cca9c6d1a35384db7fcfdabaae05dd837", size = 692986, upload-time = "2026-06-05T18:56:24.661Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/8c/4eefaabdf538882528164060ae83d9a34f1172b019c32c3254436834e9b1/dbus_fast-5.0.22-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:703e0f8f9af52e8e053394ee2b578042be0c3d8ea2b1488f9db8cb14393cc13f", size = 810835, upload-time = "2026-06-05T18:56:26.356Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/cf/fd327dbb40ee67a9331fb587bf78aff2ab1500b35979978a5cacb10d7f8c/dbus_fast-5.0.22-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb1d7e8e65561d0fd438004fd9e0f981c8a862912fed58dd4e29db1936c39d73", size = 855498, upload-time = "2026-06-05T18:56:28.009Z" },
+ { url = "https://files.pythonhosted.org/packages/56/33/1709ebc16a4d353ddc4fcd29252e2b9d93bded6422a45fd6df170e0911c1/dbus_fast-5.0.22-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:959fab6420897ab99410e67d6f9f9a7f6f4cedb6014700768f5e2d71dbff5dc6", size = 833510, upload-time = "2026-06-05T18:56:29.806Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/fd/89d7c34152900d986b9c78e39cc62aa73eefc22b57b3a8c946d945a85540/dbus_fast-5.0.22-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:eb31c5ff339a7071b914617a69d5b7c6ba7d411da4b01a5f9b5b2fe51e9d1301", size = 853669, upload-time = "2026-06-05T18:47:56.747Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/a1/031cc4a89d947f1fe110f663f93dcce9230213b7accaf719790d813def04/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:856f0543c593f3480e93e67bcd1aa4ddc1d94a6076cfd3ad4e0f5e2b01b33dc3", size = 818486, upload-time = "2026-06-05T18:56:31.72Z" },
+ { url = "https://files.pythonhosted.org/packages/36/e2/de8b764fdb947314fb8c2e079b556510194fd100983776845e234a107cc9/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:96d231d128c1f46f263790335897195dde9dac2f38571782db8ae1d8647bd548", size = 833582, upload-time = "2026-06-05T18:56:33.325Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/1f/e5f0dd28d07c4b3f7bafd3357bfa424c8dace355a3dad921fec05db4634b/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:595bd3ccfd8318cbafff79f33a15709fee3728724fd61d5fa220080d73b574cb", size = 862291, upload-time = "2026-06-05T18:56:35.102Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/dd/61086156a1c2d8ffd04d61a232debbaff8ca9fc1cf598476999e1f06164d/dbus_fast-5.0.22-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:69a077c296eaab8c30160e861b5514c33d99d67d41d17dbf02e89aae44543b11", size = 1353993, upload-time = "2026-06-05T18:56:36.849Z" },
+ { url = "https://files.pythonhosted.org/packages/26/69/5b54654f598ef98e8f94fd5a40929668b1f8fcd76e7fb50de0db73d329da/dbus_fast-5.0.22-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04bac97d0cb754a4d13037d0132517f1df28192d6e0568a0bf6df06623062285", size = 1534804, upload-time = "2026-06-05T18:56:38.804Z" },
+ { url = "https://files.pythonhosted.org/packages/24/b7/c00d01699dc87ffc35f143226d3b296372840e2e2bc15101d35df7c74949/dbus_fast-5.0.22-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3eb57d592d84b0bb90e0c077db7ecb61562f49cc9b86a3ef08cbe17243e9cc4f", size = 1613316, upload-time = "2026-06-05T18:56:40.461Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/94/ea0db4c1aa6409cb16551b50aa8573e72f64407ca5281b042919ef81ca1c/dbus_fast-5.0.22-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:de4d235d1282ebb3ab65b6cddab84e914c045d92ceb381ddcbdbaf66bf1fb132", size = 822053, upload-time = "2026-06-05T18:56:42.519Z" },
+ { url = "https://files.pythonhosted.org/packages/40/e4/a3bb52185b8a8c76bd8aaba3ff4fa8395eea19fbc142122b43dc377b275c/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:048f34299fbe82d7b87c56f47e8bd83f62339a4517685abc6671d603a55d2c89", size = 1549996, upload-time = "2026-06-05T18:56:44.307Z" },
+ { url = "https://files.pythonhosted.org/packages/37/2b/6e405ba92e87d78a689a387809d975f97f8c8748b98efccfacd2b4e1d9f5/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92df9fb6d8adeb17b534621c2ee730295bbe1d0c2584d5c82b1db478e3f04e8f", size = 823004, upload-time = "2026-06-05T18:56:46.023Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/8f/77135ab8d690030cdb0ebeca879640b5945c4cbf5344ecbc507b4628da24/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7be4271e38251f1ad726962dec60da887c8ed352d157352e4fc27f56aece5c5d", size = 1629160, upload-time = "2026-06-05T18:56:47.688Z" },
]
[[package]]
name = "distlib"
-version = "0.4.0"
+version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
+ { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" },
]
[[package]]
@@ -214,7 +219,7 @@ name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
@@ -223,11 +228,11 @@ wheels = [
[[package]]
name = "filelock"
-version = "3.29.0"
+version = "3.29.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" },
+ { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" },
]
[[package]]
@@ -241,11 +246,11 @@ wheels = [
[[package]]
name = "idna"
-version = "3.16"
+version = "3.18"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
@@ -275,6 +280,227 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
+[[package]]
+name = "numpy"
+version = "2.2.6"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" },
+ { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" },
+ { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" },
+ { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" },
+ { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" },
+ { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" },
+ { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" },
+ { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" },
+ { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" },
+ { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" },
+ { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" },
+ { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" },
+ { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" },
+ { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" },
+ { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" },
+ { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" },
+ { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" },
+ { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" },
+ { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" },
+ { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" },
+ { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" },
+ { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" },
+ { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" },
+ { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" },
+ { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" },
+ { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" },
+ { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.4.6"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version == '3.11.*'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" },
+ { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" },
+ { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" },
+ { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" },
+ { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" },
+ { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" },
+ { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" },
+ { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" },
+ { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" },
+ { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" },
+ { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" },
+ { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" },
+ { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" },
+ { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" },
+ { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" },
+ { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" },
+ { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" },
+ { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" },
+ { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" },
+ { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" },
+ { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" },
+ { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" },
+ { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" },
+ { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" },
+ { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" },
+ { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" },
+ { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" },
+ { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" },
+ { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" },
+ { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" },
+ { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.5.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.12'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461, upload-time = "2026-06-21T20:57:51.95Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/0a/11486d02add7b1384dff7374d124b1cfbb0ee864dcc9f6a2c0380638cf84/numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561", size = 16789987, upload-time = "2026-06-21T20:56:16.657Z" },
+ { url = "https://files.pythonhosted.org/packages/55/b2/285f48640a181947b4587a3766d21ec1eaa7fea833d4b49957e09da467a2/numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6", size = 11760322, upload-time = "2026-06-21T20:56:19.813Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/67/b032db1eb03ca30d16eda3b0c22aaa615338b9263c2fd559d0f29451aca4/numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be", size = 5319605, upload-time = "2026-06-21T20:56:22.173Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/83/03fc7300c7c6b6c84c487b1dc80d322817b95fbd1f4dd57a85e23b7198de/numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2", size = 6653628, upload-time = "2026-06-21T20:56:23.914Z" },
+ { url = "https://files.pythonhosted.org/packages/82/49/2ec21730bc63ccfda829323f7040a8ed4715b3852ce658689cf74ee96a8c/numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8", size = 15153691, upload-time = "2026-06-21T20:56:25.631Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/6b/f4a3d0637692c49da8ef99d72d52526f92e0a8d6ac4f0ca9f31441b9d9ea/numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a", size = 16660066, upload-time = "2026-06-21T20:56:28.009Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/2f/c354ec86d1f3f5c19649463b0d39652e160736e5b0a4cd18dff0576715c4/numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0", size = 16514638, upload-time = "2026-06-21T20:56:30.26Z" },
+ { url = "https://files.pythonhosted.org/packages/06/34/43efdcb319988648580f93c11f1ae82cf7e2faa74925e98e454ae3aa95f8/numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54", size = 18419647, upload-time = "2026-06-21T20:56:32.41Z" },
+ { url = "https://files.pythonhosted.org/packages/71/e2/f5d1676b1d7fb682eb5e9a1641e7ebd2414b3216c370661d1029778908b4/numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5", size = 6056688, upload-time = "2026-06-21T20:56:34.657Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/7c/48f115d1c58a34032facebcd51fdf2d02df2c51d4a46a81dd1197bb2ea6b/numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2", size = 12419237, upload-time = "2026-06-21T20:56:36.24Z" },
+ { url = "https://files.pythonhosted.org/packages/86/26/2e0882f4044d1b1a1b63e875151fb2393389032022a8b7f5657a7996d3b2/numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca", size = 10339912, upload-time = "2026-06-21T20:56:38.733Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/33/07675aaad7f26ea013d5e884d9a0d784b79c6bd7566c333f5a52fa3c610b/numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2", size = 16784890, upload-time = "2026-06-21T20:56:40.799Z" },
+ { url = "https://files.pythonhosted.org/packages/85/4b/953118a730ee3b35e28645e0eb4cf9beec5bdbb954e1ac2f5fcefba6bbc3/numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c", size = 11754584, upload-time = "2026-06-21T20:56:43.094Z" },
+ { url = "https://files.pythonhosted.org/packages/44/9b/56dd530c367c74ae17411027cea4135ca57e1e0583bf5594cee18bd83217/numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd", size = 5313904, upload-time = "2026-06-21T20:56:45.503Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/b0/bcd672edad27ecca7da1f7bb0ce72cd1706a4f2d79ae94990afc97c13e1c/numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd", size = 6648504, upload-time = "2026-06-21T20:56:47.567Z" },
+ { url = "https://files.pythonhosted.org/packages/80/9e/15cdfcbd30a1544a46c9e487a00df331c4672450216538705a9e51fa6710/numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e", size = 15150086, upload-time = "2026-06-21T20:56:49.352Z" },
+ { url = "https://files.pythonhosted.org/packages/32/4e/8d7656ccaab3e81e97258b8a9bc5f0c8502513a92fb4ceb0a2cbfebc17bf/numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9", size = 16647250, upload-time = "2026-06-21T20:56:51.542Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/81/97060281b602ed07f21b12f4ec409eac1f75a2f91fbc829ed8b2becf3ad4/numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab", size = 16512864, upload-time = "2026-06-21T20:56:55.401Z" },
+ { url = "https://files.pythonhosted.org/packages/33/ab/4496208146911f8d8ddb54f68a972aafa6c8d44babcb2ea03b0e5cc87c9d/numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4", size = 18408407, upload-time = "2026-06-21T20:56:57.75Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/9f/a4df67c181e4ee8b467aa3332dc2db10fd5c515136831302f3ca48bc0a01/numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988", size = 6054431, upload-time = "2026-06-21T20:57:00.016Z" },
+ { url = "https://files.pythonhosted.org/packages/30/53/491e1c47c55b62ccc6a63c1c5b8635c73fc2258dddeb9bda27cae4a0ae96/numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748", size = 12414420, upload-time = "2026-06-21T20:57:01.815Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/4a/25c2906f541e9d9f4c5769764db732e6627be91a13f4724fa10634d77db4/numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60", size = 10339533, upload-time = "2026-06-21T20:57:03.961Z" },
+ { url = "https://files.pythonhosted.org/packages/86/ad/abc44aaceaf7b17ee1edde2bbb4458da591bc79574cffff50c4bb35f00d1/numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9", size = 16783807, upload-time = "2026-06-21T20:57:06.253Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/39/b72e168daf9c00fb20c9fc996d00437ccecdef3102387775d29d7a62576d/numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446", size = 11765215, upload-time = "2026-06-21T20:57:08.547Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c", size = 5324493, upload-time = "2026-06-21T20:57:11.012Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/8c/0d104deaa0401c93395a629ec902891618a2eff76d19229139cb5a887bfc/numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303", size = 6645211, upload-time = "2026-06-21T20:57:12.919Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/d9/4a4a628c812750363786afc3d33492709a5cd64b215469c16b0f6c7bb811/numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22", size = 15166004, upload-time = "2026-06-21T20:57:14.717Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03", size = 16650797, upload-time = "2026-06-21T20:57:16.906Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/a0/a0090e6329f4ca5992c07847bb579c5259a19953dc57255bb08793142ffb/numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a", size = 16524647, upload-time = "2026-06-21T20:57:19.165Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/7d/6caf27734c42b65837e7461ed0dbbd6b6fc835060c9714ec59d673bb383a/numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21", size = 18411841, upload-time = "2026-06-21T20:57:21.638Z" },
+ { url = "https://files.pythonhosted.org/packages/13/dc/26edadbd812536769a82c2e9e002234e33feb5da43061d47a044f6d309b7/numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731", size = 6106361, upload-time = "2026-06-21T20:57:23.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73", size = 12551749, upload-time = "2026-06-21T20:57:25.945Z" },
+ { url = "https://files.pythonhosted.org/packages/05/a7/6bc6384c080b86c7f6c85c5bc5b540b24f4f679cd144791d99574e90d462/numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e", size = 10617072, upload-time = "2026-06-21T20:57:28.175Z" },
+ { url = "https://files.pythonhosted.org/packages/86/6b/4a2b71d66ada5608ae02b63f150dfad520f6940721cb7f029ad270befc0e/numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b", size = 11881067, upload-time = "2026-06-21T20:57:30.104Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/b2/d365eb40a20efb49d67e9feb90494ed8511282ee1f5fa16006675c65397d/numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927", size = 5440290, upload-time = "2026-06-21T20:57:32.193Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/5e/e9c03188de5f9b767e46a8fe988bcfd3efad066a4a3fda8b9cb11a93f895/numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826", size = 6748371, upload-time = "2026-06-21T20:57:33.933Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/1d/68c186a38a5027bae2c4ddd5ea681fdaf8b4d30fb7301def6d8ad270390f/numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd", size = 15214643, upload-time = "2026-06-21T20:57:35.677Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/67/73f67b7c7e20635baae9c4c3ead4ae7326a005900297a6110971abd62eb5/numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6", size = 16690128, upload-time = "2026-06-21T20:57:38.159Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/05/d4c1fb0c46d02a27d6b2b8b319a78c90937acec8631c1641874670b31e6f/numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a", size = 16577902, upload-time = "2026-06-21T20:57:40.447Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/1d/771c797d50fa26e4888989cccf1d50ee51f530d4e455ad2692dcb64fa711/numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9", size = 18452814, upload-time = "2026-06-21T20:57:42.875Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/46/52fc0d2a68d7643f0f149eeea5a5d8ea2a3507056ac8afa83c9212606e8b/numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7", size = 6253168, upload-time = "2026-06-21T20:57:45.101Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/be/6c8d1118b5f13b2881dc095d5b345de19c6638b8959c17409b6eff84c8aa/numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa", size = 12736286, upload-time = "2026-06-21T20:57:46.935Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865", size = 10686451, upload-time = "2026-06-21T20:57:49.313Z" },
+]
+
+[[package]]
+name = "opencv-python"
+version = "4.13.0.92"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
+ { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" },
+ { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" },
+ { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" },
+ { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" },
+]
+
[[package]]
name = "orjson"
version = "3.11.9"
@@ -474,11 +700,11 @@ wheels = [
[[package]]
name = "platformdirs"
-version = "4.9.6"
+version = "4.10.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
+ { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
]
[[package]]
@@ -499,6 +725,10 @@ dependencies = [
{ name = "dbus-fast" },
{ name = "evdev" },
{ name = "libarchive-c" },
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
+ { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
+ { name = "opencv-python" },
{ name = "orjson" },
{ name = "pefile" },
{ name = "pillow" },
@@ -527,6 +757,8 @@ requires-dist = [
{ name = "dbus-fast", specifier = ">=5.0.4" },
{ name = "evdev", specifier = ">=1.9.3" },
{ name = "libarchive-c", specifier = ">=5.3" },
+ { name = "numpy", specifier = ">=2.2.0" },
+ { name = "opencv-python", specifier = ">=4.11.0" },
{ name = "orjson", specifier = ">=3.11.8" },
{ name = "pefile", specifier = ">=2024.8.26" },
{ name = "pillow", specifier = ">=12.2.0" },
@@ -640,15 +872,15 @@ wheels = [
[[package]]
name = "pyright"
-version = "1.1.409"
+version = "1.1.411"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/51/4e/3aa27f74211522dba7e9cbc3e74de779c6d4b654c54e50a4840623be8014/pyright-1.1.409.tar.gz", hash = "sha256:986ee05beca9e077c165758ad123667c679e050059a2546aa02473930394bc93", size = 4430434, upload-time = "2026-04-23T11:02:03.799Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/16/6b/330d8ebae582b30c2959a1ef4c3bc344ebde48c2ff0c3f113c4710735e11/pyright-1.1.409-py3-none-any.whl", hash = "sha256:aa3ea228cab90c845c7a60d28db7a844c04315356392aa09fafcee98c8c22fb3", size = 6438161, upload-time = "2026-04-23T11:02:01.309Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" },
]
[[package]]
@@ -702,7 +934,7 @@ wheels = [
[[package]]
name = "pytest"
-version = "9.0.3"
+version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -713,9 +945,9 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
@@ -732,15 +964,15 @@ wheels = [
[[package]]
name = "python-discovery"
-version = "1.3.1"
+version = "1.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
{ name = "platformdirs" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" },
]
[[package]]
@@ -992,14 +1224,14 @@ wheels = [
[[package]]
name = "tqdm"
-version = "4.67.3"
+version = "4.68.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" },
]
[[package]]
@@ -1031,7 +1263,7 @@ wheels = [
[[package]]
name = "virtualenv"
-version = "21.3.3"
+version = "21.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
@@ -1040,9 +1272,9 @@ dependencies = [
{ name = "python-discovery" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" },
]
[[package]]