alchemyface 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alchemyface-0.1.0/CHANGELOG.md +41 -0
- alchemyface-0.1.0/LICENSE +21 -0
- alchemyface-0.1.0/MANIFEST.in +16 -0
- alchemyface-0.1.0/PKG-INFO +192 -0
- alchemyface-0.1.0/README.md +160 -0
- alchemyface-0.1.0/pyproject.toml +95 -0
- alchemyface-0.1.0/requirements-dev.txt +10 -0
- alchemyface-0.1.0/requirements.txt +6 -0
- alchemyface-0.1.0/setup.cfg +4 -0
- alchemyface-0.1.0/src/alchemyface/__init__.py +38 -0
- alchemyface-0.1.0/src/alchemyface/capture.py +68 -0
- alchemyface-0.1.0/src/alchemyface/cli.py +140 -0
- alchemyface-0.1.0/src/alchemyface/detection/__init__.py +6 -0
- alchemyface-0.1.0/src/alchemyface/detection/base.py +18 -0
- alchemyface-0.1.0/src/alchemyface/detection/yunet.py +82 -0
- alchemyface-0.1.0/src/alchemyface/embedding/__init__.py +6 -0
- alchemyface-0.1.0/src/alchemyface/embedding/base.py +26 -0
- alchemyface-0.1.0/src/alchemyface/embedding/sface.py +48 -0
- alchemyface-0.1.0/src/alchemyface/errors.py +21 -0
- alchemyface-0.1.0/src/alchemyface/models.py +155 -0
- alchemyface-0.1.0/src/alchemyface/pipeline.py +98 -0
- alchemyface-0.1.0/src/alchemyface/py.typed +0 -0
- alchemyface-0.1.0/src/alchemyface/store/__init__.py +6 -0
- alchemyface-0.1.0/src/alchemyface/store/base.py +37 -0
- alchemyface-0.1.0/src/alchemyface/store/memory.py +129 -0
- alchemyface-0.1.0/src/alchemyface/types.py +59 -0
- alchemyface-0.1.0/src/alchemyface.egg-info/PKG-INFO +192 -0
- alchemyface-0.1.0/src/alchemyface.egg-info/SOURCES.txt +30 -0
- alchemyface-0.1.0/src/alchemyface.egg-info/dependency_links.txt +1 -0
- alchemyface-0.1.0/src/alchemyface.egg-info/entry_points.txt +2 -0
- alchemyface-0.1.0/src/alchemyface.egg-info/requires.txt +9 -0
- alchemyface-0.1.0/src/alchemyface.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project follows [Semantic Versioning](https://semver.org/).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
_Nothing yet._
|
|
11
|
+
|
|
12
|
+
## [0.1.0] — 2026-08-28
|
|
13
|
+
|
|
14
|
+
First release. Extracted from an earlier prototype into a typed, installable library.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `Recognizer` facade sequencing three protocols — `Detector`, `Embedder` and
|
|
19
|
+
`FaceStore` — so a different gallery or embedding model is a drop-in.
|
|
20
|
+
- `YuNetDetector` for detection and `SFaceEmbedder` for 128-d embeddings, via
|
|
21
|
+
OpenCV's DNN runtime. Embeddings are L2-normalised, making cosine similarity
|
|
22
|
+
a dot product.
|
|
23
|
+
- `InMemoryStore`: a numpy gallery with `.npz` save/load. No database required.
|
|
24
|
+
- Runtime model resolution — explicit path, `$ALCHEMYFACE_MODEL_DIR`,
|
|
25
|
+
`~/.cache/alchemyface/models/`, then a SHA256-verified download from the
|
|
26
|
+
OpenCV Zoo. Weights are never vendored, keeping the wheel at ~20 KB.
|
|
27
|
+
- `VideoSource`, a context-managed wrapper over `cv2.VideoCapture`.
|
|
28
|
+
- CLI: `alchemyface download-models | enroll | identify | version`.
|
|
29
|
+
- Inline type information (`py.typed`), checked with mypy under
|
|
30
|
+
`disallow_untyped_defs`.
|
|
31
|
+
|
|
32
|
+
### Notes
|
|
33
|
+
|
|
34
|
+
- Depends on `opencv-python-headless` rather than `opencv-python`: the package
|
|
35
|
+
makes no GUI calls, and the headless build installs cleanly on CI, in Docker
|
|
36
|
+
and on servers without `libGL`.
|
|
37
|
+
- 88 tests at 91% coverage. The default suite needs no models, camera or
|
|
38
|
+
network; model-backed tests are marked and skip when weights are absent.
|
|
39
|
+
|
|
40
|
+
[Unreleased]: https://github.com/kouya-marino/AlchemyFace/compare/v0.1.0...HEAD
|
|
41
|
+
[0.1.0]: https://github.com/kouya-marino/AlchemyFace/releases/tag/v0.1.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kouya-marino
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
include LICENSE
|
|
2
|
+
include README.md
|
|
3
|
+
include CHANGELOG.md
|
|
4
|
+
include requirements.txt
|
|
5
|
+
include requirements-dev.txt
|
|
6
|
+
include src/alchemyface/py.typed
|
|
7
|
+
|
|
8
|
+
# Never ship: personal data, model weights, developer scaffolding.
|
|
9
|
+
prune _local
|
|
10
|
+
prune examples
|
|
11
|
+
prune tests
|
|
12
|
+
prune docs
|
|
13
|
+
prune htmlcov
|
|
14
|
+
global-exclude *.npy *.npz *.onnx *.mp3 *.pt
|
|
15
|
+
global-exclude *.py[cod]
|
|
16
|
+
global-exclude .DS_Store
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alchemyface
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Face detection and recognition built on YuNet and SFace — a small, typed, dependency-light Python library.
|
|
5
|
+
Author-email: Prashant Rawat <prashantrawatmailbox@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/kouya-marino/AlchemyFace
|
|
8
|
+
Project-URL: Repository, https://github.com/kouya-marino/AlchemyFace
|
|
9
|
+
Project-URL: Issues, https://github.com/kouya-marino/AlchemyFace/issues
|
|
10
|
+
Keywords: face-recognition,face-detection,yunet,sface,opencv,embeddings
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: opencv-python-headless<5,>=4.9
|
|
24
|
+
Requires-Dist: numpy<3,>=1.24
|
|
25
|
+
Requires-Dist: typer<1,>=0.12
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
29
|
+
Requires-Dist: ruff>=0.4.0; extra == "dev"
|
|
30
|
+
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# AlchemyFace
|
|
34
|
+
|
|
35
|
+
[](https://pypi.org/project/alchemyface/)
|
|
36
|
+
[](https://github.com/kouya-marino/AlchemyFace/actions/workflows/ci.yml)
|
|
37
|
+
[](https://github.com/astral-sh/ruff)
|
|
38
|
+
[](https://www.python.org/)
|
|
39
|
+
|
|
40
|
+
Face detection and recognition built on [YuNet](https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet)
|
|
41
|
+
and [SFace](https://github.com/opencv/opencv_zoo/tree/main/models/face_recognition_sface).
|
|
42
|
+
Small, typed, and dependency-light: OpenCV, NumPy, Typer. Nothing else.
|
|
43
|
+
|
|
44
|
+
## Why
|
|
45
|
+
|
|
46
|
+
Most Python face-recognition libraries pull in dlib, PyTorch or TensorFlow.
|
|
47
|
+
AlchemyFace uses two small ONNX models through OpenCV's own DNN runtime, so a
|
|
48
|
+
working install is a few megabytes of Python and about 37 MB of weights fetched
|
|
49
|
+
once, on first use.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install alchemyface
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Use
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import cv2
|
|
61
|
+
from alchemyface import Recognizer
|
|
62
|
+
|
|
63
|
+
r = Recognizer() # weights download once, then cached
|
|
64
|
+
|
|
65
|
+
r.enroll("prashant", cv2.imread("me.jpg"))
|
|
66
|
+
r.enroll("alice", cv2.imread("alice.jpg"))
|
|
67
|
+
|
|
68
|
+
for recognition in r.identify(cv2.imread("group.jpg")):
|
|
69
|
+
face, match = recognition.face, recognition.match
|
|
70
|
+
if match:
|
|
71
|
+
print(f"{match.label} at {face.bbox} ({match.score:.2f})")
|
|
72
|
+
else:
|
|
73
|
+
print(f"unknown face at {face.bbox}")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Enrolled faces live in memory. Persist them when you are done:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
r.store.save("gallery.npz")
|
|
80
|
+
r.store.load("gallery.npz")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Bring your own components
|
|
84
|
+
|
|
85
|
+
`Recognizer` is a thin facade over three protocols — `Detector`, `Embedder` and
|
|
86
|
+
`FaceStore`. Any object satisfying the protocol can be substituted, which is how
|
|
87
|
+
a pgvector-backed store or a different embedding model will slot in later
|
|
88
|
+
without touching the pipeline.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from alchemyface import Recognizer
|
|
92
|
+
from alchemyface.detection import YuNetDetector
|
|
93
|
+
from alchemyface.embedding import SFaceEmbedder
|
|
94
|
+
from alchemyface.store import InMemoryStore
|
|
95
|
+
|
|
96
|
+
r = Recognizer(
|
|
97
|
+
detector=YuNetDetector(score_threshold=0.8),
|
|
98
|
+
embedder=SFaceEmbedder(),
|
|
99
|
+
store=InMemoryStore(),
|
|
100
|
+
threshold=0.363,
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Live video
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from alchemyface import Recognizer
|
|
108
|
+
from alchemyface.capture import VideoSource
|
|
109
|
+
|
|
110
|
+
r = Recognizer()
|
|
111
|
+
r.store.load("gallery.npz")
|
|
112
|
+
|
|
113
|
+
with VideoSource(0, width=1280, height=720) as camera:
|
|
114
|
+
for frame in camera.frames():
|
|
115
|
+
for recognition in r.identify(frame):
|
|
116
|
+
match = recognition.match
|
|
117
|
+
print(match.label if match else "unknown", recognition.face.bbox)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### CLI
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
alchemyface download-models # pre-fetch weights
|
|
124
|
+
alchemyface enroll --name prashant --image me.jpg --gallery g.npz
|
|
125
|
+
alchemyface identify --image group.jpg --gallery g.npz
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Model weights
|
|
129
|
+
|
|
130
|
+
Weights are resolved in this order, first hit wins:
|
|
131
|
+
|
|
132
|
+
1. `model_dir=` passed to `Recognizer`
|
|
133
|
+
2. `$ALCHEMYFACE_MODEL_DIR`
|
|
134
|
+
3. `~/.cache/alchemyface/models/`
|
|
135
|
+
4. downloaded from the OpenCV Zoo and SHA256-verified
|
|
136
|
+
|
|
137
|
+
To work fully offline, point at a directory you already have:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
export ALCHEMYFACE_MODEL_DIR=/path/to/onnx
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## The recognition threshold
|
|
144
|
+
|
|
145
|
+
The default cosine threshold is `0.363`, SFace's published operating point:
|
|
146
|
+
above it, two embeddings are treated as the same person. Raise it for fewer
|
|
147
|
+
false accepts, lower it for fewer false rejects. It is a tunable, not a
|
|
148
|
+
constant — validate it against your own data before relying on it.
|
|
149
|
+
|
|
150
|
+
## Development
|
|
151
|
+
|
|
152
|
+
Requires [`pyenv`](https://github.com/pyenv/pyenv) with
|
|
153
|
+
[`pyenv-virtualenv`](https://github.com/pyenv/pyenv-virtualenv).
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
pyenv install 3.10.6 # if not already present
|
|
157
|
+
pyenv virtualenv 3.10.6 alchemyface # .python-version activates it here
|
|
158
|
+
pip install -e ".[dev]"
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
| Command | Does |
|
|
162
|
+
|---|---|
|
|
163
|
+
| `pytest tests/ -m "not models and not camera"` | the fast suite — no models, camera or network |
|
|
164
|
+
| `pytest tests/ -m "not camera"` | adds the tests that load the real ONNX weights |
|
|
165
|
+
| `ruff check src tests` | lint |
|
|
166
|
+
| `ruff format src tests` | format |
|
|
167
|
+
| `mypy src/alchemyface` | type check |
|
|
168
|
+
| `python -m build` | build the wheel and sdist |
|
|
169
|
+
|
|
170
|
+
Tests that need the real weights are marked `models` and skip unless
|
|
171
|
+
`ALCHEMYFACE_MODEL_DIR` points at a directory containing them:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
export ALCHEMYFACE_MODEL_DIR="$PWD/_local/onnx"
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## A note on data
|
|
178
|
+
|
|
179
|
+
This repository contains a `_local/` directory that is **git-ignored and must
|
|
180
|
+
stay that way**. It holds face embeddings, name recordings and captured images
|
|
181
|
+
of real, identifiable people, carried over from the internal prototype this
|
|
182
|
+
library grew out of. Under Japan's APPI and GDPR Article 9 those are sensitive
|
|
183
|
+
personal data. They are development fixtures only: they are excluded from the
|
|
184
|
+
wheel, the sdist and version control, and they must never be published.
|
|
185
|
+
|
|
186
|
+
## Licence
|
|
187
|
+
|
|
188
|
+
MIT — see [LICENSE](LICENSE).
|
|
189
|
+
|
|
190
|
+
The ONNX weights are distributed by the [OpenCV Zoo](https://github.com/opencv/opencv_zoo)
|
|
191
|
+
under their own terms — YuNet under MIT, SFace under Apache-2.0 — and are
|
|
192
|
+
downloaded at runtime rather than redistributed here.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# AlchemyFace
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/alchemyface/)
|
|
4
|
+
[](https://github.com/kouya-marino/AlchemyFace/actions/workflows/ci.yml)
|
|
5
|
+
[](https://github.com/astral-sh/ruff)
|
|
6
|
+
[](https://www.python.org/)
|
|
7
|
+
|
|
8
|
+
Face detection and recognition built on [YuNet](https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet)
|
|
9
|
+
and [SFace](https://github.com/opencv/opencv_zoo/tree/main/models/face_recognition_sface).
|
|
10
|
+
Small, typed, and dependency-light: OpenCV, NumPy, Typer. Nothing else.
|
|
11
|
+
|
|
12
|
+
## Why
|
|
13
|
+
|
|
14
|
+
Most Python face-recognition libraries pull in dlib, PyTorch or TensorFlow.
|
|
15
|
+
AlchemyFace uses two small ONNX models through OpenCV's own DNN runtime, so a
|
|
16
|
+
working install is a few megabytes of Python and about 37 MB of weights fetched
|
|
17
|
+
once, on first use.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install alchemyface
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Use
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import cv2
|
|
29
|
+
from alchemyface import Recognizer
|
|
30
|
+
|
|
31
|
+
r = Recognizer() # weights download once, then cached
|
|
32
|
+
|
|
33
|
+
r.enroll("prashant", cv2.imread("me.jpg"))
|
|
34
|
+
r.enroll("alice", cv2.imread("alice.jpg"))
|
|
35
|
+
|
|
36
|
+
for recognition in r.identify(cv2.imread("group.jpg")):
|
|
37
|
+
face, match = recognition.face, recognition.match
|
|
38
|
+
if match:
|
|
39
|
+
print(f"{match.label} at {face.bbox} ({match.score:.2f})")
|
|
40
|
+
else:
|
|
41
|
+
print(f"unknown face at {face.bbox}")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Enrolled faces live in memory. Persist them when you are done:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
r.store.save("gallery.npz")
|
|
48
|
+
r.store.load("gallery.npz")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Bring your own components
|
|
52
|
+
|
|
53
|
+
`Recognizer` is a thin facade over three protocols — `Detector`, `Embedder` and
|
|
54
|
+
`FaceStore`. Any object satisfying the protocol can be substituted, which is how
|
|
55
|
+
a pgvector-backed store or a different embedding model will slot in later
|
|
56
|
+
without touching the pipeline.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from alchemyface import Recognizer
|
|
60
|
+
from alchemyface.detection import YuNetDetector
|
|
61
|
+
from alchemyface.embedding import SFaceEmbedder
|
|
62
|
+
from alchemyface.store import InMemoryStore
|
|
63
|
+
|
|
64
|
+
r = Recognizer(
|
|
65
|
+
detector=YuNetDetector(score_threshold=0.8),
|
|
66
|
+
embedder=SFaceEmbedder(),
|
|
67
|
+
store=InMemoryStore(),
|
|
68
|
+
threshold=0.363,
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Live video
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from alchemyface import Recognizer
|
|
76
|
+
from alchemyface.capture import VideoSource
|
|
77
|
+
|
|
78
|
+
r = Recognizer()
|
|
79
|
+
r.store.load("gallery.npz")
|
|
80
|
+
|
|
81
|
+
with VideoSource(0, width=1280, height=720) as camera:
|
|
82
|
+
for frame in camera.frames():
|
|
83
|
+
for recognition in r.identify(frame):
|
|
84
|
+
match = recognition.match
|
|
85
|
+
print(match.label if match else "unknown", recognition.face.bbox)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### CLI
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
alchemyface download-models # pre-fetch weights
|
|
92
|
+
alchemyface enroll --name prashant --image me.jpg --gallery g.npz
|
|
93
|
+
alchemyface identify --image group.jpg --gallery g.npz
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Model weights
|
|
97
|
+
|
|
98
|
+
Weights are resolved in this order, first hit wins:
|
|
99
|
+
|
|
100
|
+
1. `model_dir=` passed to `Recognizer`
|
|
101
|
+
2. `$ALCHEMYFACE_MODEL_DIR`
|
|
102
|
+
3. `~/.cache/alchemyface/models/`
|
|
103
|
+
4. downloaded from the OpenCV Zoo and SHA256-verified
|
|
104
|
+
|
|
105
|
+
To work fully offline, point at a directory you already have:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
export ALCHEMYFACE_MODEL_DIR=/path/to/onnx
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## The recognition threshold
|
|
112
|
+
|
|
113
|
+
The default cosine threshold is `0.363`, SFace's published operating point:
|
|
114
|
+
above it, two embeddings are treated as the same person. Raise it for fewer
|
|
115
|
+
false accepts, lower it for fewer false rejects. It is a tunable, not a
|
|
116
|
+
constant — validate it against your own data before relying on it.
|
|
117
|
+
|
|
118
|
+
## Development
|
|
119
|
+
|
|
120
|
+
Requires [`pyenv`](https://github.com/pyenv/pyenv) with
|
|
121
|
+
[`pyenv-virtualenv`](https://github.com/pyenv/pyenv-virtualenv).
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
pyenv install 3.10.6 # if not already present
|
|
125
|
+
pyenv virtualenv 3.10.6 alchemyface # .python-version activates it here
|
|
126
|
+
pip install -e ".[dev]"
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
| Command | Does |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `pytest tests/ -m "not models and not camera"` | the fast suite — no models, camera or network |
|
|
132
|
+
| `pytest tests/ -m "not camera"` | adds the tests that load the real ONNX weights |
|
|
133
|
+
| `ruff check src tests` | lint |
|
|
134
|
+
| `ruff format src tests` | format |
|
|
135
|
+
| `mypy src/alchemyface` | type check |
|
|
136
|
+
| `python -m build` | build the wheel and sdist |
|
|
137
|
+
|
|
138
|
+
Tests that need the real weights are marked `models` and skip unless
|
|
139
|
+
`ALCHEMYFACE_MODEL_DIR` points at a directory containing them:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
export ALCHEMYFACE_MODEL_DIR="$PWD/_local/onnx"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## A note on data
|
|
146
|
+
|
|
147
|
+
This repository contains a `_local/` directory that is **git-ignored and must
|
|
148
|
+
stay that way**. It holds face embeddings, name recordings and captured images
|
|
149
|
+
of real, identifiable people, carried over from the internal prototype this
|
|
150
|
+
library grew out of. Under Japan's APPI and GDPR Article 9 those are sensitive
|
|
151
|
+
personal data. They are development fixtures only: they are excluded from the
|
|
152
|
+
wheel, the sdist and version control, and they must never be published.
|
|
153
|
+
|
|
154
|
+
## Licence
|
|
155
|
+
|
|
156
|
+
MIT — see [LICENSE](LICENSE).
|
|
157
|
+
|
|
158
|
+
The ONNX weights are distributed by the [OpenCV Zoo](https://github.com/opencv/opencv_zoo)
|
|
159
|
+
under their own terms — YuNet under MIT, SFace under Apache-2.0 — and are
|
|
160
|
+
downloaded at runtime rather than redistributed here.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "alchemyface"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Face detection and recognition built on YuNet and SFace — a small, typed, dependency-light Python library."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Prashant Rawat", email = "prashantrawatmailbox@gmail.com"},
|
|
14
|
+
]
|
|
15
|
+
keywords = ["face-recognition", "face-detection", "yunet", "sface", "opencv", "embeddings"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Intended Audience :: Science/Research",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Scientific/Engineering :: Image Recognition",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
# Deliberately minimal. Model weights are fetched at runtime rather than
|
|
28
|
+
# vendored, and everything the original prototype needed for its own deployment
|
|
29
|
+
# (SQLAlchemy, psycopg2, pgvector, pandas, scikit-learn, ultralytics, pygame)
|
|
30
|
+
# belongs to the application, not to this library.
|
|
31
|
+
#
|
|
32
|
+
# headless, not the GUI build: the package makes no imshow/waitKey calls, and
|
|
33
|
+
# plain opencv-python needs libGL, which breaks CI, Docker and headless servers.
|
|
34
|
+
dependencies = [
|
|
35
|
+
"opencv-python-headless>=4.9,<5",
|
|
36
|
+
"numpy>=1.24,<3",
|
|
37
|
+
"typer>=0.12,<1",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
dev = [
|
|
42
|
+
"pytest>=8.0",
|
|
43
|
+
"pytest-cov>=5.0",
|
|
44
|
+
"ruff>=0.4.0",
|
|
45
|
+
"mypy>=1.11",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[project.urls]
|
|
49
|
+
Homepage = "https://github.com/kouya-marino/AlchemyFace"
|
|
50
|
+
Repository = "https://github.com/kouya-marino/AlchemyFace"
|
|
51
|
+
Issues = "https://github.com/kouya-marino/AlchemyFace/issues"
|
|
52
|
+
|
|
53
|
+
[project.scripts]
|
|
54
|
+
alchemyface = "alchemyface.cli:app"
|
|
55
|
+
|
|
56
|
+
[tool.setuptools.packages.find]
|
|
57
|
+
where = ["src"]
|
|
58
|
+
include = ["alchemyface*"]
|
|
59
|
+
|
|
60
|
+
[tool.setuptools.package-data]
|
|
61
|
+
alchemyface = ["py.typed"]
|
|
62
|
+
|
|
63
|
+
[tool.pytest.ini_options]
|
|
64
|
+
testpaths = ["tests"]
|
|
65
|
+
addopts = "-v --tb=short"
|
|
66
|
+
markers = [
|
|
67
|
+
"models: requires the real ONNX weights on disk (skipped when absent)",
|
|
68
|
+
"camera: requires an attached camera (skipped in CI)",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[tool.coverage.run]
|
|
72
|
+
source = ["src/alchemyface"]
|
|
73
|
+
branch = true
|
|
74
|
+
|
|
75
|
+
[tool.ruff]
|
|
76
|
+
target-version = "py310"
|
|
77
|
+
line-length = 120
|
|
78
|
+
|
|
79
|
+
[tool.ruff.lint]
|
|
80
|
+
select = ["E", "F", "W", "I"]
|
|
81
|
+
|
|
82
|
+
[tool.mypy]
|
|
83
|
+
python_version = "3.10"
|
|
84
|
+
packages = ["alchemyface"]
|
|
85
|
+
mypy_path = "src"
|
|
86
|
+
warn_unused_ignores = true
|
|
87
|
+
warn_redundant_casts = true
|
|
88
|
+
disallow_untyped_defs = true
|
|
89
|
+
no_implicit_optional = true
|
|
90
|
+
|
|
91
|
+
# cv2 ships partial stubs. Use the `FaceDetectorYN.create` / `FaceRecognizerSF.create`
|
|
92
|
+
# class-method form, which they declare; the module-level `*_create` aliases are not.
|
|
93
|
+
[[tool.mypy.overrides]]
|
|
94
|
+
module = ["cv2.*"]
|
|
95
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Development-only dependencies. Install with:
|
|
2
|
+
# pip install -r requirements-dev.txt
|
|
3
|
+
# Production deps stay in requirements.txt.
|
|
4
|
+
#
|
|
5
|
+
# Kept in step with pyproject.toml's [project.optional-dependencies] dev extra —
|
|
6
|
+
# CI gates on ruff and mypy, so both belong here.
|
|
7
|
+
pytest>=8.0
|
|
8
|
+
pytest-cov>=5.0
|
|
9
|
+
ruff>=0.4.0
|
|
10
|
+
mypy>=1.11
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Runtime dependencies, kept in step with pyproject.toml's [project] dependencies.
|
|
2
|
+
# Installing the package itself (`pip install -e .`) is the usual route; this
|
|
3
|
+
# file exists for environments that pin from a requirements file.
|
|
4
|
+
opencv-python-headless>=4.9,<5
|
|
5
|
+
numpy>=1.24,<3
|
|
6
|
+
typer>=0.12,<1
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""AlchemyFace — face detection and recognition on YuNet and SFace.
|
|
2
|
+
|
|
3
|
+
The public surface is deliberately small::
|
|
4
|
+
|
|
5
|
+
from alchemyface import Recognizer
|
|
6
|
+
|
|
7
|
+
r = Recognizer()
|
|
8
|
+
r.enroll("prashant", image)
|
|
9
|
+
r.identify(frame)
|
|
10
|
+
|
|
11
|
+
Everything else is a seam. ``Detector``, ``Embedder`` and ``FaceStore`` are
|
|
12
|
+
protocols, so any conforming object can be substituted without touching the
|
|
13
|
+
pipeline.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from alchemyface.errors import (
|
|
17
|
+
AlchemyFaceError,
|
|
18
|
+
ModelDownloadError,
|
|
19
|
+
ModelNotFoundError,
|
|
20
|
+
NoFaceDetectedError,
|
|
21
|
+
)
|
|
22
|
+
from alchemyface.pipeline import DEFAULT_THRESHOLD, Recognizer
|
|
23
|
+
from alchemyface.types import Face, Match, Recognition
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"AlchemyFaceError",
|
|
29
|
+
"DEFAULT_THRESHOLD",
|
|
30
|
+
"Face",
|
|
31
|
+
"Match",
|
|
32
|
+
"ModelDownloadError",
|
|
33
|
+
"ModelNotFoundError",
|
|
34
|
+
"NoFaceDetectedError",
|
|
35
|
+
"Recognition",
|
|
36
|
+
"Recognizer",
|
|
37
|
+
"__version__",
|
|
38
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Reading frames from a camera or a video file.
|
|
2
|
+
|
|
3
|
+
A thin wrapper over ``cv2.VideoCapture`` that fails loudly when the device
|
|
4
|
+
will not open, releases itself on the way out of a ``with`` block, and offers
|
|
5
|
+
an iterator so callers do not have to write the read-check-read loop by hand.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
from typing import Iterator
|
|
12
|
+
|
|
13
|
+
import cv2
|
|
14
|
+
import numpy as np
|
|
15
|
+
from numpy.typing import NDArray
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class VideoSource:
|
|
19
|
+
"""A camera index or a path to a video file, as a context manager."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
source: int | str = 0,
|
|
24
|
+
*,
|
|
25
|
+
width: int | None = None,
|
|
26
|
+
height: int | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self._capture = cv2.VideoCapture(source)
|
|
29
|
+
if not self._capture.isOpened():
|
|
30
|
+
self._capture.release()
|
|
31
|
+
raise RuntimeError(f"could not open video source {source!r}")
|
|
32
|
+
if width is not None:
|
|
33
|
+
self._capture.set(cv2.CAP_PROP_FRAME_WIDTH, float(width))
|
|
34
|
+
if height is not None:
|
|
35
|
+
self._capture.set(cv2.CAP_PROP_FRAME_HEIGHT, float(height))
|
|
36
|
+
self._released = False
|
|
37
|
+
|
|
38
|
+
def read(self) -> NDArray[np.uint8] | None:
|
|
39
|
+
"""The next frame, or ``None`` once the stream is exhausted."""
|
|
40
|
+
ok, frame = self._capture.read()
|
|
41
|
+
if not ok:
|
|
42
|
+
return None
|
|
43
|
+
return np.asarray(frame, dtype=np.uint8)
|
|
44
|
+
|
|
45
|
+
def frames(self) -> Iterator[NDArray[np.uint8]]:
|
|
46
|
+
"""Yield frames until the stream ends."""
|
|
47
|
+
while True:
|
|
48
|
+
frame = self.read()
|
|
49
|
+
if frame is None:
|
|
50
|
+
return
|
|
51
|
+
yield frame
|
|
52
|
+
|
|
53
|
+
def release(self) -> None:
|
|
54
|
+
"""Release the device. Safe to call more than once."""
|
|
55
|
+
if not self._released:
|
|
56
|
+
self._capture.release()
|
|
57
|
+
self._released = True
|
|
58
|
+
|
|
59
|
+
def __enter__(self) -> "VideoSource":
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def __exit__(
|
|
63
|
+
self,
|
|
64
|
+
exc_type: type[BaseException] | None,
|
|
65
|
+
exc: BaseException | None,
|
|
66
|
+
traceback: TracebackType | None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.release()
|