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
50 lines
1.6 KiB
Python
50 lines
1.6 KiB
Python
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')) == {}
|