Give Hermes recognitions, not just photos: faces(), tracking ids, discovery

Hermes asked for structured detections instead of a bare JPEG. faces() returns the
faces in view with no photo: a persistent track id, bbox, detection confidence, how
long the person has been in view, and identity {name, similarity} or null, never a
made-up name. look() returns that same JSON as text plus the photo (send_image can
skip it); list_people() gives names and counts; robot://status and two prompts make
the server discoverable, where list_resources and list_prompts were empty. The
detector now tracks faces across frames (FaceTracker) and publishes the track id and
the identity as a second hypothesis.

Deliberately not built: embeddings in responses, age/gender attributes, auto-updating
face data, and a remote enroll tool (a prompt could make the robot call anyone "Kyle").

Checked end to end: a real Hermes turn on the local ornith model called faces() (0.03 s,
no image) and answered that Kyle was there at 0.78 similarity. Also: the camera is
calibrated (18 of 24 views, 0.296 px, outlier rejection added) and the calibration is
kept in pi/calibration and restored by the installer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFN8nRRCiDiMN7ChBpHJhs
This commit is contained in:
2026-10-05 01:21:32 -07:00
co-authored by Claude Sonnet 5.5
parent 497f026dc9
commit 3b65697f1e
16 changed files with 599 additions and 44 deletions
+42 -1
View File
@@ -2,7 +2,8 @@ import cv2
import numpy as np
import yaml
from hermes_vision.calibrate import board_points, camera_info_yaml, novel_enough, solve, view_signature
from hermes_vision.calibrate import (board_points, camera_info_yaml, coverage, novel_enough, solve, solve_robust,
view_signature)
SIZE = (1296, 972)
K_TRUE = np.array([[1100.0, 0, 650.0], [0, 1090.0, 480.0], [0, 0, 1]])
@@ -72,3 +73,43 @@ def test_find_board_detects_a_real_rendered_checkerboard_and_not_a_blank_frame()
corners = find_board(bgr, 9, 6)
assert corners is not None and corners.reshape(-1, 2).shape == (54, 2)
assert find_board(np.full_like(bgr, 128), 9, 6) is None
def test_k3_is_held_at_zero_by_default_but_can_be_fitted():
obj, views = synthetic_views()
_, _, d = solve([obj] * len(views), views, SIZE)
assert d[4] == 0.0
_, _, d_full = solve([obj] * len(views), views, SIZE, fix_k3=False)
assert d_full[4] != 0.0
def test_coverage_names_the_regions_the_board_never_reached():
w, h = SIZE
centre_only = [np.random.default_rng(1).uniform([w * 0.4, h * 0.4], [w * 0.6, h * 0.6], (54, 2)).astype(np.float32)]
frac, empty = coverage(centre_only, SIZE)
assert frac < 0.2 and 'top-left' in empty and 'bottom-right' in empty and 'centre' not in empty
rng = np.random.default_rng(2)
everywhere = [rng.uniform([0, 0], [w, h], (200, 2)).astype(np.float32)]
assert coverage(everywhere, SIZE) == (1.0, [])
def test_blurred_outlier_views_are_dropped_and_the_result_stays_accurate():
obj, views = synthetic_views(n=40, seed=9)
rng = np.random.default_rng(5)
noisy = [v + rng.normal(0, 0.15, v.shape).astype(np.float32) for v in views] # ordinary jitter
bad = [v + rng.normal(0, 2.0, v.shape).astype(np.float32) for v in views[:4]] # four blurred views
allv = noisy + bad
rms_all, k_all, _ = solve([obj] * len(allv), allv, SIZE)
rms, k, d, keep = solve_robust([obj] * len(allv), allv, SIZE)
assert set(range(len(noisy), len(allv))) <= set(range(len(allv))) - set(keep) # the four bad ones are gone
assert len(keep) >= len(noisy) - 2 # the ordinary ones are kept
assert rms < rms_all and rms < 0.3
assert abs(k[0, 0] - 1100) / 1100 < 0.02
def test_it_never_drops_below_the_minimum_number_of_views():
obj, views = synthetic_views(n=14, seed=4)
rng = np.random.default_rng(1)
allv = [v + rng.normal(0, 1.0, v.shape).astype(np.float32) * (i % 2) for i, v in enumerate(views)]
_, _, _, keep = solve_robust([obj] * len(allv), allv, SIZE, min_views=len(allv) - 1)
assert len(keep) >= len(allv) - 1
+55
View File
@@ -0,0 +1,55 @@
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