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:
@@ -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,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");
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user