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
64 lines
2.1 KiB
Python
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
|