Add opt-in face recognition to hermes_vision

SFace embeddings on top of YuNet's landmarks. Nothing is stored until someone is
enrolled with `python -m hermes_vision.people enroll NAME`; only 128-number
embeddings are kept, never images, in a 0600 JSON file under a 0700 directory,
written atomically. `list` and `forget` manage it. The detector node puts a
recognised name in Detection2D.id (empty if unknown) and picks up database
changes without a restart.

Checked with a throwaway database on the burst frames: eight of nine unenrolled
frames were recognised, down to side profiles (similarity 0.52-0.66), the miss
being a head tipped back at 0.34 against the 0.363 threshold, and an unrelated
photo scored 0.16 and stayed unknown.

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-04 23:13:49 -07:00
co-authored by Claude Sonnet 5.5
parent f8525c5526
commit cf711060d0
6 changed files with 293 additions and 2 deletions
+49
View File
@@ -0,0 +1,49 @@
import json
import os
import stat
import numpy as np
import pytest
from hermes_vision.recognize import best_match, cosine, load_db, save_db
def vec(seed, noise=0.0, base=None):
r = np.random.default_rng(seed)
v = r.normal(size=128).astype(np.float32) if base is None else base
return v + noise * r.normal(size=128).astype(np.float32)
def test_cosine_basics():
a = vec(1)
assert cosine(a, a) == pytest.approx(1.0)
assert cosine(a, -a) == pytest.approx(-1.0)
def test_best_match_picks_the_closest_person_and_rejects_strangers():
alice, bob = vec(1), vec(2)
db = {'alice': [alice, vec(1, 0.3, alice)], 'bob': [bob]}
assert best_match(vec(9, 0.2, alice), db)[0] == 'alice'
assert best_match(vec(9, 0.2, bob), db)[0] == 'bob'
name, sim = best_match(vec(77), db) # an unrelated face
assert name == '' and sim < 0.363
def test_empty_db_matches_nobody():
assert best_match(vec(1), {}) == ('', -1.0)
def test_db_roundtrip_is_private_and_atomic(tmp_path):
path = str(tmp_path / 'sub' / 'faces.json')
save_db({'alice': [vec(1), vec(2)]}, path)
assert stat.S_IMODE(os.stat(path).st_mode) == 0o600
assert stat.S_IMODE(os.stat(os.path.dirname(path)).st_mode) == 0o700
back = load_db(path)
assert list(back) == ['alice'] and len(back['alice']) == 2
assert np.allclose(back['alice'][0], vec(1))
assert [f for f in os.listdir(os.path.dirname(path)) if f.startswith('.faces-')] == [] # no temp litter
assert json.load(open(path)).keys() == {'alice'}
def test_load_missing_db_is_empty(tmp_path):
assert load_db(str(tmp_path / 'nope.json')) == {}