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
147 lines
4.7 KiB
Python
147 lines
4.7 KiB
Python
import numpy as np
|
|
import pytest
|
|
|
|
from hermes_face.render import H, STATES, W, FaceRenderer, box_blur
|
|
|
|
|
|
def run(r, seconds, t0=0.0, fps=30):
|
|
t, frame = t0, None
|
|
for _ in range(int(seconds * fps)):
|
|
t += 1 / fps
|
|
frame = r.frame(t, 1 / fps)
|
|
return frame, t
|
|
|
|
|
|
def brightness(frame):
|
|
return float(frame.astype(np.float32).mean())
|
|
|
|
|
|
@pytest.mark.parametrize('state', STATES)
|
|
@pytest.mark.parametrize('style', ['phosphor', 'vector'])
|
|
def test_every_state_draws_something(state, style):
|
|
frame, _ = run(FaceRenderer(style=style, state=state, seed=0), 3)
|
|
assert frame.shape == (H, W, 3) and frame.dtype == np.uint8
|
|
assert frame.max() > 100
|
|
|
|
|
|
def test_sleeping_is_dimmer_than_idle():
|
|
idle, _ = run(FaceRenderer(state='idle', seed=0), 4)
|
|
asleep, _ = run(FaceRenderer(state='sleeping', seed=0), 4)
|
|
assert brightness(asleep) < 0.8 * brightness(idle)
|
|
|
|
|
|
def test_wakes_up():
|
|
r = FaceRenderer(state='sleeping', seed=0)
|
|
_, t = run(r, 2)
|
|
r.set_state('idle', t)
|
|
frame, _ = run(r, 3, t)
|
|
idle, _ = run(FaceRenderer(state='idle', seed=0), 3)
|
|
assert r.a['open'] > 0.9 and abs(brightness(frame) - brightness(idle)) < 0.2 * brightness(idle)
|
|
|
|
|
|
def test_looking_returns_to_the_state_it_interrupted():
|
|
r = FaceRenderer(state='thinking', seed=0)
|
|
_, t = run(r, 1)
|
|
r.set_state('looking', t)
|
|
assert r.state == 'looking'
|
|
run(r, 2, t)
|
|
assert r.state == 'thinking'
|
|
|
|
|
|
def test_unknown_state_rejected():
|
|
with pytest.raises(ValueError):
|
|
FaceRenderer().set_state('dancing', 0.0)
|
|
|
|
|
|
def test_pan_moves_the_eyes():
|
|
def centroid_x(pan):
|
|
r = FaceRenderer(state='idle', seed=0, jitter=0)
|
|
r.set_pose(pan, 0)
|
|
frame, _ = run(r, 2)
|
|
lum = frame.astype(np.float32).sum(axis=(0, 2))
|
|
return float((lum * np.arange(W)).sum() / lum.sum())
|
|
assert centroid_x(60) > centroid_x(0) > centroid_x(-60) # +pan = robot's left = viewer's right
|
|
|
|
|
|
def test_box_blur_conserves_energy():
|
|
b = np.zeros((H, W), np.float32)
|
|
b[120, 120] = 1.0
|
|
out = box_blur(b, 2)
|
|
assert abs(out.sum() - 1.0) < 1e-3
|
|
assert np.allclose(out[118:123, 118:123], 1 / 25, atol=1e-5)
|
|
|
|
|
|
def test_rgb565_frame_matches_rgb_frame():
|
|
a = FaceRenderer(state='idle', seed=3)
|
|
b = FaceRenderer(state='idle', seed=3)
|
|
rgb, raw = None, None
|
|
for i in range(1, 40):
|
|
rgb = a.frame(i / 30, 1 / 30)
|
|
raw = b.frame(i / 30, 1 / 30, 'rgb565')
|
|
assert len(raw) == H * W * 2
|
|
px = np.frombuffer(raw, '>u2').reshape(H, W)
|
|
assert np.array_equal(px >> 11, rgb[..., 0] >> 3)
|
|
assert np.array_equal((px >> 5) & 0x3F, rgb[..., 1] >> 2)
|
|
assert np.array_equal(px & 0x1F, rgb[..., 2] >> 3)
|
|
|
|
|
|
@pytest.mark.parametrize('rotation', [90, 180, 270])
|
|
def test_rotation_turns_the_frame_counter_clockwise(rotation):
|
|
base = FaceRenderer(state='idle', seed=5, rotation=0)
|
|
turned = FaceRenderer(state='idle', seed=5, rotation=rotation)
|
|
a = b = None
|
|
for i in range(1, 30):
|
|
a = base.frame(i / 30, 1 / 30)
|
|
b = turned.frame(i / 30, 1 / 30)
|
|
assert np.array_equal(b, np.rot90(a, rotation // 90))
|
|
|
|
|
|
def test_bad_rotation_rejected():
|
|
with pytest.raises(ValueError):
|
|
FaceRenderer(rotation=45)
|
|
|
|
|
|
def test_wild_parameters_are_clamped():
|
|
r = FaceRenderer(focus=1e9, persist_ms=0, intensity=-3, jitter=1e6, seed=0)
|
|
assert (r.focus, r.persist_ms, r.intensity, r.jitter) == (1.0, 5.0, 0.0, 5.0)
|
|
assert run(r, 1)[0].shape == (H, W, 3)
|
|
|
|
|
|
def test_unknown_phosphor_rejected():
|
|
with pytest.raises(ValueError):
|
|
FaceRenderer(phosphor='P99')
|
|
|
|
|
|
def eye_centroid(r, seconds, t0=0.0):
|
|
frame, t = run(r, seconds, t0)
|
|
lum = frame[:130].astype(np.float32).sum(axis=(0, 2)) # the eyes live in the top half
|
|
rows = frame[:130].astype(np.float32).sum(axis=(1, 2))
|
|
return float((lum * np.arange(W)).sum() / lum.sum()), float((rows * np.arange(130)).sum() / rows.sum()), t
|
|
|
|
|
|
def test_gaze_moves_the_eyes_toward_the_person_and_drifts_back():
|
|
r = FaceRenderer(state='idle', seed=0, jitter=0)
|
|
cx0, cy0, t = eye_centroid(r, 2)
|
|
r.set_gaze(1.0, 0.0, t)
|
|
cx1, _, t = eye_centroid(r, 1, t) # held, so still glancing
|
|
assert cx1 > cx0 + 5
|
|
r.set_gaze(0.0, -1.0, t)
|
|
_, cy2, t = eye_centroid(r, 1, t)
|
|
assert cy2 < cy0 - 3 # up on the display
|
|
cx3, cy3, _ = eye_centroid(r, 8, t) # no refresh: hold expires, eyes return
|
|
assert abs(cx3 - cx0) < 2 and abs(cy3 - cy0) < 2
|
|
|
|
|
|
def test_sleeping_ignores_gaze():
|
|
r = FaceRenderer(state='sleeping', seed=0, jitter=0)
|
|
cx0, _, t = eye_centroid(r, 4)
|
|
r.set_gaze(1.0, 0.0, t)
|
|
cx1, _, _ = eye_centroid(r, 2, t)
|
|
assert abs(cx1 - cx0) < 1.5
|
|
|
|
|
|
def test_gaze_is_clamped():
|
|
r = FaceRenderer(seed=0)
|
|
r.set_gaze(50, -50, 0.0)
|
|
assert r._gaze == (1.0, -1.0)
|