look_whos_talking / tests /test_engine.py
Dwain
Look Who's Talking v0.1.0 - multi-person active-speaker gaze for Reachy Mini
e6f68f5
Raw
History Blame Contribute Delete
12 kB
import json
import math
import numpy as np
import pytest
from look_whos_talking.config import EngineConfig
from look_whos_talking.engine.engine import GazeEngine
from look_whos_talking.engine.types import AudioState, RawFace, VisionFrame
SIZE = (1920, 1080)
def frame(centers, ts, patches=None):
faces = []
for i, cx in enumerate(centers):
patch = None if patches is None else patches[i]
faces.append(RawFace(bbox=(cx - 100, 400, 200, 200), right_eye=(cx - 40, 470),
left_eye=(cx + 40, 470), nose=(cx, 520), mouth_patch=patch))
return VisionFrame(faces=tuple(faces), ts=ts, frame_size=SIZE)
def frame_with_patch(cx, ts, patch):
"""One face at `cx` carrying `patch` as its mouth patch."""
return frame([cx], ts, [patch])
def talking_patch(rng):
return rng.integers(0, 255, (24, 32)).astype(np.uint8)
def still_patch():
return (np.ones((24, 32)) * 128).astype(np.uint8)
def test_solo_person_pixel_aim_with_bias():
cfg = EngineConfig.from_dict({"servo": {"aim_bias_v_frac": 0.1}})
eng = GazeEngine(cfg)
out = eng.step(frame([960], ts=0.0), None, now=0.0)
assert out.aim.kind == "pixel"
assert abs(out.aim.u - 960) < 1e-6
assert abs(out.aim.v - (520 + 0.1 * 200)) < 1e-6 # nose_v + bias*bbox_h
def test_aim_bias_scales_with_face_height_not_width():
# Wide-but-short box: pins the bias to bbox h, catching a bbox[2]/bbox[3] slip
# that a square test face cannot see.
cfg = EngineConfig.from_dict({"servo": {"aim_bias_v_frac": 0.5}})
eng = GazeEngine(cfg)
wide = RawFace(bbox=(800, 400, 400, 100), right_eye=(920, 470),
left_eye=(1000, 470), nose=(960, 520))
out = eng.step(VisionFrame(faces=(wide,), ts=0.0, frame_size=SIZE), None, now=0.0)
assert abs(out.aim.v - (520 + 0.5 * 100)) < 1e-6
def test_two_people_switches_to_talker():
rng = np.random.default_rng(1)
eng = GazeEngine()
t = 0.0
out = None
while t < 6.0: # face 2 talks, face 1 still; audio says speech
f = frame([400, 1500], ts=t, patches=[still_patch(), talking_patch(rng)])
out = eng.step(f, AudioState(speech=True, doa_angle=None, ts=t), now=t)
t += 0.1
snap = eng.snapshot()
assert snap["target_id"] == 2
assert snap["state"] == "GROUP"
assert [f["is_target"] for f in snap["faces"]] == [False, True]
assert out.aim.kind == "pixel" and out.aim.u > 1000
def test_none_vision_scans():
eng = GazeEngine()
out = None
for i in range(60):
out = eng.step(None, None, now=i * 0.1)
assert out.aim.kind == "pose"
def test_snapshot_json_safe():
eng = GazeEngine()
eng.step(frame([960], ts=0.0), AudioState(True, 1.57, 0.0), now=0.0)
snap = eng.snapshot()
s = json.dumps(snap)
assert "target_id" in s
assert snap["audio"] == {"speech": True, "doa_angle": 1.57, "stale": False}
assert snap["faces"][0]["is_target"] is True
# snapshot() hands out a copy: a dashboard mutating it cannot corrupt or
# tear the engine's own state.
snap["faces"].append("junk")
assert len(eng.snapshot()["faces"]) == 1
def test_snapshot_json_safe_under_numpy_typed_inputs():
# The vision and audio workers hand back numpy scalars (cv2 boxes, DoA
# maths). json.dumps rejects every one of them, so the snapshot has to
# coerce at the boundary or the dashboard 500s on live hardware.
eng = GazeEngine()
proto = frame([960], ts=0.0).faces[0]
face = RawFace(bbox=tuple(np.float32(v) for v in proto.bbox),
right_eye=proto.right_eye, left_eye=proto.left_eye, nose=proto.nose)
vision = VisionFrame(faces=(face,), ts=0.0, frame_size=SIZE)
audio = AudioState(speech=np.bool_(True), doa_angle=np.float32(1.57), ts=0.0)
out = eng.step(vision, audio, now=np.float32(0.0))
snap = eng.snapshot()
json.dumps(snap)
assert type(snap["audio"]["doa_angle"]) is float
assert type(out.aim.v) is float
# Antennas are derived from the fused scores, so numpy bboxes/DoA leak
# np.float32 all the way to the driver unless the boundary coerces them.
assert [type(a) for a in out.antennas] == [float, float]
def test_snapshot_is_well_formed_before_the_first_step():
snap = GazeEngine().snapshot()
json.dumps(snap)
assert snap["audio"] == {"speech": False, "doa_angle": None, "stale": False}
assert snap["target_id"] is None
assert snap["faces"] == [] and snap["events"] == []
def test_stale_audio_is_never_shown_in_the_snapshot():
# The selector discards audio older than max(vad_hold_s, 0.5); the snapshot
# must not display a reading the engine refused to act on.
eng = GazeEngine()
eng.step(frame([960], ts=10.0), AudioState(speech=True, doa_angle=1.57, ts=0.0),
now=10.0)
assert eng.snapshot()["audio"] == {"speech": False, "doa_angle": None, "stale": True}
def test_camera_death_degrades_to_scan_not_ghost_face():
# A stalled vision worker keeps handing back its LAST frame forever. Without
# a staleness verdict the engine reads that fossil as a live face and stays
# ENGAGED on a pixel that no longer exists.
eng = GazeEngine()
for i in range(20): # live face for 2 s
ts = i * 0.1
eng.step(frame([960], ts=ts), None, now=ts)
assert eng.snapshot()["state"] == "ENGAGED"
last = frame([960], ts=1.9)
# Camera dies: same last frame re-delivered, ts frozen at 1.9
out = None
for i in range(60): # 6 s of frozen mailbox
now = 2.0 + i * 0.1
out = eng.step(last, None, now=now)
snap = eng.snapshot()
assert snap["state"] == "ALONE" # tracks aged out; no ghost ENGAGED
assert snap["faces"] == [] or all(f["misses"] > 0 for f in snap["faces"])
assert out.aim.kind == "pose" # scanning, not aiming at a fossil pixel
def test_stale_audio_never_fires_sound_glance():
# The selector drops stale audio on its own, but behavior used to receive the
# raw reading -- so a dead mic worker's last bearing latched the gaze while
# the snapshot simultaneously reported it stale.
eng = GazeEngine()
fossil = AudioState(speech=True, doa_angle=math.pi, ts=0.0)
events = []
for i in range(80): # 8 s alone with a 10-s-stale reading replayed
now = 10.0 + i * 0.1
events.extend(eng.step(None, fossil, now=now).events)
assert "sound_glance" not in events # fossil bearing must not latch the gaze
snap = eng.snapshot()
assert snap["audio"]["stale"] is True
assert snap["audio"]["speech"] is False
def test_hold_aim_passes_through_untouched():
# Target lost for less than lost_hold_s: behavior returns Aim("hold"),
# meaning "keep the previous pose target". The engine must forward it
# verbatim -- resolution belongs to the driver, not here.
cfg = EngineConfig.from_dict({"servo": {"aim_bias_v_frac": 0.25}})
eng = GazeEngine(cfg)
eng.step(frame([960], ts=0.0), None, now=0.0)
out = eng.step(None, None, now=0.1)
assert out.aim.kind == "hold"
assert out.aim.u == 0.0 and out.aim.v == 0.0 and out.aim.pose is None
def test_stale_vision_ts_reuses_tracks():
# A re-delivered camera frame (same ts) must not age tracks or re-score lips.
eng = GazeEngine()
eng.step(frame([400], ts=0.0), None, now=0.0)
eng.step(frame([1500], ts=0.0), None, now=0.1)
snap = eng.snapshot()
assert [f["id"] for f in snap["faces"]] == [1]
assert snap["faces"][0]["bbox"][0] == 300.0 # still the first frame's box
def test_events_are_per_step_not_sticky():
eng = GazeEngine()
out1 = eng.step(None, AudioState(True, 1.57, 0.0), now=0.0)
assert "sound_glance" in out1.events
assert eng.snapshot()["events"] == ["sound_glance"]
out2 = eng.step(None, AudioState(True, 1.57, 0.1), now=0.1)
assert out2.events == ()
assert eng.snapshot()["events"] == []
def test_hot_config_swap():
eng = GazeEngine()
eng.apply_config(EngineConfig.preset("owl"))
assert eng.config.speaker.min_dwell_s == 1.2
def test_apply_config_reaches_the_behavior_policy():
eng = GazeEngine()
assert eng.step(frame([960], ts=0.0), None, now=0.0).antennas is not None
eng.apply_config(EngineConfig.from_dict({"behavior": {"antennas_enabled": False}}))
assert eng.step(frame([960], ts=0.1), None, now=0.1).antennas is None
def test_apply_config_preserves_selection_unless_speaker_changed():
# SpeakerSelector has no set_config, so a *speaker-section* swap rebuilds it
# and drops both the VAD hold and the current target -- accepted v1 reset,
# pinned below. A servo/behavior-only edit (a dashboard slider) must NOT
# trigger it: that would hand the gaze to the wrong face for a full
# min_dwell_s every time an unrelated knob moves.
eng = GazeEngine()
right = math.pi # DoA hard right: scores face 2 above face 1 on position
eng.step(frame([400], ts=0.0), AudioState(True, right, 0.0), now=0.0)
assert eng.snapshot()["target_id"] == 1
# A better-scoring face 2 arrives but cannot win yet (min_dwell_s = 2.0).
for i in range(1, 10):
t = i * 0.1
eng.step(frame([400, 1500], ts=t), AudioState(True, right, t), now=t)
snap = eng.snapshot()
assert snap["target_id"] == 1
assert snap["faces"][1]["score"] > snap["faces"][0]["score"] # 2 would win a reset
# Servo-only edit: dwell, target and VAD hold all survive. Ordering is
# load-bearing: step with audio=None IMMEDIATELY after the swap — any
# intervening speech frame would re-arm the hold in a rebuilt selector
# too, making the speaking assertion pass under the bug.
eng.apply_config(EngineConfig.from_dict({"servo": {"aim_bias_v_frac": 0.1}}))
eng.step(frame([400, 1500], ts=1.0), None, now=1.0)
assert eng.snapshot()["speaking"] is True # VAD hold carried across the swap
assert eng.snapshot()["target_id"] == 1 # a rebuilt selector would adopt 2
# Speaker-section edit: the documented reset does happen.
eng.apply_config(EngineConfig.from_dict({"speaker": {"min_dwell_s": 1.5}}))
eng.step(frame([400, 1500], ts=1.2), AudioState(True, right, 1.2), now=1.2)
assert eng.snapshot()["target_id"] == 2 # rebuilt selector re-adopts the best
def test_none_vision_gap_does_not_spike_lips_on_resume():
rng = np.random.default_rng(31)
eng = GazeEngine()
base = (np.ones((24, 32)) * 120).astype(np.uint8)
# steady still face: near-zero lips
for i in range(30):
ts = i * 0.1
noisy = (base.astype(np.int16) + rng.integers(-3, 4, (24, 32))).clip(0, 255).astype(np.uint8)
eng.step(frame_with_patch(960, ts, noisy), None, now=ts)
# camera stalls 0.4 s (within tracker coast window)
for i in range(4):
eng.step(None, None, now=3.0 + i * 0.1)
# resumes with the subject having moved a lot during the gap
moved = (np.ones((24, 32)) * 200).astype(np.uint8)
eng.step(frame_with_patch(960, 3.5, moved), None, now=3.5)
snap = eng.snapshot()
assert snap["faces"][0]["lip"] < 0.05 # no cross-gap spike
def test_none_vision_keeps_last_frame_geometry():
# A coasting track is still scored, and DoA consistency normalizes the face's
# x against the frame width -- so falling back to the 1920x1080 default while
# the camera is down flips a right-of-center face to left-of-center.
eng = GazeEngine()
right = AudioState(speech=True, doa_angle=math.pi, ts=0.0) # DoA hard right
small = VisionFrame(faces=(RawFace(bbox=(500, 200, 100, 100), right_eye=(530, 230),
left_eye=(570, 230), nose=(550, 260)),),
ts=0.0, frame_size=(640, 480))
eng.step(small, right, now=0.0)
s1 = eng.snapshot()["faces"][0]["score"]
# coasting: must score in 640x480 geometry
eng.step(None, AudioState(speech=True, doa_angle=math.pi, ts=0.1), now=0.1)
s2 = eng.snapshot()["faces"][0]["score"]
assert s2 == pytest.approx(s1, abs=0.02) # no geometry flip