alchemyface 0.1.0__py3-none-any.whl
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/__init__.py +38 -0
- alchemyface/capture.py +68 -0
- alchemyface/cli.py +140 -0
- alchemyface/detection/__init__.py +6 -0
- alchemyface/detection/base.py +18 -0
- alchemyface/detection/yunet.py +82 -0
- alchemyface/embedding/__init__.py +6 -0
- alchemyface/embedding/base.py +26 -0
- alchemyface/embedding/sface.py +48 -0
- alchemyface/errors.py +21 -0
- alchemyface/models.py +155 -0
- alchemyface/pipeline.py +98 -0
- alchemyface/py.typed +0 -0
- alchemyface/store/__init__.py +6 -0
- alchemyface/store/base.py +37 -0
- alchemyface/store/memory.py +129 -0
- alchemyface/types.py +59 -0
- alchemyface-0.1.0.dist-info/METADATA +192 -0
- alchemyface-0.1.0.dist-info/RECORD +23 -0
- alchemyface-0.1.0.dist-info/WHEEL +5 -0
- alchemyface-0.1.0.dist-info/entry_points.txt +2 -0
- alchemyface-0.1.0.dist-info/licenses/LICENSE +21 -0
- alchemyface-0.1.0.dist-info/top_level.txt +1 -0
alchemyface/__init__.py
ADDED
|
@@ -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
|
+
]
|
alchemyface/capture.py
ADDED
|
@@ -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()
|
alchemyface/cli.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Command line entry point for AlchemyFace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import typer
|
|
9
|
+
from numpy.typing import NDArray
|
|
10
|
+
|
|
11
|
+
from alchemyface import __version__
|
|
12
|
+
from alchemyface.errors import AlchemyFaceError
|
|
13
|
+
from alchemyface.models import MODELS, download, find_local
|
|
14
|
+
from alchemyface.pipeline import DEFAULT_THRESHOLD, Recognizer
|
|
15
|
+
from alchemyface.store.memory import InMemoryStore
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
name="alchemyface",
|
|
19
|
+
help="Face detection and recognition on YuNet and SFace.",
|
|
20
|
+
no_args_is_help=True,
|
|
21
|
+
add_completion=False,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Typer promotes a lone command to the top level, which would make `alchemyface
|
|
26
|
+
# version` an unexpected argument. An explicit callback keeps subcommand mode on
|
|
27
|
+
# regardless of how many commands are registered.
|
|
28
|
+
@app.callback()
|
|
29
|
+
def main() -> None:
|
|
30
|
+
"""Face detection and recognition on YuNet and SFace."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _build_recognizer(**kwargs: object) -> Recognizer:
|
|
34
|
+
"""Indirection so tests can substitute a fake-backed Recognizer."""
|
|
35
|
+
return Recognizer(**kwargs) # type: ignore[arg-type]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_image(path: Path) -> NDArray[np.uint8]:
|
|
39
|
+
# Imported lazily so `alchemyface version` does not pay for loading cv2.
|
|
40
|
+
import cv2 # pylint: disable=import-outside-toplevel
|
|
41
|
+
|
|
42
|
+
image = cv2.imread(str(path))
|
|
43
|
+
if image is None:
|
|
44
|
+
typer.echo(f"could not read an image from {path}", err=True)
|
|
45
|
+
raise typer.Exit(code=2)
|
|
46
|
+
# imread's default IMREAD_COLOR always yields 8-bit 3-channel BGR, so this
|
|
47
|
+
# is a no-op at runtime; it is here to state the dtype for the type checker.
|
|
48
|
+
return np.asarray(image, dtype=np.uint8)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_gallery(recognizer: Recognizer, gallery: Path) -> None:
|
|
52
|
+
store = recognizer.store
|
|
53
|
+
if gallery.exists() and isinstance(store, InMemoryStore):
|
|
54
|
+
store.load(gallery)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _save_gallery(recognizer: Recognizer, gallery: Path) -> None:
|
|
58
|
+
store = recognizer.store
|
|
59
|
+
if isinstance(store, InMemoryStore):
|
|
60
|
+
gallery.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
store.save(gallery)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command()
|
|
65
|
+
def version() -> None:
|
|
66
|
+
"""Print the installed AlchemyFace version."""
|
|
67
|
+
typer.echo(__version__)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command("download-models")
|
|
71
|
+
def download_models(
|
|
72
|
+
model_dir: Path | None = typer.Option(
|
|
73
|
+
None, "--model-dir", help="Where to write the weights (defaults to the cache)."
|
|
74
|
+
),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Fetch the ONNX weights ahead of first use."""
|
|
77
|
+
for spec in MODELS.values():
|
|
78
|
+
existing = find_local(spec, model_dir)
|
|
79
|
+
if existing is not None:
|
|
80
|
+
typer.echo(f"{spec.key}: already present at {existing}")
|
|
81
|
+
continue
|
|
82
|
+
typer.echo(f"{spec.key}: downloading {spec.filename} …")
|
|
83
|
+
try:
|
|
84
|
+
path = download(spec, dest_dir=model_dir)
|
|
85
|
+
except AlchemyFaceError as exc:
|
|
86
|
+
typer.echo(f"{spec.key}: {exc}", err=True)
|
|
87
|
+
raise typer.Exit(code=1) from exc
|
|
88
|
+
typer.echo(f"{spec.key}: saved to {path}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.command()
|
|
92
|
+
def enroll(
|
|
93
|
+
name: str = typer.Option(..., "--name", help="Label to store the face under."),
|
|
94
|
+
image: Path = typer.Option(..., "--image", exists=True, dir_okay=False, help="Photo containing one face."),
|
|
95
|
+
gallery: Path = typer.Option(..., "--gallery", help="Gallery .npz to create or extend."),
|
|
96
|
+
model_dir: Path | None = typer.Option(None, "--model-dir", help="Directory holding the weights."),
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Add the most prominent face in an image to a gallery."""
|
|
99
|
+
recognizer = _build_recognizer(model_dir=model_dir)
|
|
100
|
+
_load_gallery(recognizer, gallery)
|
|
101
|
+
try:
|
|
102
|
+
recognizer.enroll(name, _read_image(image))
|
|
103
|
+
except AlchemyFaceError as exc:
|
|
104
|
+
typer.echo(f"could not enroll {name}: {exc}", err=True)
|
|
105
|
+
raise typer.Exit(code=1) from exc
|
|
106
|
+
_save_gallery(recognizer, gallery)
|
|
107
|
+
typer.echo(f"enrolled {name} — gallery now holds {len(recognizer.store)} face(s)")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command()
|
|
111
|
+
def identify(
|
|
112
|
+
image: Path = typer.Option(..., "--image", exists=True, dir_okay=False, help="Image to search."),
|
|
113
|
+
gallery: Path = typer.Option(
|
|
114
|
+
...,
|
|
115
|
+
"--gallery",
|
|
116
|
+
exists=True,
|
|
117
|
+
dir_okay=False,
|
|
118
|
+
help="Gallery .npz to search against.",
|
|
119
|
+
),
|
|
120
|
+
threshold: float = typer.Option(DEFAULT_THRESHOLD, "--threshold", help="Cosine score required to accept."),
|
|
121
|
+
model_dir: Path | None = typer.Option(None, "--model-dir", help="Directory holding the weights."),
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Report who each face in an image looks like."""
|
|
124
|
+
recognizer = _build_recognizer(model_dir=model_dir, threshold=threshold)
|
|
125
|
+
recognizer.threshold = threshold
|
|
126
|
+
_load_gallery(recognizer, gallery)
|
|
127
|
+
recognitions = recognizer.identify(_read_image(image))
|
|
128
|
+
if not recognitions:
|
|
129
|
+
typer.echo("no faces detected")
|
|
130
|
+
return
|
|
131
|
+
for recognition in recognitions:
|
|
132
|
+
x, y, w, h = recognition.face.bbox
|
|
133
|
+
if recognition.match is None:
|
|
134
|
+
typer.echo(f"unknown at ({x},{y},{w},{h})")
|
|
135
|
+
else:
|
|
136
|
+
typer.echo(f"{recognition.match.label:<16} at ({x},{y},{w},{h}) score={recognition.match.score:.3f}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__": # pragma: no cover
|
|
140
|
+
app()
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Face detection. See ``base`` for the protocol these implement."""
|
|
2
|
+
|
|
3
|
+
from alchemyface.detection.base import Detector
|
|
4
|
+
from alchemyface.detection.yunet import YuNetDetector, face_from_row, row_from_face
|
|
5
|
+
|
|
6
|
+
__all__ = ["Detector", "YuNetDetector", "face_from_row", "row_from_face"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""The detection seam."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from numpy.typing import NDArray
|
|
9
|
+
|
|
10
|
+
from alchemyface.types import Face
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class Detector(Protocol):
|
|
15
|
+
"""Finds faces in a BGR image."""
|
|
16
|
+
|
|
17
|
+
def detect(self, image: NDArray[np.uint8]) -> list[Face]:
|
|
18
|
+
"""Every face found, in the detector's own order. Empty list if none."""
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""YuNet face detection through OpenCV's DNN runtime.
|
|
2
|
+
|
|
3
|
+
OpenCV hands back an ``(N, 15) float32`` array — bounding box in columns 0-3,
|
|
4
|
+
five landmarks in 4-13, score in 14 — or ``None`` when nothing is found. The
|
|
5
|
+
box is not clamped to the image, so it can start at a negative coordinate or
|
|
6
|
+
run off the right edge; ``face_from_row`` fixes that before anyone slices an
|
|
7
|
+
array with it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import cv2
|
|
15
|
+
import numpy as np
|
|
16
|
+
from numpy.typing import NDArray
|
|
17
|
+
|
|
18
|
+
from alchemyface.models import DETECTOR, resolve
|
|
19
|
+
from alchemyface.types import Face
|
|
20
|
+
|
|
21
|
+
_ROW_WIDTH = 15
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def face_from_row(row: NDArray[np.float32], image_shape: tuple[int, ...]) -> Face:
|
|
25
|
+
"""Convert one OpenCV detection row into a :class:`Face`, clamped."""
|
|
26
|
+
height, width = int(image_shape[0]), int(image_shape[1])
|
|
27
|
+
x = max(0, int(row[0]))
|
|
28
|
+
y = max(0, int(row[1]))
|
|
29
|
+
w = max(0, min(int(row[2]), width - x))
|
|
30
|
+
h = max(0, min(int(row[3]), height - y))
|
|
31
|
+
return Face(
|
|
32
|
+
bbox=(x, y, w, h),
|
|
33
|
+
landmarks=np.asarray(row[4:14], dtype=np.float32).reshape(5, 2),
|
|
34
|
+
confidence=float(row[14]),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def row_from_face(face: Face) -> NDArray[np.float32]:
|
|
39
|
+
"""Rebuild the 15-column row OpenCV needs for ``alignCrop``."""
|
|
40
|
+
row = np.zeros(_ROW_WIDTH, dtype=np.float32)
|
|
41
|
+
row[:4] = face.bbox
|
|
42
|
+
row[4:14] = face.landmarks.reshape(-1)
|
|
43
|
+
row[14] = face.confidence
|
|
44
|
+
return row
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class YuNetDetector:
|
|
48
|
+
"""Implements :class:`~alchemyface.detection.base.Detector` with YuNet."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
model_path: Path | str | None = None,
|
|
54
|
+
model_dir: Path | str | None = None,
|
|
55
|
+
score_threshold: float = 0.9,
|
|
56
|
+
nms_threshold: float = 0.3,
|
|
57
|
+
top_k: int = 5000,
|
|
58
|
+
) -> None:
|
|
59
|
+
path = Path(model_path) if model_path else resolve(DETECTOR, model_dir)
|
|
60
|
+
# The `Xxx.create` class-method form is what cv2's bundled type stubs
|
|
61
|
+
# declare; the module-level `FaceDetectorYN_create` alias is not, and
|
|
62
|
+
# trips mypy with "Module has no attribute".
|
|
63
|
+
self._detector = cv2.FaceDetectorYN.create(str(path), "", (320, 320), score_threshold, nms_threshold, top_k)
|
|
64
|
+
self._input_size = (320, 320)
|
|
65
|
+
|
|
66
|
+
def detect(self, image: NDArray[np.uint8]) -> list[Face]:
|
|
67
|
+
"""Every face found in a BGR image, with boxes clamped to its bounds."""
|
|
68
|
+
if image.ndim != 3 or image.shape[2] != 3:
|
|
69
|
+
raise ValueError(f"expected a three-channel BGR image, got shape {image.shape}")
|
|
70
|
+
height, width = image.shape[:2]
|
|
71
|
+
# The network is built for a fixed input size; it must be told
|
|
72
|
+
# whenever the frame dimensions change or OpenCV raises.
|
|
73
|
+
if (width, height) != self._input_size:
|
|
74
|
+
self._detector.setInputSize((width, height))
|
|
75
|
+
self._input_size = (width, height)
|
|
76
|
+
|
|
77
|
+
_, rows = self._detector.detect(image)
|
|
78
|
+
if rows is None:
|
|
79
|
+
return []
|
|
80
|
+
# Iterating a 2-D ndarray yields rows, but the numpy stubs type the
|
|
81
|
+
# element as a scalar, so each row is re-asserted as a float32 array.
|
|
82
|
+
return [face_from_row(np.asarray(row, dtype=np.float32), image.shape) for row in rows]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""The embedding seam."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from numpy.typing import NDArray
|
|
9
|
+
|
|
10
|
+
from alchemyface.types import Face
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class Embedder(Protocol):
|
|
15
|
+
"""Turns a detected face into a comparable vector."""
|
|
16
|
+
|
|
17
|
+
dim: int
|
|
18
|
+
"""Length of the vectors this embedder produces."""
|
|
19
|
+
|
|
20
|
+
def embed(self, image: NDArray[np.uint8], face: Face) -> NDArray[np.float32]:
|
|
21
|
+
"""A unit-length embedding of ``face`` as it appears in ``image``.
|
|
22
|
+
|
|
23
|
+
Implementations MUST return an L2-normalised, one-dimensional array of
|
|
24
|
+
length ``dim``, so that a dot product between two of them is their
|
|
25
|
+
cosine similarity.
|
|
26
|
+
"""
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""SFace embeddings through OpenCV's DNN runtime.
|
|
2
|
+
|
|
3
|
+
``alignCrop`` warps the face to a canonical 112x112 using the five landmarks,
|
|
4
|
+
and ``feature`` turns that into a ``(1, 128) float32`` row. That row is *not*
|
|
5
|
+
normalised — its L2 norm is around 10 — so this class flattens and normalises
|
|
6
|
+
it. Once every vector is unit length, cosine similarity is a dot product, which
|
|
7
|
+
is exactly what OpenCV's own ``FaceRecognizerSF.match`` computes and why
|
|
8
|
+
scikit-learn is not a dependency.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import cv2
|
|
16
|
+
import numpy as np
|
|
17
|
+
from numpy.typing import NDArray
|
|
18
|
+
|
|
19
|
+
from alchemyface.detection import row_from_face
|
|
20
|
+
from alchemyface.models import EMBEDDER, resolve
|
|
21
|
+
from alchemyface.types import Face
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SFaceEmbedder:
|
|
25
|
+
"""Implements :class:`~alchemyface.embedding.base.Embedder` with SFace."""
|
|
26
|
+
|
|
27
|
+
dim: int = 128
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
model_path: Path | str | None = None,
|
|
33
|
+
model_dir: Path | str | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
path = Path(model_path) if model_path else resolve(EMBEDDER, model_dir)
|
|
36
|
+
# The `Xxx.create` class-method form is what cv2's bundled type stubs
|
|
37
|
+
# declare; the module-level `FaceRecognizerSF_create` alias is not.
|
|
38
|
+
self._recognizer = cv2.FaceRecognizerSF.create(str(path), "")
|
|
39
|
+
|
|
40
|
+
def embed(self, image: NDArray[np.uint8], face: Face) -> NDArray[np.float32]:
|
|
41
|
+
"""A unit-length 128-d embedding of one detected face."""
|
|
42
|
+
aligned = self._recognizer.alignCrop(image, row_from_face(face))
|
|
43
|
+
raw = self._recognizer.feature(aligned)
|
|
44
|
+
flat = np.asarray(raw, dtype=np.float32).ravel()
|
|
45
|
+
norm = float(np.linalg.norm(flat))
|
|
46
|
+
if norm == 0.0:
|
|
47
|
+
raise ValueError("SFace returned a zero embedding for this face")
|
|
48
|
+
return (flat / norm).astype(np.float32)
|
alchemyface/errors.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Every exception AlchemyFace raises descends from AlchemyFaceError, so a
|
|
2
|
+
caller can wrap the library in one except clause and never see a bare
|
|
3
|
+
cv2.error or urllib exception leak through."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AlchemyFaceError(Exception):
|
|
9
|
+
"""Base class for every error AlchemyFace raises."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ModelNotFoundError(AlchemyFaceError):
|
|
13
|
+
"""Weights were not on disk and downloading them was not permitted."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ModelDownloadError(AlchemyFaceError):
|
|
17
|
+
"""A download failed, or what arrived did not match its checksum."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NoFaceDetectedError(AlchemyFaceError):
|
|
21
|
+
"""An operation required a face and the image had none."""
|
alchemyface/models.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Locating the ONNX weights AlchemyFace runs on.
|
|
2
|
+
|
|
3
|
+
Weights are not vendored: a 37 MB wheel is slow to install and re-uploads on
|
|
4
|
+
every release. They are resolved at runtime, first hit wins:
|
|
5
|
+
|
|
6
|
+
1. an explicit ``model_dir`` argument
|
|
7
|
+
2. ``$ALCHEMYFACE_MODEL_DIR``
|
|
8
|
+
3. ``~/.cache/alchemyface/models/`` (honours ``$XDG_CACHE_HOME``)
|
|
9
|
+
4. downloaded from the OpenCV Zoo and verified against a pinned SHA256
|
|
10
|
+
|
|
11
|
+
Files already on disk are trusted and not checksummed — they may legitimately
|
|
12
|
+
be a different build. Downloads always are.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import tempfile
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.request
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Iterator
|
|
26
|
+
|
|
27
|
+
from alchemyface.errors import ModelDownloadError, ModelNotFoundError
|
|
28
|
+
|
|
29
|
+
# The zoo keeps its weights in Git LFS. raw.githubusercontent.com serves the
|
|
30
|
+
# ~130-byte pointer file instead of the model; the media host serves the model.
|
|
31
|
+
_ZOO = "https://media.githubusercontent.com/media/opencv/opencv_zoo/main/models"
|
|
32
|
+
|
|
33
|
+
_CHUNK = 1 << 20
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ModelSpec:
|
|
38
|
+
"""Where a model lives, what it is called, and how to know it arrived intact."""
|
|
39
|
+
|
|
40
|
+
key: str
|
|
41
|
+
filename: str
|
|
42
|
+
url: str
|
|
43
|
+
sha256: str
|
|
44
|
+
|
|
45
|
+
aliases: tuple[str, ...] = ()
|
|
46
|
+
"""Other filenames the same architecture ships under. The prototype's
|
|
47
|
+
weights use different names, and should resolve without being renamed."""
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def candidates(self) -> tuple[str, ...]:
|
|
51
|
+
"""Filenames to look for on disk, canonical name first."""
|
|
52
|
+
return (self.filename, *self.aliases)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
DETECTOR = ModelSpec(
|
|
56
|
+
key="detector",
|
|
57
|
+
filename="face_detection_yunet_2023mar.onnx",
|
|
58
|
+
url=f"{_ZOO}/face_detection_yunet/face_detection_yunet_2023mar.onnx",
|
|
59
|
+
sha256="8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4",
|
|
60
|
+
aliases=("yunet_n_640_640.onnx",),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
EMBEDDER = ModelSpec(
|
|
64
|
+
key="embedder",
|
|
65
|
+
filename="face_recognition_sface_2021dec.onnx",
|
|
66
|
+
url=f"{_ZOO}/face_recognition_sface/face_recognition_sface_2021dec.onnx",
|
|
67
|
+
sha256="0ba9fbfa01b5270c96627c4ef784da859931e02f04419c829e83484087c34e79",
|
|
68
|
+
aliases=("face_recognizer_fast.onnx",),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
MODELS: dict[str, ModelSpec] = {spec.key: spec for spec in (DETECTOR, EMBEDDER)}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cache_dir() -> Path:
|
|
75
|
+
"""Where downloaded weights are kept between runs."""
|
|
76
|
+
root = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
|
|
77
|
+
return Path(root) / "alchemyface" / "models"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _search_paths(model_dir: Path | str | None = None) -> Iterator[Path]:
|
|
81
|
+
if model_dir is not None:
|
|
82
|
+
yield Path(model_dir)
|
|
83
|
+
env = os.environ.get("ALCHEMYFACE_MODEL_DIR")
|
|
84
|
+
if env:
|
|
85
|
+
yield Path(env)
|
|
86
|
+
yield cache_dir()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def find_local(spec: ModelSpec, model_dir: Path | str | None = None) -> Path | None:
|
|
90
|
+
"""First existing file matching any of the spec's names, or None."""
|
|
91
|
+
for directory in _search_paths(model_dir):
|
|
92
|
+
for name in spec.candidates:
|
|
93
|
+
candidate = directory / name
|
|
94
|
+
if candidate.is_file():
|
|
95
|
+
return candidate
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def sha256_of(path: Path | str) -> str:
|
|
100
|
+
"""Hex digest of a file, read in chunks so a 37 MB model is not slurped."""
|
|
101
|
+
digest = hashlib.sha256()
|
|
102
|
+
with open(path, "rb") as handle:
|
|
103
|
+
for chunk in iter(lambda: handle.read(_CHUNK), b""):
|
|
104
|
+
digest.update(chunk)
|
|
105
|
+
return digest.hexdigest()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def download(spec: ModelSpec, dest_dir: Path | str | None = None) -> Path:
|
|
109
|
+
"""Fetch a model, verify it, and place it atomically.
|
|
110
|
+
|
|
111
|
+
The download goes to a ``.part`` file in the destination directory and is
|
|
112
|
+
renamed only after the checksum passes, so an interrupted or corrupted
|
|
113
|
+
download can never be mistaken for a usable cache entry.
|
|
114
|
+
"""
|
|
115
|
+
directory = Path(dest_dir) if dest_dir is not None else cache_dir()
|
|
116
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
handle, raw_tmp = tempfile.mkstemp(dir=directory, suffix=".part")
|
|
118
|
+
os.close(handle)
|
|
119
|
+
tmp = Path(raw_tmp)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
with urllib.request.urlopen(spec.url, timeout=60) as response:
|
|
123
|
+
with open(tmp, "wb") as out:
|
|
124
|
+
shutil.copyfileobj(response, out, _CHUNK)
|
|
125
|
+
except (urllib.error.URLError, OSError, ValueError) as exc:
|
|
126
|
+
tmp.unlink(missing_ok=True)
|
|
127
|
+
raise ModelDownloadError(f"could not download {spec.filename} from {spec.url}: {exc}") from exc
|
|
128
|
+
|
|
129
|
+
actual = sha256_of(tmp)
|
|
130
|
+
if actual != spec.sha256:
|
|
131
|
+
tmp.unlink(missing_ok=True)
|
|
132
|
+
raise ModelDownloadError(f"checksum mismatch for {spec.filename}: expected {spec.sha256}, got {actual}")
|
|
133
|
+
|
|
134
|
+
destination = directory / spec.filename
|
|
135
|
+
tmp.replace(destination)
|
|
136
|
+
return destination
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def resolve(
|
|
140
|
+
spec: ModelSpec,
|
|
141
|
+
model_dir: Path | str | None = None,
|
|
142
|
+
allow_download: bool = True,
|
|
143
|
+
) -> Path:
|
|
144
|
+
"""Path to usable weights, downloading them if that is permitted."""
|
|
145
|
+
local = find_local(spec, model_dir)
|
|
146
|
+
if local is not None:
|
|
147
|
+
return local
|
|
148
|
+
if not allow_download:
|
|
149
|
+
raise ModelNotFoundError(
|
|
150
|
+
f"{spec.filename} was not found in any of "
|
|
151
|
+
f"{[str(p) for p in _search_paths(model_dir)]}, "
|
|
152
|
+
"and downloading was disabled. Set ALCHEMYFACE_MODEL_DIR, pass "
|
|
153
|
+
"model_dir=, or run `alchemyface download-models`."
|
|
154
|
+
)
|
|
155
|
+
return download(spec)
|
alchemyface/pipeline.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""The facade that sequences detection, embedding and storage.
|
|
2
|
+
|
|
3
|
+
Recognizer deliberately contains no algorithm. It decides *order* and applies
|
|
4
|
+
the *threshold*; everything else is delegated to whichever Detector, Embedder
|
|
5
|
+
and FaceStore it was handed. That is what makes a pgvector gallery or a
|
|
6
|
+
different embedding model a drop-in rather than a rewrite.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Mapping
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from numpy.typing import NDArray
|
|
16
|
+
|
|
17
|
+
from alchemyface.detection.base import Detector
|
|
18
|
+
from alchemyface.embedding.base import Embedder
|
|
19
|
+
from alchemyface.errors import NoFaceDetectedError
|
|
20
|
+
from alchemyface.store.base import FaceStore
|
|
21
|
+
from alchemyface.types import Face, Recognition
|
|
22
|
+
|
|
23
|
+
DEFAULT_THRESHOLD = 0.363
|
|
24
|
+
"""SFace's published cosine operating point. A tunable, not a constant:
|
|
25
|
+
validate it against your own data before relying on it."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Recognizer:
|
|
29
|
+
"""Detect, embed, enroll and identify faces."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
detector: Detector | None = None,
|
|
35
|
+
embedder: Embedder | None = None,
|
|
36
|
+
store: FaceStore | None = None,
|
|
37
|
+
model_dir: Path | str | None = None,
|
|
38
|
+
threshold: float = DEFAULT_THRESHOLD,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Any component left as ``None`` gets the default implementation.
|
|
41
|
+
|
|
42
|
+
Constructing the defaults loads the ONNX weights, downloading them on
|
|
43
|
+
first use. Pass all three components to build a Recognizer that touches
|
|
44
|
+
neither disk nor network.
|
|
45
|
+
"""
|
|
46
|
+
# Imported here rather than at module scope so that injecting fakes
|
|
47
|
+
# never pays the cost of importing cv2-backed modules.
|
|
48
|
+
# pylint: disable=import-outside-toplevel
|
|
49
|
+
from alchemyface.detection.yunet import YuNetDetector
|
|
50
|
+
from alchemyface.embedding.sface import SFaceEmbedder
|
|
51
|
+
from alchemyface.store.memory import InMemoryStore
|
|
52
|
+
|
|
53
|
+
self.detector: Detector = detector if detector is not None else YuNetDetector(model_dir=model_dir)
|
|
54
|
+
self.embedder: Embedder = embedder if embedder is not None else SFaceEmbedder(model_dir=model_dir)
|
|
55
|
+
self.store: FaceStore = store if store is not None else InMemoryStore(dim=self.embedder.dim)
|
|
56
|
+
self.threshold = threshold
|
|
57
|
+
|
|
58
|
+
def detect(self, image: NDArray[np.uint8]) -> list[Face]:
|
|
59
|
+
"""Every face in the image."""
|
|
60
|
+
return self.detector.detect(image)
|
|
61
|
+
|
|
62
|
+
def embed(self, image: NDArray[np.uint8], face: Face) -> NDArray[np.float32]:
|
|
63
|
+
"""The unit-length embedding of one already-detected face."""
|
|
64
|
+
return self.embedder.embed(image, face)
|
|
65
|
+
|
|
66
|
+
def enroll(
|
|
67
|
+
self,
|
|
68
|
+
label: str,
|
|
69
|
+
image: NDArray[np.uint8],
|
|
70
|
+
metadata: Mapping[str, Any] | None = None,
|
|
71
|
+
) -> str:
|
|
72
|
+
"""Add the most prominent face in the image to the gallery.
|
|
73
|
+
|
|
74
|
+
Raises :class:`NoFaceDetectedError` if there is no face. Enrolling the
|
|
75
|
+
largest face is a deliberate choice: an enrolment photo with a
|
|
76
|
+
bystander in the background should not silently enrol the bystander.
|
|
77
|
+
"""
|
|
78
|
+
faces = self.detect(image)
|
|
79
|
+
if not faces:
|
|
80
|
+
raise NoFaceDetectedError(f"no face found in the image supplied for {label!r}")
|
|
81
|
+
face = max(faces, key=lambda candidate: candidate.area)
|
|
82
|
+
return self.store.add(label, self.embed(image, face), metadata)
|
|
83
|
+
|
|
84
|
+
def identify(self, image: NDArray[np.uint8]) -> list[Recognition]:
|
|
85
|
+
"""One :class:`Recognition` per detected face.
|
|
86
|
+
|
|
87
|
+
``Recognition.match`` is ``None`` when the best candidate falls below
|
|
88
|
+
the threshold, or when the gallery is empty — the caller decides what
|
|
89
|
+
"unknown" should mean rather than the library guessing a label.
|
|
90
|
+
"""
|
|
91
|
+
recognitions: list[Recognition] = []
|
|
92
|
+
for face in self.detect(image):
|
|
93
|
+
candidates = self.store.search(self.embed(image, face), k=1)
|
|
94
|
+
best = candidates[0] if candidates else None
|
|
95
|
+
if best is not None and best.score < self.threshold:
|
|
96
|
+
best = None
|
|
97
|
+
recognitions.append(Recognition(face=face, match=best))
|
|
98
|
+
return recognitions
|
alchemyface/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""The gallery seam.
|
|
2
|
+
|
|
3
|
+
A store owns enrolled embeddings and answers nearest-neighbour queries. It is
|
|
4
|
+
a Protocol rather than a base class so a pgvector or SQLite implementation can
|
|
5
|
+
be added later without inheriting from anything in this package.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Mapping, Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from numpy.typing import NDArray
|
|
14
|
+
|
|
15
|
+
from alchemyface.types import Match
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class FaceStore(Protocol):
|
|
20
|
+
"""Holds labelled embeddings and finds the closest ones."""
|
|
21
|
+
|
|
22
|
+
def add(
|
|
23
|
+
self,
|
|
24
|
+
label: str,
|
|
25
|
+
vector: NDArray[np.float32],
|
|
26
|
+
metadata: Mapping[str, Any] | None = None,
|
|
27
|
+
) -> str:
|
|
28
|
+
"""Store an embedding under a label. Returns its opaque entry id."""
|
|
29
|
+
|
|
30
|
+
def search(self, vector: NDArray[np.float32], k: int = 1) -> list[Match]:
|
|
31
|
+
"""The ``k`` most similar entries, best first. Empty if the store is."""
|
|
32
|
+
|
|
33
|
+
def remove(self, entry_id: str) -> None:
|
|
34
|
+
"""Delete one entry. Raises ``KeyError`` if it is not there."""
|
|
35
|
+
|
|
36
|
+
def __len__(self) -> int:
|
|
37
|
+
"""How many entries are stored."""
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""A gallery held in a single numpy matrix.
|
|
2
|
+
|
|
3
|
+
Every vector is stored L2-normalised, which makes cosine similarity a matrix
|
|
4
|
+
product: ``vectors @ query``. That is why scikit-learn is not a dependency.
|
|
5
|
+
Brute force over a few thousand faces is well under a millisecond, and it
|
|
6
|
+
keeps the default install free of any database.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Mapping
|
|
15
|
+
from uuid import uuid4
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
from numpy.typing import NDArray
|
|
19
|
+
|
|
20
|
+
from alchemyface.types import Match
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class _Entry:
|
|
25
|
+
entry_id: str
|
|
26
|
+
label: str
|
|
27
|
+
metadata: dict[str, Any]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _as_unit_row(vector: NDArray[np.float32], dim: int) -> NDArray[np.float32]:
|
|
31
|
+
"""Validate, flatten and normalise. Accepts ``(dim,)`` or ``(1, dim)``."""
|
|
32
|
+
flat = np.asarray(vector, dtype=np.float32).ravel()
|
|
33
|
+
if flat.shape != (dim,):
|
|
34
|
+
raise ValueError(f"expected a vector of dimension {dim}, got shape {np.shape(vector)}")
|
|
35
|
+
norm = float(np.linalg.norm(flat))
|
|
36
|
+
if norm == 0.0:
|
|
37
|
+
raise ValueError("cannot store a zero vector: it has no direction")
|
|
38
|
+
return (flat / norm).astype(np.float32)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class InMemoryStore:
|
|
42
|
+
"""Implements :class:`~alchemyface.store.base.FaceStore` with numpy."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, dim: int = 128) -> None:
|
|
45
|
+
self._dim = dim
|
|
46
|
+
self._vectors: NDArray[np.float32] = np.empty((0, dim), dtype=np.float32)
|
|
47
|
+
self._entries: list[_Entry] = []
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def dim(self) -> int:
|
|
51
|
+
"""Length of the vectors this gallery holds."""
|
|
52
|
+
return self._dim
|
|
53
|
+
|
|
54
|
+
def __len__(self) -> int:
|
|
55
|
+
"""How many entries are stored."""
|
|
56
|
+
return len(self._entries)
|
|
57
|
+
|
|
58
|
+
def add(
|
|
59
|
+
self,
|
|
60
|
+
label: str,
|
|
61
|
+
vector: NDArray[np.float32],
|
|
62
|
+
metadata: Mapping[str, Any] | None = None,
|
|
63
|
+
) -> str:
|
|
64
|
+
"""Store an embedding under a label. Returns its opaque entry id."""
|
|
65
|
+
row = _as_unit_row(vector, self._dim)
|
|
66
|
+
entry = _Entry(uuid4().hex, label, dict(metadata or {}))
|
|
67
|
+
self._vectors = np.vstack([self._vectors, row])
|
|
68
|
+
self._entries.append(entry)
|
|
69
|
+
return entry.entry_id
|
|
70
|
+
|
|
71
|
+
def search(self, vector: NDArray[np.float32], k: int = 1) -> list[Match]:
|
|
72
|
+
"""The ``k`` most similar entries, best first."""
|
|
73
|
+
if not self._entries:
|
|
74
|
+
return []
|
|
75
|
+
query = _as_unit_row(vector, self._dim)
|
|
76
|
+
scores = self._vectors @ query
|
|
77
|
+
take = min(max(k, 1), len(self._entries))
|
|
78
|
+
# argpartition finds the top-k in O(n); only those k are then sorted.
|
|
79
|
+
top = np.argpartition(-scores, take - 1)[:take]
|
|
80
|
+
top = top[np.argsort(-scores[top])]
|
|
81
|
+
return [
|
|
82
|
+
Match(
|
|
83
|
+
label=self._entries[i].label,
|
|
84
|
+
score=float(scores[i]),
|
|
85
|
+
entry_id=self._entries[i].entry_id,
|
|
86
|
+
metadata=dict(self._entries[i].metadata),
|
|
87
|
+
)
|
|
88
|
+
for i in top
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
def remove(self, entry_id: str) -> None:
|
|
92
|
+
"""Delete one entry. Raises ``KeyError`` if it is not there."""
|
|
93
|
+
for index, entry in enumerate(self._entries):
|
|
94
|
+
if entry.entry_id == entry_id:
|
|
95
|
+
del self._entries[index]
|
|
96
|
+
self._vectors = np.delete(self._vectors, index, axis=0)
|
|
97
|
+
return
|
|
98
|
+
raise KeyError(entry_id)
|
|
99
|
+
|
|
100
|
+
def save(self, path: Path | str) -> None:
|
|
101
|
+
"""Write the gallery to a ``.npz`` file.
|
|
102
|
+
|
|
103
|
+
Metadata goes in as one JSON blob rather than an object array, so the
|
|
104
|
+
file loads without ``allow_pickle`` and cannot execute anything.
|
|
105
|
+
"""
|
|
106
|
+
np.savez(
|
|
107
|
+
path,
|
|
108
|
+
vectors=self._vectors,
|
|
109
|
+
labels=np.array([e.label for e in self._entries], dtype="U"),
|
|
110
|
+
entry_ids=np.array([e.entry_id for e in self._entries], dtype="U"),
|
|
111
|
+
metadata=np.array(json.dumps([e.metadata for e in self._entries])),
|
|
112
|
+
dim=np.array(self._dim),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def load(self, path: Path | str) -> None:
|
|
116
|
+
"""Replace the gallery with the contents of a ``.npz`` file."""
|
|
117
|
+
with np.load(path, allow_pickle=False) as data:
|
|
118
|
+
dim = int(data["dim"])
|
|
119
|
+
if dim != self._dim:
|
|
120
|
+
raise ValueError(f"gallery dimension {dim} does not match store dimension {self._dim}")
|
|
121
|
+
vectors = np.asarray(data["vectors"], dtype=np.float32)
|
|
122
|
+
labels: list[str] = np.asarray(data["labels"]).tolist()
|
|
123
|
+
entry_ids: list[str] = np.asarray(data["entry_ids"]).tolist()
|
|
124
|
+
metadata = json.loads(str(data["metadata"]))
|
|
125
|
+
|
|
126
|
+
self._vectors = vectors.reshape(-1, self._dim)
|
|
127
|
+
self._entries = [
|
|
128
|
+
_Entry(entry_id, label, dict(meta)) for entry_id, label, meta in zip(entry_ids, labels, metadata)
|
|
129
|
+
]
|
alchemyface/types.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Value types shared across AlchemyFace. NumPy is the only import."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Mapping
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from numpy.typing import NDArray
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, eq=False)
|
|
13
|
+
class Face:
|
|
14
|
+
"""A detected face: where it is, how to align it, how sure the detector was.
|
|
15
|
+
|
|
16
|
+
``eq=False`` is deliberate. ``landmarks`` is an ndarray, and a generated
|
|
17
|
+
``__eq__`` would compare element-wise and return an array, so any ``==``
|
|
18
|
+
between two Faces would raise "truth value of an array is ambiguous".
|
|
19
|
+
Identity comparison is the useful default here.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
bbox: tuple[int, int, int, int]
|
|
23
|
+
"""Pixel bounding box as ``(x, y, width, height)``, clamped to the image."""
|
|
24
|
+
|
|
25
|
+
landmarks: NDArray[np.float32]
|
|
26
|
+
"""``(5, 2)`` array: right eye, left eye, nose tip, right and left mouth corner."""
|
|
27
|
+
|
|
28
|
+
confidence: float
|
|
29
|
+
"""Detector score. Not a recognition score — see :class:`Match`."""
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def area(self) -> int:
|
|
33
|
+
"""Pixel area, used to pick the most prominent face in a frame."""
|
|
34
|
+
return self.bbox[2] * self.bbox[3]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Match:
|
|
39
|
+
"""A gallery entry that resembles a query embedding."""
|
|
40
|
+
|
|
41
|
+
label: str
|
|
42
|
+
|
|
43
|
+
score: float
|
|
44
|
+
"""Cosine similarity in ``[-1, 1]``. Higher is more alike."""
|
|
45
|
+
|
|
46
|
+
entry_id: str
|
|
47
|
+
"""Opaque id of the stored entry, as returned by ``FaceStore.add``."""
|
|
48
|
+
|
|
49
|
+
metadata: Mapping[str, Any]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, eq=False)
|
|
53
|
+
class Recognition:
|
|
54
|
+
"""One detected face and the best gallery entry for it, if any cleared
|
|
55
|
+
the threshold. ``match is None`` means unknown — the library does not
|
|
56
|
+
invent a label for it."""
|
|
57
|
+
|
|
58
|
+
face: Face
|
|
59
|
+
match: Match | None
|
|
@@ -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,23 @@
|
|
|
1
|
+
alchemyface/__init__.py,sha256=wR_u7sUmoAJK8IY42bh5WoLGIOJkFMgm56HiF_a_rLQ,893
|
|
2
|
+
alchemyface/capture.py,sha256=dpVgoTjGhQVAvZCtepAw807N_C97ZsbdtA_xasaam7c,2073
|
|
3
|
+
alchemyface/cli.py,sha256=em0fXJL1aBpdIqkMrla96R8E23iIdpPjNmcDgxs4dCE,5127
|
|
4
|
+
alchemyface/errors.py,sha256=G4y_WkSe1s5QylUW9-jcWlqNA4gYIM5P8SIwjfYGgsY,672
|
|
5
|
+
alchemyface/models.py,sha256=XFu5DQAuiU8vkYS3gYQ_r0-pzOjuMuuFghCjmwBi7HQ,5346
|
|
6
|
+
alchemyface/pipeline.py,sha256=MiAlOcrl8W5lGdXCcLvYdXWX9k9-t3t_7JOSE3vvI0w,4113
|
|
7
|
+
alchemyface/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
alchemyface/types.py,sha256=_Wj347H25n_6vTx0Sf8c-5MUlx83Z2uUY-8Ic6BV_9M,1746
|
|
9
|
+
alchemyface/detection/__init__.py,sha256=UfTU-TFcFvLLovivIdo8tunqKScgLu2Z0oTwPxC5S4o,277
|
|
10
|
+
alchemyface/detection/base.py,sha256=nO23sz_FLedoqM1lHvtuQ7HF1CqEI01dOv5B1XTCn8c,428
|
|
11
|
+
alchemyface/detection/yunet.py,sha256=YFpAxJRgTNRKyBjbZZBaqa9F0sRifEWLYWVfdzBT6RA,3180
|
|
12
|
+
alchemyface/embedding/__init__.py,sha256=hp6-DTTk3OeB-i_PpX9eJGtalbdJvjaQDciwnGhySqo,213
|
|
13
|
+
alchemyface/embedding/base.py,sha256=886cl7KnfCW96UUTgCtpAAV0XxBTfwHxEqlWrD8Ey2I,726
|
|
14
|
+
alchemyface/embedding/sface.py,sha256=WGA_xenYtaD-dzO9ehX1QqNbwJXxMlF8NY0bsdWQaUs,1835
|
|
15
|
+
alchemyface/store/__init__.py,sha256=YSCJXHLtiWa4RkVaU7x8aMfBYANXe6y76Ayx5rcl8jg,209
|
|
16
|
+
alchemyface/store/base.py,sha256=044pDe86y6DWlfMeCh_slbQUjG2dLbzDc7vsIc7Jszg,1127
|
|
17
|
+
alchemyface/store/memory.py,sha256=ovQbyVSYcOE3KLQ1aonoWEABn4w50fifYsR-EQVZcDw,4782
|
|
18
|
+
alchemyface-0.1.0.dist-info/licenses/LICENSE,sha256=ubEdDj3_clTEY-8HCiV9OeF-q2jtElWDhIYdLWWdcD0,1069
|
|
19
|
+
alchemyface-0.1.0.dist-info/METADATA,sha256=je83lHMqNg39b7YFcbCrwdpskIJliShLMXJO9QaOg54,6672
|
|
20
|
+
alchemyface-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
21
|
+
alchemyface-0.1.0.dist-info/entry_points.txt,sha256=IQyiMwb2lqnPVY9QlL_QMhMPDdFSKD0hQA2uNBP8NEI,52
|
|
22
|
+
alchemyface-0.1.0.dist-info/top_level.txt,sha256=G8ftfh2cZe1n-U6avWo62EZf89dI5b1Z7bXmM0umc4A,12
|
|
23
|
+
alchemyface-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
alchemyface
|