Files
kyleandClaude Sonnet 5.5 720a7cfc34 Bring every doc up to date, and give the repo a Wi-Fi template that exists
CONTEXT.md had grown by appending and contradicted itself (all four nodes vs six, a retracted CPU
figure, "no calibration", "nobody enrolled", "audio untested as a service", the Wi-Fi-off boot as
not done, a class the tracker replaced). Rewritten as one current-state document organised by
subsystem, with fresh measurements, the owner's decisions, the gotchas, the neck's arrival-day plan
and what is genuinely still open.

README: status, speakers not fitted, new-session note for Hermes, the calibration section (shipped
calibration, coverage hints, outlier dropping, --from-views), running the tests, the docs row.
HANDOFF.md gets a status banner listing where the original brief has drifted. CLAUDE.md starts at
CONTEXT.md. The echo-cancel config no longer talks about a Pi Zero, and the enclosure guide no longer
tells you to centre a servo with a tool that does not exist yet.

pi/network-config.example is a tracked, credentials-free first-boot template: the README pointed at
pi/50-cloud-init.yaml, which is gitignored and so missing from a fresh clone. The dead IdentityCache
(replaced by the tracker) is removed. Docstrings corrected: audio scope publishing, the MCP health
topics, and what the calibration tool does.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFN8nRRCiDiMN7ChBpHJhs
2026-10-05 08:38:32 -07:00

64 lines
2.1 KiB
Python

from hermes_vision.detect import Face
from hermes_vision.tracking import FaceTracker
def f(x, y=100, w=80, h=80):
return Face(x, y, w, h, 0.9)
def test_a_face_keeps_its_track_id_and_age_grows():
tr = FaceTracker()
ids = []
for i in range(8):
(t,) = tr.update([f(100 + 3 * i)], i * 0.25)
ids.append(t.id)
assert ids == [1] * 8 and abs(t.age(1.75) - 1.75) < 1e-9
def test_two_people_get_two_ids_and_keep_them_when_they_swap_order():
tr = FaceTracker()
a, b = tr.update([f(100), f(500)], 0.0)
b2, a2 = tr.update([f(502), f(102)], 0.25) # reported in the other order
assert (a.id, b.id) == (a2.id, b2.id) and a.id != b.id
def test_a_track_survives_a_short_gap_but_not_a_long_one():
tr = FaceTracker(max_age_s=1.5)
(t,) = tr.update([f(100)], 0.0)
tr.update([], 1.0) # nobody for a second
(t2,) = tr.update([f(100)], 1.2)
assert t2.id == t.id
tr.update([], 1.3)
tr.update([], 3.5) # gone for over max_age
(t3,) = tr.update([f(100)], 3.6)
assert t3.id != t.id
def test_identity_is_resolved_for_new_tracks_and_rechecked_once_a_second():
calls = []
tr = FaceTracker(recheck_s=1.0)
ident = lambda face: (calls.append(face), ('Kyle', 0.8))[1]
for i in range(8): # 4 Hz for two seconds
(t,) = tr.update([f(100 + i)], i * 0.25, ident)
assert t.name == 'Kyle' and t.similarity == 0.8
assert len(calls) == 2 # at t=0 and again at t=1.0
def test_a_stranger_is_unknown_not_named():
tr = FaceTracker()
(t,) = tr.update([f(100)], 0.0, lambda face: ('', 0.12))
assert t.name == '' and t.similarity == 0.12
def test_without_an_identifier_nothing_is_named():
(t,) = FaceTracker().update([f(100)], 0.0)
assert t.name == '' and t.id == 1
def test_iou():
from hermes_vision.recognize import iou
a = Face(0, 0, 100, 100)
assert iou(a, a) == 1.0
assert iou(a, Face(200, 200, 50, 50)) == 0.0
assert 0.3 < iou(a, Face(50, 0, 100, 100)) < 0.4