Skip to content

Commit d51e799

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 d51e799

14 files changed

Lines changed: 589 additions & 22 deletions

File tree

.github/workflows/ci.yml

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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 setuptools (provides pkg_resources for face_recognition_models)
30+
run: |
31+
python -m pip install --upgrade pip
32+
python -m pip install --force-reinstall 'setuptools>=68.0'
33+
34+
- name: Install package and test deps
35+
run: |
36+
pip install -e ".[dev]"
37+
38+
- name: Run tests
39+
run: pytest tests/ -v --tb=short
40+
41+
- name: Check formatting (black)
42+
run: black --check sam_faces/ tests/
43+
if: matrix.python-version == '3.13'
44+
45+
build:
46+
runs-on: ubuntu-latest
47+
needs: test
48+
steps:
49+
- uses: actions/checkout@v4
50+
51+
- name: Set up Python
52+
uses: actions/setup-python@v5
53+
with:
54+
python-version: '3.12'
55+
56+
- name: Install build tools
57+
run: |
58+
python -m pip install --upgrade pip
59+
pip install build twine
60+
61+
- name: Build package
62+
run: python -m build
63+
64+
- name: Check package
65+
run: twine check dist/*

README.md

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
**Face recognition and identity memory for AI assistants.**
44

55
[![PyPI](https://img.shields.io/pypi/v/sam-faces)](https://pypi.org/project/sam-faces/)
6-
[![Python](https://img.shields.io/badge/python-3.9%2B-blue)](https://www.python.org/)
6+
[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/)
77
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
88

99
Give your AI assistant a real face memory. Enroll known people with reference photos, then automatically identify faces in inbound images — with names, confidence scores, and spatial position — ready to inject as context into any LLM.
@@ -18,7 +18,7 @@ pip install sam-faces
1818

1919
The `sam-faces` command is added to your PATH automatically.
2020

21-
**Requirements:** Python 3.9+ with build tools (for dlib compilation):
21+
**Requirements:** Python 3.10+ with build tools (for dlib compilation):
2222
- Ubuntu/Debian: `sudo apt install cmake build-essential`
2323
- macOS: `xcode-select --install`
2424

@@ -35,10 +35,10 @@ Output:
3535
{
3636
"face_count": 2,
3737
"faces": [
38-
{"name": "Jane Smith", "confidence": 0.646, "position_desc": "middle"},
39-
{"name": "John Smith", "confidence": 0.571, "position_desc": "middle-left"}
38+
{"name": "Jane Smith", "confidence": 0.646, "center": [220, 330], "position_desc": "middle-left"},
39+
{"name": "John Smith", "confidence": 0.571, "center": [920, 310], "position_desc": "middle-right"}
4040
],
41-
"llm_context": "2 faces detected: Jane Smith (middle, 64%); John Smith (middle-left, 57%)."
41+
"llm_context": "2 faces detected: Jane Smith (at 22% left, 33% down, 64% confidence); John Smith (at 92% left, 31% down, 57% confidence)."
4242
}
4343
```
4444

@@ -54,6 +54,33 @@ sam-faces enroll --name "Jane Smith" --photo photo.jpg
5454
sam-faces list
5555
```
5656

57+
## Python API
58+
59+
You can also use sam-faces as a library inside your Python scripts or agents:
60+
61+
```python
62+
from sam_faces import identify, enroll, list_people
63+
64+
# Identify faces in a photo
65+
result = identify("photo.jpg")
66+
print(result["llm_context"])
67+
# → "2 faces detected: Jane Smith (at 22% left, 33% down, 64% confidence); ..."
68+
69+
# Enroll a new person
70+
enroll("Jane Smith", "photo.jpg", note="birthday party")
71+
72+
# List all enrolled people
73+
for person in list_people():
74+
print(f"{person['name']}: {person['encoding_count']} encodings")
75+
```
76+
77+
### Lazy imports
78+
79+
The package uses lazy loading for heavy vision dependencies. Importing `sam_faces`
80+
does not load dlib or face_recognition until you actually call `identify()`,
81+
`enroll()`, or `visualize()`. This keeps startup fast and avoids import failures
82+
when only doing database operations.
83+
5784
## For OpenClaw Agents
5885

5986
When installed as an OpenClaw skill, sam-faces **automatically processes every inbound image**:

SKILL.md

Lines changed: 2 additions & 2 deletions
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
{
@@ -115,6 +115,6 @@ sam-faces unknowns
115115
- Database: `{workspaceDir}/faces/people.db`
116116
- Unknown face crops: `{workspaceDir}/faces/unknown/`
117117
- Face crop thumbnails (audit trail): `{workspaceDir}/faces/crops/`
118-
- Requires Python 3.9+ and build tools (cmake, C++ compiler) for dlib.
118+
- Requires Python 3.10+ and build tools (cmake, C++ compiler) for dlib.
119119
- On Ubuntu/Debian: `sudo apt install cmake build-essential`
120120
- On macOS: `xcode-select --install`

pyproject.toml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,27 @@ 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"},
1414
]
1515
keywords = ["face-recognition", "ai", "agent", "identity", "memory", "openclaw"]
1616
classifiers = [
1717
"Development Status :: 4 - Beta",
1818
"Intended Audience :: Developers",
1919
"Topic :: Scientific/Engineering :: Artificial Intelligence",
2020
"Programming Language :: Python :: 3",
21-
"Programming Language :: Python :: 3.9",
2221
"Programming Language :: Python :: 3.10",
2322
"Programming Language :: Python :: 3.11",
2423
"Programming Language :: Python :: 3.12",
24+
"Programming Language :: Python :: 3.13",
2525
"Operating System :: OS Independent",
2626
]
2727
dependencies = [
2828
"face-recognition>=1.3.0",
2929
"Pillow>=9.0.0",
3030
"numpy>=1.21.0",
31+
"setuptools>=40.0.0",
3132
]
3233

3334
[project.optional-dependencies]
@@ -36,6 +37,7 @@ dev = [
3637
"black",
3738
"build",
3839
"twine",
40+
"setuptools>=40.0.0",
3941
]
4042

4143
[project.scripts]
@@ -49,3 +51,9 @@ Repository = "https://github.com/jasonacox-sam/sam-faces"
4951
[tool.setuptools.packages.find]
5052
where = ["."]
5153
include = ["sam_faces*"]
54+
55+
[tool.pytest.ini_options]
56+
testpaths = ["tests"]
57+
python_files = "test_*.py"
58+
python_classes = "Test*"
59+
python_functions = "test_*"

sam_faces/__init__.py

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,71 @@
1-
#!/usr/bin/env python3
21
"""
3-
sam_faces/__init__.py — Face recognition and identity memory for AI assistants.
2+
sam-faces — Face recognition and identity memory for AI assistants.
43
5-
Enroll known people with reference photos, then automatically identify faces
6-
in inbound images — with names, confidence scores, and an llm_context string
7-
ready to inject into any LLM prompt. 100% local, no cloud APIs.
4+
This package provides local face recognition capabilities for OpenClaw agents:
5+
- Enroll known people with reference photos
6+
- Identify faces in inbound images with confidence scores
7+
- Generate llm_context strings for enriched image descriptions
8+
- Draw bounding boxes and labels on photos for visualization
9+
10+
All operations run 100% locally via dlib/face_recognition. No cloud APIs.
11+
12+
Schema (SQLite):
13+
people(id, name, created_at)
14+
encodings(id, person_id, vector BLOB, note, added_at, crop_path)
15+
unknown_candidates(id, image_path, face_crop_path, detected_at, resolved, resolved_as)
16+
17+
Authors:
18+
- Sam Cox (https://github.com/jasonacox-sam)
19+
- Jason Cox (https://github.com/jasonacox)
820
"""
921

1022
__version__ = "1.0.0"
11-
__author__ = "Sam Cox <sam@jasonacox.com>"
23+
__author__ = "Sam Cox (https://github.com/jasonacox-sam), Jason Cox (https://github.com/jasonacox)"
24+
25+
# Lazy imports - only load heavy dependencies when needed
26+
# Importing face_recognition at module load time triggers dlib init,
27+
# which can fail if pkg_resources is missing (Python 3.13+ without setuptools)
28+
29+
30+
def _lazy(name):
31+
"""Lazy module loader - imports on first attribute access."""
32+
import importlib
33+
return importlib.import_module(f"sam_faces.{name}")
1234

35+
36+
# Database operations are lightweight (SQLite only) - safe to import eagerly
1337
from .database import init_db, get_all_encodings, add_person, add_encoding
1438
from .database import find_person_by_name, list_people, add_unknown
15-
from .identify import identify, DEFAULT_THRESHOLD
16-
from .enroll import enroll
39+
40+
# Heavy vision imports are deferred until actually used
41+
identify = None
42+
enroll = None
43+
visualize = None
44+
DEFAULT_THRESHOLD = 0.55
45+
46+
47+
def __getattr__(name):
48+
"""Lazy load heavy vision modules on first access."""
49+
global identify, enroll, visualize, DEFAULT_THRESHOLD
50+
if name == "identify":
51+
from .identify import identify as _identify, DEFAULT_THRESHOLD as _thresh
52+
identify = _identify
53+
DEFAULT_THRESHOLD = _thresh
54+
return identify
55+
if name == "enroll":
56+
from .enroll import enroll as _enroll
57+
enroll = _enroll
58+
return enroll
59+
if name == "visualize":
60+
from .visualize import visualize as _viz
61+
visualize = _viz
62+
return visualize
63+
if name == "DEFAULT_THRESHOLD":
64+
from .identify import DEFAULT_THRESHOLD as _thresh
65+
DEFAULT_THRESHOLD = _thresh
66+
return DEFAULT_THRESHOLD
67+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
68+
1769

1870
__all__ = [
1971
"init_db",
@@ -25,6 +77,7 @@
2577
"add_unknown",
2678
"identify",
2779
"enroll",
80+
"visualize",
2881
"DEFAULT_THRESHOLD",
2982
"__version__",
3083
]

sam_faces/cli.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1+
"""
2+
sam_faces/cli.py — Unified command-line interface for sam-faces.
3+
4+
Subcommands:
5+
identify — Detect and name faces in a photo
6+
enroll — Add a face to the people database
7+
list — Show all enrolled people
8+
unknowns — Review unresolved unknown faces
9+
visualize — Draw bounding boxes on detected faces
10+
11+
Backward compatibility: sam-faces --photo image.jpg routes to identify.
12+
"""
13+
114
import argparse
215
import json
316
import sys
@@ -8,16 +21,20 @@
821
from .enroll import enroll
922

1023

24+
from .visualize import visualize
25+
26+
1127
def main():
1228
parser = argparse.ArgumentParser(
1329
prog="sam-faces",
1430
description="Face recognition and identity memory for AI assistants"
1531
)
32+
parser.add_argument("--photo", dest="legacy_photo", help="Legacy: identify faces in a photo (backward compat)")
1633
sub = parser.add_subparsers(dest="command", help="Command to run")
1734

1835
# 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")
36+
p_identify = sub.add_parser("identify", help="Identify faces in a photo")
37+
p_identify.add_argument("photo", nargs="?", help="Path to photo")
2138
p_identify.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD,
2239
help=f"Match threshold (default: {DEFAULT_THRESHOLD})")
2340
p_identify.add_argument("--no-save-unknowns", action="store_true",
@@ -39,8 +56,23 @@ def main():
3956
# unknowns
4057
p_unknowns = sub.add_parser("unknowns", help="List unresolved unknown faces")
4158

59+
# visualize
60+
p_viz = sub.add_parser("visualize", help="Draw bounding boxes on detected faces")
61+
p_viz.add_argument("photo", help="Path to photo")
62+
p_viz.add_argument("--output", "-o", help="Output path (default: <photo>_faces.jpg)")
63+
p_viz.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD,
64+
help=f"Match threshold (default: {DEFAULT_THRESHOLD})")
65+
4266
args = parser.parse_args()
4367

68+
# Handle legacy --photo flag
69+
if args.legacy_photo and not args.command:
70+
args.command = "identify"
71+
args.photo = args.legacy_photo
72+
args.threshold = DEFAULT_THRESHOLD
73+
args.no_save_unknowns = False
74+
args.no_crops = False
75+
4476
if not args.command:
4577
parser.print_help()
4678
sys.exit(1)
@@ -81,6 +113,13 @@ def main():
81113
for u in unknowns:
82114
print(f" [{u['id']}] {u['detected_at'][:10]} {u['image_path']}")
83115

116+
elif args.command == "visualize":
117+
result = visualize(args.photo, args.output, args.threshold)
118+
if result.get("error"):
119+
print(f"Error: {result['error']}", file=sys.stderr)
120+
sys.exit(1)
121+
print(json.dumps(result, indent=2))
122+
84123

85124
if __name__ == "__main__":
86125
main()

0 commit comments

Comments
 (0)