Hermes asked for structured detections instead of a bare JPEG. faces() returns the
faces in view with no photo: a persistent track id, bbox, detection confidence, how
long the person has been in view, and identity {name, similarity} or null, never a
made-up name. look() returns that same JSON as text plus the photo (send_image can
skip it); list_people() gives names and counts; robot://status and two prompts make
the server discoverable, where list_resources and list_prompts were empty. The
detector now tracks faces across frames (FaceTracker) and publishes the track id and
the identity as a second hypothesis.
Deliberately not built: embeddings in responses, age/gender attributes, auto-updating
face data, and a remote enroll tool (a prompt could make the robot call anyone "Kyle").
Checked end to end: a real Hermes turn on the local ornith model called faces() (0.03 s,
no image) and answered that Kyle was there at 0.78 similarity. Also: the camera is
calibrated (18 of 24 views, 0.296 px, outlier rejection added) and the calibration is
kept in pi/calibration and restored by the installer.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFN8nRRCiDiMN7ChBpHJhs
159 lines
7.6 KiB
Python
159 lines
7.6 KiB
Python
"""ROS node for the face. Draws on the BrainCraft HAT's display.
|
|
|
|
Subscribes: /hermes/state (std_msgs/String): sleeping idle listening thinking looking speaking
|
|
/joint_states (sensor_msgs/JointState): pan_joint, tilt_joint in radians. The sign
|
|
convention is the MCP tool's: positive pan = the robot's left, positive tilt = up.
|
|
/vision/faces (vision_msgs/Detection2DArray): the eyes glance toward the best face (a named one
|
|
first: the name is results[1].class_id)
|
|
/camera/camera_info (sensor_msgs/CameraInfo): the image size for that, if published
|
|
/audio/activity (std_msgs/String): speaking | listening | quiet, from hermes_audio
|
|
/audio/scope/{mic,speaker} (std_msgs/Float32MultiArray): the real waveform for the mouth
|
|
/hermes/state doubles as the agent's heartbeat: hermes_mcp republishes it about once a second, and
|
|
when it has not been heard for `agent_timeout_s` (default 10, 0 disables) the face goes to sleep, so
|
|
"sleeping" really means "no agent connected".
|
|
Which state shows is decided by hermes_face.arbiter: sleeping (agent only) > speaking > listening >
|
|
thinking > idle; room sound never wakes a sleeping face.
|
|
The face starts in `initial_state` (default `sleeping`), so it runs with nothing else up; whichever
|
|
node knows the agent is connected (hermes_mcp) publishes `idle` to wake it.
|
|
"""
|
|
import math
|
|
import time
|
|
|
|
import rclpy
|
|
from rclpy.node import Node
|
|
from sensor_msgs.msg import CameraInfo, JointState
|
|
from std_msgs.msg import Float32MultiArray, String
|
|
from vision_msgs.msg import Detection2DArray
|
|
|
|
from .arbiter import Heartbeat, resolve_state
|
|
from .gaze import Target, gaze_from_pixel, pick_target
|
|
from .render import STATES, FaceRenderer
|
|
|
|
|
|
class FaceNode(Node):
|
|
def __init__(self):
|
|
super().__init__('hermes_face')
|
|
param = lambda name, default: self.declare_parameter(name, default).value # noqa: E731
|
|
self.fps = min(60.0, max(1.0, float(param('fps', 30.0)))) # a timer period of 1/fps
|
|
output = param('output', 'st7789') # 'st7789' or 'none' (render only)
|
|
self.pan_joint = param('pan_joint', 'pan_joint')
|
|
self.tilt_joint = param('tilt_joint', 'tilt_joint')
|
|
initial = param('initial_state', 'sleeping')
|
|
self.agent_state = initial if initial in STATES else 'sleeping'
|
|
self.audio_activity = 'quiet'
|
|
self._logged_state = ''
|
|
self.heartbeat = Heartbeat(float(param('agent_timeout_s', 10.0)))
|
|
self.audio_drive = bool(param('audio_drive', True))
|
|
self.follow = bool(param('gaze_follow', True))
|
|
self.image_size = (float(param('camera_width', 1296)), float(param('camera_height', 972)))
|
|
|
|
self.renderer = FaceRenderer(
|
|
phosphor=param('phosphor', 'P7'), style=param('style', 'phosphor'),
|
|
persist_ms=float(param('persistence_ms', 70.0)), focus=float(param('focus', 0.35)),
|
|
jitter=float(param('jitter', 0.3)), intensity=float(param('intensity', 1.0)),
|
|
state=initial if initial in STATES else 'sleeping', now=time.monotonic(),
|
|
rotation=int(param('rotation', 270))) # 270: the mounted pose, microSD end up
|
|
|
|
self.display = None
|
|
if output == 'st7789':
|
|
from .display import AsyncDisplay, St7789
|
|
self.display = AsyncDisplay(St7789(
|
|
x_offset=int(param('x_offset', 0)), y_offset=int(param('y_offset', 80)),
|
|
baudrate=int(param('baudrate', 48_000_000))))
|
|
|
|
self.create_subscription(String, '/hermes/state', self._on_state, 10)
|
|
self.create_subscription(JointState, '/joint_states', self._on_joints, 10)
|
|
if self.audio_drive:
|
|
self.create_subscription(String, '/audio/activity', self._on_activity, 10)
|
|
for kind in ('mic', 'speaker'):
|
|
self.create_subscription(Float32MultiArray, f'/audio/scope/{kind}',
|
|
lambda m, k=kind: self.renderer.set_scope(k, m.data, time.monotonic()), 2)
|
|
if self.follow:
|
|
self.create_subscription(Detection2DArray, '/vision/faces', self._on_faces, 10)
|
|
self.create_subscription(CameraInfo, '/camera/camera_info', self._on_camera_info, 1)
|
|
self._last = time.monotonic()
|
|
self._frames, self._stat_t = 0, self._last
|
|
self.create_timer(1.0 / self.fps, self._tick)
|
|
self.get_logger().info(f'face up: {output}, {self.fps:g} fps, state {self.renderer.state}')
|
|
self._resolve(time.monotonic())
|
|
|
|
def _on_state(self, msg: String) -> None:
|
|
if msg.data not in STATES:
|
|
self.get_logger().warn(f'ignoring unknown state {msg.data!r}; known: {", ".join(STATES)}')
|
|
return
|
|
now = time.monotonic()
|
|
self.heartbeat.beat(now)
|
|
if msg.data == 'looking': # a timed event: the renderer squints, flashes, then goes back
|
|
self.renderer.set_state('looking', now)
|
|
return
|
|
self.agent_state = msg.data
|
|
self._resolve(now)
|
|
|
|
def _on_activity(self, msg: String) -> None:
|
|
self.audio_activity = msg.data
|
|
self._resolve(time.monotonic())
|
|
|
|
def _resolve(self, now: float) -> None:
|
|
if self.agent_state != 'sleeping' and not self.heartbeat.alive(now):
|
|
self.get_logger().info(f'no /hermes/state for {self.heartbeat.timeout_s:g} s: agent gone, sleeping')
|
|
self.agent_state = 'sleeping'
|
|
if self.renderer.state != 'looking':
|
|
self.renderer.set_state(resolve_state(self.agent_state, self.audio_activity if self.audio_drive else 'quiet'), now)
|
|
if self.renderer.state != self._logged_state:
|
|
self._logged_state = self.renderer.state
|
|
self.get_logger().info(f'state: {self._logged_state} (agent={self.agent_state}, audio={self.audio_activity})')
|
|
|
|
def _on_joints(self, msg: JointState) -> None:
|
|
pos = dict(zip(msg.name, msg.position))
|
|
if self.pan_joint in pos or self.tilt_joint in pos:
|
|
self.renderer.set_pose(math.degrees(pos.get(self.pan_joint, 0.0)),
|
|
math.degrees(pos.get(self.tilt_joint, 0.0)))
|
|
|
|
def _on_camera_info(self, msg: CameraInfo) -> None:
|
|
if msg.width and msg.height:
|
|
self.image_size = (float(msg.width), float(msg.height))
|
|
|
|
def _on_faces(self, msg: Detection2DArray) -> None:
|
|
target = pick_target([
|
|
Target(d.bbox.center.position.x, d.bbox.center.position.y, d.bbox.size_x, d.bbox.size_y,
|
|
d.results[0].hypothesis.score if d.results else 1.0,
|
|
d.results[1].hypothesis.class_id if len(d.results) > 1 else '') # the name, if recognised
|
|
for d in msg.detections])
|
|
if target:
|
|
gx, gy = gaze_from_pixel(target.cx, target.cy, *self.image_size)
|
|
self.renderer.set_gaze(gx, gy, time.monotonic())
|
|
|
|
def _tick(self) -> None:
|
|
now = time.monotonic()
|
|
self._resolve(now) # also settles the state a finished `looking` returned to
|
|
rgb = self.renderer.frame(now, now - self._last, 'rgb565')
|
|
self._last = now
|
|
if self.display:
|
|
self.display.show(rgb)
|
|
self._frames += 1
|
|
if now - self._stat_t >= 30.0:
|
|
self.get_logger().debug(f'{self._frames / (now - self._stat_t):.1f} fps')
|
|
self._frames, self._stat_t = 0, now
|
|
|
|
def destroy_node(self):
|
|
if self.display:
|
|
self.display.close()
|
|
super().destroy_node()
|
|
|
|
|
|
def main(args=None):
|
|
rclpy.init(args=args)
|
|
node = FaceNode()
|
|
try:
|
|
rclpy.spin(node)
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
node.destroy_node()
|
|
if rclpy.ok():
|
|
rclpy.shutdown()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|