From 8a02e41b21d9b8c91339bff3ee57194982790e97 Mon Sep 17 00:00:00 2001 From: DeeJanuz <45082401+DeeJanuz@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:08:16 -0600 Subject: [PATCH] Eye tracker: one thread for OpenCV and numpy Nothing called cv2.setNumThreads, so OpenCV kept a pool of one worker per core for pupil windows of 140 to 240 px. Live, its idle workers spun and yielded about 14,000 times a second each, about a quarter of a core, next to SteamVR's compositor. numpy's OpenBLAS also started 8 threads that never had work. eyes_pupil.py now sets OpenCV to one thread, and ft-eyes sets OPENBLAS_NUM_THREADS and OMP_NUM_THREADS to 1 before numpy loads (a value already in the environment wins). Replaying fit1 into a scratch share (ft-eyes-replay, 14 s measured, capped at one core with the replay): threads 13 -> 1, involuntary context switches 3,812/s -> 430/s, system time 6.9% -> 1.6% of a core. Under that cap the frames it kept up with went from 21-36 to 57-69 a second per eye. Co-Authored-By: Claude Opus 5.5 --- gaze/README.md | 2 +- gaze/tracker/eyes_pupil.py | 7 ++++++- gaze/tracker/ft-eyes | 7 ++++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/gaze/README.md b/gaze/README.md index 8143f51..3346530 100644 --- a/gaze/README.md +++ b/gaze/README.md @@ -63,7 +63,7 @@ Ground rules, for anyone changing it: - **Clean room.** Nothing of Valve's goes in: we don't decompile, disassemble, or patch the `eyetracking` binary or its network weights, and we don't copy their code or weights. Its public output (eye-server.mmap, read-only) is fair game as a baseline and as labels, and so are published papers and openly licensed pupil detectors (check each one's license: PuRe, PuReST, ElSe, and ExCuSe are non-commercial only). - **Root only reads.** ft-eyegrab never writes to, stops, or signals the `eyetracking` process, vrserver, or vrcompositor, never opens `/dev/adsp`, `/dev/cdsp`, or `/dev/spidev0.1`, and never writes to `/dev/shm/eye-server.mmap` (it also carries calibration clicks into SteamVR's tracker), `/opt`, or `/persist`. - **Eye images are biometric data.** Recordings live outside the repo, in `~/.local/share/frametop/eyes/captures` (0700), and `.gitignore` catches stray frame dumps. They go nowhere but the machine that runs your offline jobs. -- **Mind the headset's budget.** Finding a pupil takes about 0.4 ms a frame while ft-eyes follows it, and 1.4-2.1 ms when it searches the whole frame. Replays, scoring, and training go to a PC. +- **Mind the headset's budget.** Finding a pupil takes about 0.4 ms a frame while ft-eyes follows it, and 1.4-2.1 ms when it searches the whole frame. ft-eyes keeps OpenCV and numpy to one thread: their pools of one per core spun idle workers at about a quarter of a core, for frames this small. Replays, scoring, and training go to a PC. ## Headset fit diff --git a/gaze/tracker/eyes_pupil.py b/gaze/tracker/eyes_pupil.py index 8884363..cf3672d 100644 --- a/gaze/tracker/eyes_pupil.py +++ b/gaze/tracker/eyes_pupil.py @@ -9,7 +9,12 @@ the search runs again with a smaller closing. import cv2 import numpy as np -DARK = 30 # pupil pixels are below this (the face around it is 40-180) +# One thread: OpenCV's pool of one per core costs more than it saves on a frame this small +# (a 140-240 px window while it follows the pupil). Its idle workers spun and yielded about +# 14,000 times a second each, a quarter of a core, beside SteamVR's compositor. +cv2.setNumThreads(1) + +DARK = 30 # pupil pixels are below this (the face around it is 40-180) MIN_AREA = 150 # pupil area range in pixels MAX_AREA = 20000 MIN_FILL = 0.75 # blob area / fitted-ellipse area diff --git a/gaze/tracker/ft-eyes b/gaze/tracker/ft-eyes index 5e25799..66bbff7 100755 --- a/gaze/tracker/ft-eyes +++ b/gaze/tracker/ft-eyes @@ -58,7 +58,12 @@ import time from collections import deque from pathlib import Path -import numpy as np +# One thread for numpy's BLAS and OpenMP, set before numpy loads: it would start one per core +# (8 here) for small arrays that never need them. eyes_pupil keeps OpenCV to one as well. +for _var in ("OPENBLAS_NUM_THREADS", "OMP_NUM_THREADS"): + os.environ.setdefault(_var, "1") + +import numpy as np # noqa: E402 sys.path.insert(0, str(Path(__file__).resolve().parent)) import eyes_model # noqa: E402