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:
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
@@ -8,7 +9,8 @@ import pytest
|
||||
pytest.importorskip('mcp')
|
||||
from starlette.testclient import TestClient # noqa: E402
|
||||
|
||||
from hermes_mcp.bridge import Activity, BearerAuth, LatestFrame, build_app, build_mcp # noqa: E402
|
||||
from hermes_mcp.bridge import (Activity, BearerAuth, FacesBuffer, LatestFrame, build_app, build_mcp, # noqa: E402
|
||||
people_summary)
|
||||
|
||||
TOKEN = 'x' * 32
|
||||
|
||||
@@ -25,9 +27,11 @@ def tool_names(mcp):
|
||||
return {t.name for t in asyncio.run(mcp.list_tools())}
|
||||
|
||||
|
||||
def test_tools_are_look_only_without_a_head_and_keep_their_names_with_one():
|
||||
assert tool_names(build_mcp(LatestFrame())) == {'look'}
|
||||
assert tool_names(build_mcp(LatestFrame(), head=FakeHead())) == {'look', 'turn_head', 'head_position'}
|
||||
def test_tools_have_no_head_tools_without_a_head_and_keep_their_names_with_one():
|
||||
base = {'look', 'faces', 'list_people'}
|
||||
assert tool_names(build_mcp(LatestFrame())) == base
|
||||
assert tool_names(build_mcp(LatestFrame(), head=FakeHead())) == base | {'turn_head', 'head_position'}
|
||||
assert 'enroll' not in tool_names(build_mcp(LatestFrame(), head=FakeHead())) # enrolment is the owner's step
|
||||
|
||||
|
||||
def test_look_returns_the_next_frame_not_a_stale_one():
|
||||
@@ -37,8 +41,9 @@ def test_look_returns_the_next_frame_not_a_stale_one():
|
||||
mcp = build_mcp(frames, announce_look=lambda: announced.append(1), look_timeout_s=2.0)
|
||||
threading.Timer(0.3, lambda: frames.put(b'\xff\xd8fresh')).start()
|
||||
result = asyncio.run(mcp.call_tool('look', {}))
|
||||
image = result.content[0]
|
||||
text, image = result.content
|
||||
assert announced == [1]
|
||||
assert text.type == 'text' and json.loads(text.text)['faces']['available'] is False # no detector running here
|
||||
assert image.type == 'image' and image.mime_type == 'image/jpeg'
|
||||
assert base64.b64decode(image.data) == b'\xff\xd8fresh' # the frame that arrived after the call
|
||||
|
||||
@@ -97,3 +102,73 @@ def test_bearer_auth_passes_lifespan_through():
|
||||
|
||||
asyncio.run(BearerAuth(inner, TOKEN)({'type': 'lifespan'}, None, None))
|
||||
assert seen == ['lifespan']
|
||||
|
||||
|
||||
def face(track_id, x, name='', sim=0.0, conf=0.9, w=300, h=400):
|
||||
return {'track_id': track_id, 'bbox': {'x': x, 'y': 100, 'w': w, 'h': h}, 'confidence': conf, 'name': name, 'similarity': sim}
|
||||
|
||||
|
||||
def call(mcp, name, args=None):
|
||||
return asyncio.run(mcp.call_tool(name, args or {}))
|
||||
|
||||
|
||||
def test_faces_reports_identity_null_for_a_stranger_and_never_makes_up_a_name():
|
||||
buf = FacesBuffer()
|
||||
buf.put([face(1, 100, 'Kyle', 0.82), face(2, 600)])
|
||||
out = call(build_mcp(LatestFrame(), faces_buf=buf), 'faces').structured_content
|
||||
assert out['available'] and out['count'] == 2 and out['image_size'] == {'w': 1296, 'h': 972}
|
||||
kyle, stranger = out['faces']
|
||||
assert kyle['identity'] == {'name': 'Kyle', 'similarity': 0.82}
|
||||
assert stranger['identity'] is None # unknown, not guessed
|
||||
assert set(kyle) == {'track_id', 'bbox', 'bbox_norm', 'confidence', 'identity', 'seen_for_s'}
|
||||
assert 'embedding' not in json.dumps(out)
|
||||
|
||||
|
||||
def test_track_ids_persist_and_seen_for_grows():
|
||||
buf = FacesBuffer()
|
||||
buf.put([face(7, 100)], t=time.monotonic() - 3.0)
|
||||
buf.put([face(7, 105)])
|
||||
(f,) = buf.summary()['faces']
|
||||
assert f['track_id'] == 7 and 2.5 < f['seen_for_s'] < 3.6
|
||||
|
||||
|
||||
def test_faces_says_when_detection_is_not_running_instead_of_reporting_an_empty_room():
|
||||
out = call(build_mcp(LatestFrame(), faces_buf=FacesBuffer()), 'faces').structured_content
|
||||
assert out['available'] is False and 'reason' in out
|
||||
buf = FacesBuffer()
|
||||
buf.put([face(1, 100)], t=time.monotonic() - 30) # stale data is not "now"
|
||||
assert buf.summary(max_age_s=1.5)['available'] is False
|
||||
|
||||
|
||||
def test_an_empty_room_is_available_with_zero_faces():
|
||||
buf = FacesBuffer()
|
||||
buf.put([])
|
||||
assert buf.summary()['available'] and buf.summary()['count'] == 0
|
||||
|
||||
|
||||
def test_look_can_skip_the_photo():
|
||||
frames, buf = LatestFrame(), FacesBuffer()
|
||||
buf.put([face(1, 100, 'Kyle', 0.8)])
|
||||
frames.put(b'\xff\xd8x')
|
||||
result = call(build_mcp(frames, faces_buf=buf), 'look', {'send_image': 'never'})
|
||||
assert len(result.content) == 1 and result.content[0].type == 'text'
|
||||
assert json.loads(result.content[0].text)['faces']['faces'][0]['identity']['name'] == 'Kyle'
|
||||
|
||||
|
||||
def test_list_people_gives_names_and_counts_never_face_data(tmp_path):
|
||||
path = tmp_path / 'faces.json'
|
||||
path.write_text(json.dumps({'Kyle': [[0.1] * 128] * 10, 'Alex': [[0.2] * 128] * 3}))
|
||||
out = call(build_mcp(LatestFrame(), people_path=str(path)), 'list_people').structured_content
|
||||
assert out == {'count': 2, 'people': [{'name': 'Alex', 'samples': 3}, {'name': 'Kyle', 'samples': 10}]}
|
||||
assert people_summary(str(tmp_path / 'missing.json')) == {'count': 0, 'people': []}
|
||||
|
||||
|
||||
def test_the_server_can_be_discovered_resources_and_prompts_are_not_empty():
|
||||
mcp = build_mcp(LatestFrame(), hardware={'speakers': False})
|
||||
assert [str(r.uri) for r in asyncio.run(mcp.list_resources())] == ['robot://status']
|
||||
assert {p.name for p in asyncio.run(mcp.list_prompts())} == {'who_is_here', 'look_around'}
|
||||
status = json.loads(asyncio.run(mcp.read_resource('robot://status'))[0].content)
|
||||
assert status['hardware'] == {'camera': True, 'head': False, 'speakers': False}
|
||||
assert status['detects'] == ['faces'] and 'people' in status['not_detected']
|
||||
assert 'embeddings never leave it' in status['recognition']['registry']
|
||||
assert 'turn_head' not in status['tools']
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import pytest
|
||||
|
||||
rclpy = pytest.importorskip('rclpy')
|
||||
|
||||
|
||||
def test_the_node_constructs_without_a_token_and_does_not_serve(monkeypatch):
|
||||
"""Declaring a parameter twice raises at construction, and nothing else exercises the node."""
|
||||
monkeypatch.delenv('ROBOT_MCP_TOKEN', raising=False)
|
||||
from hermes_mcp.mcp_node import McpNode
|
||||
rclpy.init()
|
||||
try:
|
||||
node = McpNode()
|
||||
assert node.activity is None # no token: not serving, no heartbeat
|
||||
node.destroy_node()
|
||||
finally:
|
||||
rclpy.shutdown()
|
||||
Reference in New Issue
Block a user