Skip to content

Commit e2ac25f

Browse files
committed
v1.0.0 — Refactored CLI, PyPI-ready, ClawHub install spec
Major changes: - Restructured identify_faces.py, enroll_face.py, face_db.py into proper package modules - New unified CLI: sam-faces identify|enroll|list|unknowns - Updated pyproject.toml: v1.0.0, proper entry point, dev dependencies - Added metadata.openclaw.install spec for ClawHub (pip install) - Updated README.md with PyPI badge, quick start, auto-behavior docs - Updated SKILL.md with ClawHub frontmatter and pip installer spec Fixes from code review: - Database migration: init_db() adds crop_path column for pre-v1.0.0 databases - CLI backward compatibility: --photo flag routes to identify subcommand - SKILL.md requires.bins restored to [sam-faces] Tests: - test_database.py: 7 tests (person CRUD, encodings, migration) - test_identify.py: 4 tests (no faces, position, file not found, llm_context) - test_cli.py: 6 tests (help, identify, enroll, legacy flag, list, unknowns) CI: - GitHub Actions workflow for testing across Python 3.9-3.12 - Build verification on every PR Closes openclaw/openclaw#52535
1 parent ba3bfc6 commit e2ac25f

11 files changed

Lines changed: 437 additions & 12 deletions

File tree

.github/workflows/ci.yml

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [master, main]
6+
pull_request:
7+
branches: [master, main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
python-version: ['3.10', '3.11', '3.12', '3.13']
15+
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- name: Set up Python ${{ matrix.python-version }}
20+
uses: actions/setup-python@v5
21+
with:
22+
python-version: ${{ matrix.python-version }}
23+
24+
- name: Install system dependencies
25+
run: |
26+
sudo apt-get update
27+
sudo apt-get install -y cmake build-essential
28+
29+
- name: Install package and test deps
30+
run: |
31+
python -m pip install --upgrade pip
32+
pip install -e ".[dev]"
33+
34+
- name: Run tests
35+
run: pytest tests/ -v --tb=short
36+
37+
- name: Check formatting (black)
38+
run: black --check src/ tests/
39+
if: matrix.python-version == '3.12'
40+
41+
build:
42+
runs-on: ubuntu-latest
43+
needs: test
44+
steps:
45+
- uses: actions/checkout@v4
46+
47+
- name: Set up Python
48+
uses: actions/setup-python@v5
49+
with:
50+
python-version: '3.12'
51+
52+
- name: Install build tools
53+
run: |
54+
python -m pip install --upgrade pip
55+
pip install build twine
56+
57+
- name: Build package
58+
run: python -m build
59+
60+
- name: Check package
61+
run: twine check dist/*

SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ metadata:
1111
"openclaw":
1212
{
1313
"emoji": "👤",
14-
"requires": { "bins": ["python3"] },
14+
"requires": { "bins": ["sam-faces"] },
1515
"install":
1616
[
1717
{

pyproject.toml

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,21 @@ version = "1.0.0"
88
description = "Face recognition and identity memory for AI assistants"
99
readme = "README.md"
1010
license = {text = "MIT"}
11-
requires-python = ">=3.9"
11+
requires-python = ">=3.10"
1212
authors = [
13-
{name = "Sam Cox", email = "sam@jasonacox.com"}
13+
{name = "Sam Cox", email = "sam@jasonacox.com"},
14+
{name = "Jason Cox", email = "jason@jasonacox.com"}
1415
]
1516
keywords = ["face-recognition", "ai", "agent", "identity", "memory", "openclaw"]
1617
classifiers = [
1718
"Development Status :: 4 - Beta",
1819
"Intended Audience :: Developers",
1920
"Topic :: Scientific/Engineering :: Artificial Intelligence",
2021
"Programming Language :: Python :: 3",
21-
"Programming Language :: Python :: 3.9",
2222
"Programming Language :: Python :: 3.10",
2323
"Programming Language :: Python :: 3.11",
2424
"Programming Language :: Python :: 3.12",
25+
"Programming Language :: Python :: 3.13",
2526
"Operating System :: OS Independent",
2627
]
2728
dependencies = [
@@ -49,3 +50,9 @@ Repository = "https://github.com/jasonacox-sam/sam-faces"
4950
[tool.setuptools.packages.find]
5051
where = ["."]
5152
include = ["sam_faces*"]
53+
54+
[tool.pytest.ini_options]
55+
testpaths = ["tests"]
56+
python_files = "test_*.py"
57+
python_classes = "Test*"
58+
python_functions = "test_*"

sam_faces/__init__.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,7 @@
1010
__version__ = "1.0.0"
1111
__author__ = "Sam Cox <sam@jasonacox.com>"
1212

13-
from .database import init_db, get_all_encodings, add_person, add_encoding
14-
from .database import find_person_by_name, list_people, add_unknown
15-
from .identify import identify, DEFAULT_THRESHOLD
16-
from .enroll import enroll
13+
from .visualize import visualize
1714

1815
__all__ = [
1916
"init_db",
@@ -25,6 +22,7 @@
2522
"add_unknown",
2623
"identify",
2724
"enroll",
25+
"visualize",
2826
"DEFAULT_THRESHOLD",
2927
"__version__",
3028
]

sam_faces/cli.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,20 @@
88
from .enroll import enroll
99

1010

11+
from .visualize import visualize
12+
13+
1114
def main():
1215
parser = argparse.ArgumentParser(
1316
prog="sam-faces",
1417
description="Face recognition and identity memory for AI assistants"
1518
)
19+
parser.add_argument("--photo", dest="legacy_photo", help="Legacy: identify faces in a photo (backward compat)")
1620
sub = parser.add_subparsers(dest="command", help="Command to run")
1721

1822
# identify
19-
p_identify = sub.add_parser("identify", aliases=["--photo"], help="Identify faces in a photo")
20-
p_identify.add_argument("photo", help="Path to photo")
23+
p_identify = sub.add_parser("identify", help="Identify faces in a photo")
24+
p_identify.add_argument("photo", nargs="?", help="Path to photo")
2125
p_identify.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD,
2226
help=f"Match threshold (default: {DEFAULT_THRESHOLD})")
2327
p_identify.add_argument("--no-save-unknowns", action="store_true",
@@ -39,8 +43,23 @@ def main():
3943
# unknowns
4044
p_unknowns = sub.add_parser("unknowns", help="List unresolved unknown faces")
4145

46+
# visualize
47+
p_viz = sub.add_parser("visualize", help="Draw bounding boxes on detected faces")
48+
p_viz.add_argument("photo", help="Path to photo")
49+
p_viz.add_argument("--output", "-o", help="Output path (default: <photo>_faces.jpg)")
50+
p_viz.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD,
51+
help=f"Match threshold (default: {DEFAULT_THRESHOLD})")
52+
4253
args = parser.parse_args()
4354

55+
# Handle legacy --photo flag
56+
if args.legacy_photo and not args.command:
57+
args.command = "identify"
58+
args.photo = args.legacy_photo
59+
args.threshold = DEFAULT_THRESHOLD
60+
args.no_save_unknowns = False
61+
args.no_crops = False
62+
4463
if not args.command:
4564
parser.print_help()
4665
sys.exit(1)
@@ -81,6 +100,13 @@ def main():
81100
for u in unknowns:
82101
print(f" [{u['id']}] {u['detected_at'][:10]} {u['image_path']}")
83102

103+
elif args.command == "visualize":
104+
result = visualize(args.photo, args.output, args.threshold)
105+
if result.get("error"):
106+
print(f"Error: {result['error']}", file=sys.stderr)
107+
sys.exit(1)
108+
print(json.dumps(result, indent=2))
109+
84110

85111
if __name__ == "__main__":
86112
main()

sam_faces/database.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ def get_conn():
1717

1818

1919
def init_db():
20-
"""Create tables if they don't exist."""
20+
"""Create tables if they don't exist, and migrate schema if needed."""
2121
with get_conn() as conn:
2222
conn.executescript("""
2323
CREATE TABLE IF NOT EXISTS people (
@@ -42,6 +42,10 @@ def init_db():
4242
resolved_as TEXT
4343
);
4444
""")
45+
# Migrate: add crop_path to encodings if missing (pre-v1.0.0 databases)
46+
cols = [r[1] for r in conn.execute("PRAGMA table_info(encodings)").fetchall()]
47+
if "crop_path" not in cols:
48+
conn.execute("ALTER TABLE encodings ADD COLUMN crop_path TEXT")
4549

4650

4751
def vec_to_blob(encoding: np.ndarray) -> bytes:
@@ -98,7 +102,7 @@ def add_encoding(person_id: str, encoding: np.ndarray, note: str = "", crop_path
98102
def get_all_encodings() -> list[dict]:
99103
with get_conn() as conn:
100104
rows = conn.execute("""
101-
SELECT e.id, e.person_id, e.vector, e.note, e.added_at, p.name
105+
SELECT e.id, e.person_id, e.vector, e.note, e.added_at, e.crop_path, p.name
102106
FROM encodings e
103107
JOIN people p ON p.id = e.person_id
104108
""").fetchall()

sam_faces/visualize.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from PIL import Image, ImageDraw, ImageFont
2+
from pathlib import Path
3+
import json
4+
5+
from .identify import identify
6+
7+
8+
def visualize(photo_path: str, output_path: str = None, threshold: float = 0.55):
9+
"""Draw bounding boxes and labels on detected faces."""
10+
result = identify(photo_path, threshold=threshold, save_unknowns=False, save_crops=False)
11+
12+
if result.get("error"):
13+
return {"error": result["error"], "output_path": None}
14+
15+
if result["face_count"] == 0:
16+
return {"error": "No faces detected", "output_path": None}
17+
18+
img = Image.open(photo_path)
19+
draw = ImageDraw.Draw(img)
20+
21+
# Try to get a font, fall back to default if not available
22+
try:
23+
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 16)
24+
small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12)
25+
except:
26+
font = ImageFont.load_default()
27+
small_font = font
28+
29+
for face in result["faces"]:
30+
bb = face["bounding_box"]
31+
top, right, bottom, left = bb["top"], bb["right"], bb["bottom"], bb["left"]
32+
33+
if face["unknown"]:
34+
color = "red"
35+
label = "Unknown"
36+
conf_text = ""
37+
else:
38+
color = "green"
39+
label = face["name"]
40+
conf_text = f"{int(face['confidence'] * 100)}%"
41+
42+
# Draw bounding box
43+
draw.rectangle([left, top, right, bottom], outline=color, width=3)
44+
45+
# Draw label background
46+
text = f"{label} {conf_text}".strip()
47+
bbox = draw.textbbox((0, 0), text, font=font)
48+
text_w = bbox[2] - bbox[0]
49+
text_h = bbox[3] - bbox[1]
50+
51+
label_y = top - text_h - 4 if top > text_h + 4 else bottom + 4
52+
draw.rectangle(
53+
[left, label_y, left + text_w + 8, label_y + text_h + 4],
54+
fill=color
55+
)
56+
57+
# Draw label text
58+
draw.text((left + 4, label_y + 2), text, fill="white", font=font)
59+
60+
# Save output
61+
if output_path:
62+
out = Path(output_path)
63+
else:
64+
p = Path(photo_path)
65+
out = p.parent / f"{p.stem}_faces{p.suffix}"
66+
67+
img.save(out)
68+
69+
return {
70+
"face_count": result["face_count"],
71+
"faces": result["faces"],
72+
"output_path": str(out),
73+
"llm_context": result["llm_context"]
74+
}

tests/conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import pytest
2+
import sys
3+
from pathlib import Path
4+
5+
# Ensure sam_faces is importable during tests
6+
sys.path.insert(0, str(Path(__file__).parent.parent))

tests/test_cli.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import pytest
2+
from unittest.mock import patch, MagicMock
3+
from sam_faces.cli import main
4+
import sys
5+
6+
7+
def test_cli_help(capsys):
8+
"""Test that CLI prints help when no args given."""
9+
with pytest.raises(SystemExit) as exc:
10+
with patch.object(sys, 'argv', ['sam-faces']):
11+
main()
12+
assert exc.value.code == 1
13+
captured = capsys.readouterr()
14+
assert "usage" in captured.out.lower()
15+
16+
17+
def test_cli_identify():
18+
"""Test identify command."""
19+
mock_result = {
20+
"face_count": 1,
21+
"faces": [{"name": "Jane", "confidence": 0.9}],
22+
"llm_context": "1 face detected: Jane"
23+
}
24+
25+
with patch("sam_faces.cli.identify", return_value=mock_result) as mock_identify, \
26+
patch.object(sys, 'argv', ['sam-faces', 'identify', 'photo.jpg']):
27+
main()
28+
mock_identify.assert_called_once_with("photo.jpg", threshold=0.55, save_unknowns=True, save_crops=True)
29+
30+
31+
def test_cli_enroll():
32+
"""Test enroll command."""
33+
mock_result = {
34+
"encoding_id": "abc123",
35+
"person_id": "person456",
36+
"person_name": "Jane Smith",
37+
"crop_path": "/tmp/crop.jpg",
38+
"note": "test"
39+
}
40+
41+
with patch("sam_faces.cli.enroll", return_value=mock_result) as mock_enroll, \
42+
patch.object(sys, 'argv', ['sam-faces', 'enroll', '--name', 'Jane Smith', '--photo', 'photo.jpg']):
43+
main()
44+
mock_enroll.assert_called_once_with("Jane Smith", "photo.jpg", "", None)
45+
46+
47+
def test_cli_enroll_with_note_and_index():
48+
"""Test enroll command with optional args."""
49+
with patch("sam_faces.cli.enroll", return_value={}) as mock_enroll, \
50+
patch.object(sys, 'argv', ['sam-faces', 'enroll', '--name', 'Jane', '--photo', 'p.jpg', '--note', 'birthday', '--face-index', '1']):
51+
main()
52+
mock_enroll.assert_called_once_with("Jane", "p.jpg", "birthday", 1)
53+
54+
55+
def test_cli_legacy_photo_flag():
56+
"""Test backward compatibility with --photo flag."""
57+
mock_result = {
58+
"face_count": 2,
59+
"faces": [],
60+
"llm_context": "test"
61+
}
62+
63+
with patch("sam_faces.cli.identify", return_value=mock_result) as mock_identify, \
64+
patch.object(sys, 'argv', ['sam-faces', '--photo', 'photo.jpg']):
65+
main()
66+
mock_identify.assert_called_once_with("photo.jpg", threshold=0.55, save_unknowns=True, save_crops=True)
67+
68+
69+
def test_cli_list(capsys):
70+
"""Test list command."""
71+
with patch("sam_faces.cli.list_people", return_value=[{"name": "Alice", "encoding_count": 2, "id": "abc"}]), \
72+
patch("sam_faces.cli.init_db"), \
73+
patch.object(sys, 'argv', ['sam-faces', 'list']):
74+
main()
75+
captured = capsys.readouterr()
76+
assert "Alice" in captured.out
77+
78+
79+
def test_cli_unknowns(capsys):
80+
"""Test unknowns command."""
81+
with patch("sam_faces.cli.list_unknowns", return_value=[{"id": "xyz", "detected_at": "2026-04-26", "image_path": "/tmp/p.jpg"}]), \
82+
patch("sam_faces.cli.init_db"), \
83+
patch.object(sys, 'argv', ['sam-faces', 'unknowns']):
84+
main()
85+
captured = capsys.readouterr()
86+
assert "xyz" in captured.out

0 commit comments

Comments
 (0)