Make the eyes follow detected faces; start the face at power-up

hermes_face now subscribes to /vision/faces and glances toward the best face (a
recognised one first, then the biggest), smoothed, holding 2.5 s and drifting
back to centre when none is seen. Sleeping ignores gaze. Image x flips on the
display: the camera looks the way the robot faces, so a person on the image's
right is on the robot's right, the viewer's left.

Fix the pan sign carried over from the prototype: positive pan is the robot's
left (the MCP contract), which is the viewer's right on the display.

robot-face.service starts the face at boot and restarts it on failure, with a
private working directory because lgpio drops a notification pipe in its cwd and
systemd's default / is not writable. robot-cpu-performance.service holds the CPU
at full clock, which halves the face's CPU cost.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFN8nRRCiDiMN7ChBpHJhs
This commit is contained in:
2026-10-04 23:11:19 -07:00
co-authored by Claude Sonnet 5.5
parent e54ad8aa04
commit f8525c5526
9 changed files with 178 additions and 7 deletions
+31
View File
@@ -0,0 +1,31 @@
"""Where the eyes should look, given where faces are in the camera image. No ROS imports."""
from __future__ import annotations
from typing import NamedTuple, Optional, Sequence
class Target(NamedTuple):
cx: float # face centre, pixels
cy: float
w: float # face size, pixels
h: float
score: float = 1.0
name: str = '' # who, if recognised
def pick_target(faces: Sequence[Target]) -> Optional[Target]:
"""A recognised face wins over an unknown one; otherwise the biggest, most confident face."""
if not faces:
return None
return max(faces, key=lambda f: (bool(f.name), f.w * f.h * f.score))
def gaze_from_pixel(cx: float, cy: float, width: float, height: float) -> tuple[float, float]:
"""Image position -> eye direction (+x the viewer's right, +y down), each in [-1, 1].
The camera looks the way the robot faces, so a person on the right of the image is on the
robot's right, which is the viewer's left on the display: x flips, y does not.
"""
gx = (cx - width / 2.0) / (width / 2.0)
gy = (cy - height / 2.0) / (height / 2.0)
return -max(-1.0, min(1.0, gx)), max(-1.0, min(1.0, gy))