Files
DeeJanuz--frametop/gaze/tracker/eyes_pupil.py
T
DeeJanuzandClaude Opus 5.5 8a02e41b21 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 <noreply@anthropic.com>
2026-10-03 09:08:16 -06:00

145 lines
6.3 KiB
Python

"""Classic pupil and glint finder for one 512x400 eye-camera frame.
The pupil is a dark blob enclosed by brighter iris and skin. The lens rim and the unlit
background are just as dark, but they touch the image edge, so any dark region that reaches
the edge is dropped. Closing the glints' holes can join the pupil to that background (the
left camera's, when you look more than about 20 degrees left), so when nothing is found
the search runs again with a smaller closing.
"""
import cv2
import numpy as np
# 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
MAX_ASPECT = 3.0 # long / short axis; the steep camera sees a squashed pupil
GLINT = 200 # glints are near-saturated spots on or by the pupil
CLOSE = 7 # px: closes the glints' holes in the pupil (the right eye's need this much)
CLOSE_TIGHT = 3 # px: the retry, keeps a pupil near the dark background apart from it
NEAR = 70 # the windowed search: this many pixels, or 3 pupil radii, around a hint
def find_pupil(frame, near=None):
"""Return dict(x, y, a, b, angle, area, fill, glints) or None.
`near` (a previous result) searches a window around it first (0.4 ms, not 1.4-2.1);
if the pupil isn't wholly inside the window it falls back to the whole frame."""
if near is not None:
r = int(max(NEAR, 3 * near['a']))
x0, y0 = max(int(near['x']) - r, 0), max(int(near['y']) - r, 0)
p = _find_either(frame[y0:int(near['y']) + r, x0:int(near['x']) + r], x0, y0)
if p is not None:
p['glints'] = find_glints(frame, p)
return p
p = _find_either(frame, 0, 0)
if p is not None:
p['glints'] = find_glints(frame, p)
return p
def _find_either(img, ox, oy):
p = _find(img, ox, oy)
return p if p is not None else _find(img, ox, oy, CLOSE_TIGHT)
def _find(img, ox, oy, close=CLOSE):
"""The pupil in `img` (a window at ox, oy of the frame), with frame coordinates. A dark
region touching the window's edge doesn't count: it's background, rim, or cut off."""
f = cv2.GaussianBlur(img, (5, 5), 0)
dark = (f < DARK).astype(np.uint8)
# Glints punch bright holes in the pupil; close them so the blob stays whole.
dark = cv2.morphologyEx(dark, cv2.MORPH_CLOSE, np.ones((close, close), np.uint8))
dark = cv2.morphologyEx(dark, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8))
n, lab, stats, _ = cv2.connectedComponentsWithStats(dark, connectivity=8)
h, w = img.shape
best = None
for i in range(1, n):
x, y, bw, bh, area = stats[i]
if area < MIN_AREA or area > MAX_AREA:
continue
if x <= 1 or y <= 1 or x + bw >= w - 1 or y + bh >= h - 1:
continue
blob = lab[y:y + bh, x:x + bw] == i
cs, _ = cv2.findContours(blob.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
c = max(cs, key=len)
if len(c) < 5:
continue
(ex, ey), (d1, d2), ang = cv2.fitEllipse(c)
a, b = max(d1, d2) / 2, min(d1, d2) / 2
if b < 3 or a / b > MAX_ASPECT:
continue
fill = area / (np.pi * a * b)
if fill < MIN_FILL or fill > 1.25:
continue
# Prefer the darkest, fullest blob.
score = fill - img[y:y + bh, x:x + bw][blob].mean() / 255
if best is None or score > best[0]:
# fitEllipse's angle is the direction of its first axis (d1); the long axis is
# that one or the one at right angles.
major = np.radians(ang if d1 >= d2 else ang + 90)
best = (score, dict(x=ex + x + ox, y=ey + y + oy, a=a, b=b, angle=ang, major=major,
area=int(area), fill=fill, box=(x + ox, y + oy, bw, bh)))
return best[1] if best else None
GLINT_RING = 140 # a glint sits on dark iris or pupil: its surroundings are below this
GLINT_PAIR = (5, 45) # the two LEDs' reflections: this far apart, in pixels, mostly vertical
def find_glints(frame, p):
"""Small bright spots on dark iris or pupil within 2.5 pupil radii, as (x, y) list.
Bright skin has noise speckle above GLINT too, so a spot counts only if the ring
around it is dark."""
r = int(p['a'] * 2.5) + 6
x0, y0 = max(int(p['x']) - r, 0), max(int(p['y']) - r, 0)
roi = frame[y0:int(p['y']) + r, x0:int(p['x']) + r]
n, lab, stats, cents = cv2.connectedComponentsWithStats((roi >= GLINT).astype(np.uint8))
out = []
for i in range(1, n):
x, y, w, h, area = stats[i]
if not 2 <= area <= 80:
continue
ya, yb, xa, xb = max(y - 4, 0), y + h + 4, max(x - 4, 0), x + w + 4
ring = roi[ya:yb, xa:xb][lab[ya:yb, xa:xb] != i]
if ring.size and np.median(ring) < GLINT_RING:
out.append((cents[i][0] + x0, cents[i][1] + y0))
return out
def glint_pair(p):
"""The two LED reflections as ((x, y) upper, (x, y) lower), or None. Picks the
vertical-ish pair nearest the pupil centre."""
g = p['glints']
best = None
for i in range(len(g)):
for j in range(i + 1, len(g)):
dx, dy = g[j][0] - g[i][0], g[j][1] - g[i][1]
d = np.hypot(dx, dy)
if not GLINT_PAIR[0] <= d <= GLINT_PAIR[1] or abs(dy) < 2 * abs(dx):
continue
mx, my = (g[i][0] + g[j][0]) / 2, (g[i][1] + g[j][1]) / 2
cost = np.hypot(mx - p['x'], my - p['y'])
if best is None or cost < best[0]:
best = (cost, (g[i], g[j]) if dy > 0 else (g[j], g[i]))
return best[1] if best else None
def draw(frame, p, scale=1.0):
img = cv2.cvtColor(cv2.convertScaleAbs(frame, alpha=2.0), cv2.COLOR_GRAY2BGR)
if p:
cv2.ellipse(img, ((p['x'], p['y']), (2 * p['a'], 2 * p['b']), np.degrees(p['major'])),
(0, 255, 0), 1)
for gx, gy in p['glints']:
cv2.circle(img, (int(gx), int(gy)), 3, (0, 0, 255), 1)
if scale != 1.0:
img = cv2.resize(img, None, fx=scale, fy=scale)
return img