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
This commit is contained in:
2026-10-05 08:38:32 -07:00
co-authored by Claude Sonnet 5.5
parent ce7063f647
commit 720a7cfc34
14 changed files with 390 additions and 468 deletions
@@ -4,8 +4,9 @@ listening. Audio itself never goes through ROS; this reads PipeWire with `parec`
Publishes: /audio/levels (std_msgs/Float32MultiArray): [mic_dbfs, speaker_dbfs]
/audio/activity (std_msgs/String): speaking | listening | quiet (on change, and 2 Hz)
/audio/scope/mic, /audio/scope/speaker (std_msgs/Float32MultiArray): a trigger-locked
window of `scope_samples` samples at 16 kHz in [-1, 1], while there is sound: the face
draws the mouth from it, so the trace is the real signal
window of `scope_samples` samples at 16 kHz in [-1, 1]. The mic's is published only while
listening and the speaker's only while speaking (idle, nothing goes out): the face draws the
mouth from it, so the trace is the real signal
"""
import rclpy
from rclpy.node import Node
+2 -1
View File
@@ -2,7 +2,8 @@
Serves MCP (streamable HTTP, bearer token) to Hermes on the USB link and talks to the graph locally.
Subscribes: image_topic (sensor_msgs/CompressedImage, default /camera/image_raw/compressed)
/vision/faces (vision_msgs/Detection2DArray) and /camera/camera_info, for faces() and look()
/vision/faces (vision_msgs/Detection2DArray) and /camera/camera_info, for faces() and look();
/health/* from hermes_thermal, for the health block of robot://status
Publishes: /hermes/state (std_msgs/String):
`idle` once a second while an authenticated MCP client is connected: the agent heartbeat
the face uses to wake and, when it stops, to fall asleep ("no agent connected");
+7 -5
View File
@@ -4,10 +4,12 @@
COLS x ROWS are the board's *inner corners* (a board of 10 x 7 squares has 9 x 6). With the robot
running, hold the board in front of the camera at different distances, tilts and positions, keeping the
whole board in view; it collects views that differ enough from those already taken, solves with OpenCV
and writes ~/.ros/camera_info/<camera name>_<w>x<h>.yaml (restart the robot to load it). A good result
has a reprojection error under about 0.5 px. Calibration is only valid for the focus it was taken at,
so set the lens focus first, and for the image size in use.
whole board in view, and reach the edges and corners of the image (it says which regions are still
uncovered); it collects views that differ enough from those already taken, solves with OpenCV (k3 held at
zero), drops blurred outlier views, and writes ~/.ros/camera_info/<camera name>_<w>x<h>.yaml (restart the
robot to load it) plus the collected views as a .npz, so `--from-views FILE` can re-solve them without a
camera. A good result has a reprojection error under about 0.5 px. Calibration is only valid for the focus
it was taken at, so set the lens focus first, and for the image size in use.
"""
from __future__ import annotations
@@ -126,7 +128,7 @@ def main() -> int:
p.add_argument('--cols', type=int, required=True, help='inner corners across')
p.add_argument('--rows', type=int, required=True, help='inner corners down')
p.add_argument('--square-mm', type=float, required=True, help='side of one square, measured')
p.add_argument('--views', type=int, default=20)
p.add_argument('--views', type=int, default=24)
p.add_argument('--timeout', type=float, default=240)
p.add_argument('--topic', default='/camera/image_raw/compressed')
p.add_argument('--name', default=DEFAULT_NAME)
@@ -107,27 +107,3 @@ def iou(a: Face, b: Face) -> float:
return 0.0
inter = iw * ih
return inter / (a.w * a.h + b.w * b.h - inter)
class IdentityCache:
"""Recognition is the expensive step, and a face that has barely moved is the same person: reuse
a tracked face's name for period_s before re-identifying it. Faces match by box overlap."""
def __init__(self, period_s: float = 1.0, iou_min: float = 0.4):
self.period_s, self.iou_min = period_s, iou_min
self._entries: list[tuple[Face, str, float]] = []
def update(self, faces, now: float, identify) -> list[str]:
"""Names for faces (same order). identify(face) -> name is called only for new or stale faces."""
names, nxt = [], []
for f in faces:
hit = max(((iou(f, pf), (pf, n, t)) for pf, n, t in self._entries), key=lambda x: x[0], default=None)
if hit and hit[0] >= self.iou_min and now - hit[1][2] < self.period_s:
_, name, t = hit[1]
nxt.append((f, name, t)) # keep the old stamp so it ages and gets rechecked
else:
name = identify(f)
nxt.append((f, name, now))
names.append(name)
self._entries = nxt
return names
@@ -1,40 +0,0 @@
from hermes_vision.detect import Face
from hermes_vision.recognize import IdentityCache, iou
def test_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
def test_a_tracked_face_is_identified_once_per_period():
calls = []
cache = IdentityCache(period_s=1.0)
ident = lambda f: (calls.append(f), 'kyle')[1]
assert cache.update([Face(100, 100, 80, 80)], 0.0, ident) == ['kyle']
for i in range(1, 8): # the face drifts a little, 4 times a second
assert cache.update([Face(100 + i, 100, 80, 80)], i * 0.125, ident) == ['kyle']
assert len(calls) == 1
cache.update([Face(108, 100, 80, 80)], 1.1, ident) # stale: rechecked
assert len(calls) == 2
def test_a_new_face_is_identified_immediately_and_unknown_is_cached_too():
calls = []
cache = IdentityCache()
ident = lambda f: (calls.append(f), '' if f.x > 400 else 'kyle')[1]
names = cache.update([Face(100, 100, 80, 80), Face(500, 100, 80, 80)], 0.0, ident)
assert names == ['kyle', ''] and len(calls) == 2
assert cache.update([Face(101, 100, 80, 80), Face(501, 100, 80, 80)], 0.3, ident) == ['kyle', '']
assert len(calls) == 2 # the stranger is not re-embedded every frame
def test_a_face_that_jumps_is_a_different_face():
calls = []
cache = IdentityCache()
ident = lambda f: (calls.append(f), 'kyle')[1]
cache.update([Face(100, 100, 80, 80)], 0.0, ident)
cache.update([Face(600, 300, 80, 80)], 0.1, ident)
assert len(calls) == 2
+8
View File
@@ -53,3 +53,11 @@ def test_a_stranger_is_unknown_not_named():
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