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) def mouth_rows(frame): """Row extent of lit pixels in the mouth band.""" band = frame[140:215].astype(np.float32).sum(axis=(1, 2)) rows = np.nonzero(band > 0.15 * band.max())[0] return int(rows.max() - rows.min()) if len(rows) else 0 def run_scope(kind, state, amp): r = FaceRenderer(state=state, seed=0, jitter=0) t, frame = 0.0, None for _ in range(60): t += 1 / 30 r.set_scope(kind, amp * np.sin(np.linspace(0, 8 * np.pi, 256)), t) # refreshed every frame frame = r.frame(t, 1 / 30) return frame def test_the_mouth_draws_the_real_waveform_and_autoscales_it(): big, small = run_scope('speaker', 'speaking', 0.8), run_scope('speaker', 'speaking', 0.02) assert mouth_rows(big) > 25 and mouth_rows(small) > 25 # a quiet voice is still visible assert abs(mouth_rows(big) - mouth_rows(small)) < 8 def test_the_mic_waveform_is_drawn_only_while_listening_and_stale_data_is_ignored(): listening = mouth_rows(run_scope('mic', 'listening', 0.5)) idle = mouth_rows(run_scope('mic', 'idle', 0.5)) assert listening > idle + 6 r = FaceRenderer(state='speaking', seed=0, jitter=0) r.set_scope('speaker', np.sin(np.linspace(0, 6, 128)), 0.0) frame, _ = run(r, 2) # the scope goes stale: synthetic voice, no crash assert frame.max() > 100 def test_unknown_scope_kind_rejected(): with pytest.raises(ValueError): FaceRenderer().set_scope('radio', [0.0] * 16, 0.0)