Files
hermes-bot/ros/hermes_face/test/test_render.py
T
kyleandClaude Sonnet 5.5 55d33ab490 Add hermes_face ROS package and verify the display on the Pi
The Pi (hermes-bot) is flashed, upgraded and running ROS 2 Jazzy. The ST7789
works through Blinka on lgpio: pass cs=None (the kernel owns CE0), and the glass
sits at y_offset=80 in the controller RAM for every rotation, since the driver
rotates in software. The mounted pose needs rotation=270.

hermes_face is a numpy port of the flat face from docs/face-prototype.html, with
phosphor and vector styles, plus a new `sleeping` pose for "no agent connected".
The renderer has no ROS or hardware imports. The tone map is a lookup table and
the glow runs at half resolution, so the face reaches ~28 fps on the panel at
24% of a core (20 fps) to 40% (30 fps) with the CPU at full clock.

CONTEXT.md now records the Pi, the cloud-init first-boot-only gotcha, and what
is left. pi/50-cloud-init.yaml is a gitignored Wi-Fi template.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFN8nRRCiDiMN7ChBpHJhs
2026-10-04 20:35:33 -07:00

102 lines
3.2 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)