facekey 0.0.1__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.
- facekey/__init__.py +71 -0
- facekey/__main__.py +4 -0
- facekey/_image.py +68 -0
- facekey/camera.py +117 -0
- facekey/cli.py +111 -0
- facekey/core.py +198 -0
- facekey/embedders/__init__.py +54 -0
- facekey/embedders/insightface.py +103 -0
- facekey/enrollment.py +135 -0
- facekey/errors.py +47 -0
- facekey/liveness.py +114 -0
- facekey/matching.py +66 -0
- facekey/models.py +84 -0
- facekey/pose.py +78 -0
- facekey/stores/__init__.py +64 -0
- facekey/stores/file.py +105 -0
- facekey/stores/sqlite.py +61 -0
- facekey-0.0.1.dist-info/METADATA +174 -0
- facekey-0.0.1.dist-info/RECORD +22 -0
- facekey-0.0.1.dist-info/WHEEL +4 -0
- facekey-0.0.1.dist-info/entry_points.txt +2 -0
- facekey-0.0.1.dist-info/licenses/LICENSE +36 -0
facekey/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""facekey - Face-ID-style unlock for your app.
|
|
2
|
+
|
|
3
|
+
See the README for the security caveats and model licensing. Quick start::
|
|
4
|
+
|
|
5
|
+
from facekey import FaceLock
|
|
6
|
+
|
|
7
|
+
lock = FaceLock()
|
|
8
|
+
lock.run_enrollment(camera=0, on_prompt=print)
|
|
9
|
+
if lock.unlock(camera=0, timeout=5):
|
|
10
|
+
...
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .core import FaceLock, __version__
|
|
16
|
+
from .embedders import Embedder, get_embedder, register
|
|
17
|
+
from .enrollment import EnrollmentSession
|
|
18
|
+
from .errors import (
|
|
19
|
+
EnrollmentIncomplete,
|
|
20
|
+
FaceKeyError,
|
|
21
|
+
FaceKeyLicenseWarning,
|
|
22
|
+
LivenessCheckFailed,
|
|
23
|
+
LowQualityFrame,
|
|
24
|
+
ModelDownloadError,
|
|
25
|
+
MultipleFacesDetected,
|
|
26
|
+
NoFaceDetected,
|
|
27
|
+
NotEnrolled,
|
|
28
|
+
StoreFull,
|
|
29
|
+
WeakLivenessWarning,
|
|
30
|
+
)
|
|
31
|
+
from .liveness import HeuristicLiveness, LivenessChecker, NullLiveness
|
|
32
|
+
from .matching import DEFAULT_THRESHOLD, cosine_similarity
|
|
33
|
+
from .models import AuthResult, CaptureResult, FaceObservation, FaceTemplate, PoseBucket
|
|
34
|
+
from .stores import FileStore, SQLiteStore, Store
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"__version__",
|
|
38
|
+
"FaceLock",
|
|
39
|
+
"EnrollmentSession",
|
|
40
|
+
"AuthResult",
|
|
41
|
+
"CaptureResult",
|
|
42
|
+
"FaceObservation",
|
|
43
|
+
"FaceTemplate",
|
|
44
|
+
"PoseBucket",
|
|
45
|
+
"DEFAULT_THRESHOLD",
|
|
46
|
+
"cosine_similarity",
|
|
47
|
+
# embedders
|
|
48
|
+
"Embedder",
|
|
49
|
+
"get_embedder",
|
|
50
|
+
"register",
|
|
51
|
+
# liveness
|
|
52
|
+
"LivenessChecker",
|
|
53
|
+
"HeuristicLiveness",
|
|
54
|
+
"NullLiveness",
|
|
55
|
+
# stores
|
|
56
|
+
"Store",
|
|
57
|
+
"FileStore",
|
|
58
|
+
"SQLiteStore",
|
|
59
|
+
# errors / warnings
|
|
60
|
+
"FaceKeyError",
|
|
61
|
+
"NoFaceDetected",
|
|
62
|
+
"MultipleFacesDetected",
|
|
63
|
+
"LowQualityFrame",
|
|
64
|
+
"LivenessCheckFailed",
|
|
65
|
+
"NotEnrolled",
|
|
66
|
+
"EnrollmentIncomplete",
|
|
67
|
+
"StoreFull",
|
|
68
|
+
"ModelDownloadError",
|
|
69
|
+
"FaceKeyLicenseWarning",
|
|
70
|
+
"WeakLivenessWarning",
|
|
71
|
+
]
|
facekey/__main__.py
ADDED
facekey/_image.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Frame coercion and quality gating shared by enrollment and auth."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Union
|
|
7
|
+
|
|
8
|
+
import cv2
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from .errors import LowQualityFrame
|
|
12
|
+
from .models import FaceObservation
|
|
13
|
+
|
|
14
|
+
Frame = Union[np.ndarray, bytes, bytearray, memoryview]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def to_bgr(frame: Frame) -> np.ndarray:
|
|
18
|
+
"""Coerce a frame to an ``H x W x 3`` uint8 BGR array.
|
|
19
|
+
|
|
20
|
+
Accepts a decoded ndarray (BGR or grayscale) or encoded image bytes
|
|
21
|
+
(PNG/JPEG/...). Raises :class:`ValueError` if it cannot be interpreted.
|
|
22
|
+
"""
|
|
23
|
+
if isinstance(frame, np.ndarray):
|
|
24
|
+
img = frame
|
|
25
|
+
if img.ndim == 2:
|
|
26
|
+
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
|
|
27
|
+
if img.ndim == 3 and img.shape[2] == 4:
|
|
28
|
+
return cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
|
|
29
|
+
if img.ndim == 3 and img.shape[2] == 3:
|
|
30
|
+
return np.ascontiguousarray(img)
|
|
31
|
+
raise ValueError(f"unsupported image array shape: {img.shape}")
|
|
32
|
+
|
|
33
|
+
if isinstance(frame, (bytes, bytearray, memoryview)):
|
|
34
|
+
buf = np.frombuffer(bytes(frame), dtype=np.uint8)
|
|
35
|
+
img = cv2.imdecode(buf, cv2.IMREAD_COLOR)
|
|
36
|
+
if img is None:
|
|
37
|
+
raise ValueError("could not decode image bytes")
|
|
38
|
+
return img
|
|
39
|
+
|
|
40
|
+
raise ValueError(f"unsupported frame type: {type(frame)!r}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class QualityPolicy:
|
|
45
|
+
"""Thresholds a detected face must clear to be usable."""
|
|
46
|
+
|
|
47
|
+
min_face_fraction: float = 0.06 # face bbox width / frame width
|
|
48
|
+
min_det_score: float = 0.60
|
|
49
|
+
min_sharpness: float = 40.0 # variance of Laplacian over the face crop
|
|
50
|
+
|
|
51
|
+
def check(self, bgr: np.ndarray, obs: FaceObservation) -> None:
|
|
52
|
+
"""Raise :class:`LowQualityFrame` if ``obs`` is not good enough."""
|
|
53
|
+
frame_w = bgr.shape[1]
|
|
54
|
+
x1, y1, x2, y2 = (int(round(v)) for v in obs.bbox)
|
|
55
|
+
face_w = max(1, x2 - x1)
|
|
56
|
+
|
|
57
|
+
if face_w / frame_w < self.min_face_fraction:
|
|
58
|
+
raise LowQualityFrame("face too small in frame; move closer")
|
|
59
|
+
|
|
60
|
+
if obs.det_score < self.min_det_score:
|
|
61
|
+
raise LowQualityFrame(f"weak detection ({obs.det_score:.2f})")
|
|
62
|
+
|
|
63
|
+
crop = bgr[max(0, y1):max(1, y2), max(0, x1):max(1, x2)]
|
|
64
|
+
if crop.size:
|
|
65
|
+
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
|
|
66
|
+
sharpness = float(cv2.Laplacian(gray, cv2.CV_64F).var())
|
|
67
|
+
if sharpness < self.min_sharpness:
|
|
68
|
+
raise LowQualityFrame(f"frame too blurry (sharpness {sharpness:.0f})")
|
facekey/camera.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Optional webcam helpers built on the frame-based API.
|
|
2
|
+
|
|
3
|
+
Nothing else in facekey imports this module; it is only pulled in when you call
|
|
4
|
+
``FaceLock.unlock()`` / ``FaceLock.run_enrollment()`` or use :class:`Camera`
|
|
5
|
+
directly. ``cv2.VideoCapture`` is available in the base install
|
|
6
|
+
(opencv-python-headless); ``pip install "facekey[camera]"`` additionally brings
|
|
7
|
+
the highgui build if you want to show a preview window yourself.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from typing import Callable, Optional
|
|
14
|
+
|
|
15
|
+
import cv2
|
|
16
|
+
|
|
17
|
+
from .errors import FaceKeyError
|
|
18
|
+
from .models import FaceTemplate, PoseBucket
|
|
19
|
+
|
|
20
|
+
PROMPTS = {
|
|
21
|
+
PoseBucket.FRONTAL: "Look straight at the camera",
|
|
22
|
+
PoseBucket.LEFT: "Turn your head to your left",
|
|
23
|
+
PoseBucket.RIGHT: "Turn your head to your right",
|
|
24
|
+
PoseBucket.UP: "Tilt your head up",
|
|
25
|
+
PoseBucket.DOWN: "Tilt your head down",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Camera:
|
|
30
|
+
"""Context manager around a single ``cv2.VideoCapture`` device."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, index: int = 0) -> None:
|
|
33
|
+
self.index = index
|
|
34
|
+
self._cap: "Optional[cv2.VideoCapture]" = None
|
|
35
|
+
|
|
36
|
+
def __enter__(self) -> "Camera":
|
|
37
|
+
self._cap = cv2.VideoCapture(self.index)
|
|
38
|
+
if not self._cap or not self._cap.isOpened():
|
|
39
|
+
raise FaceKeyError(f"could not open camera {self.index}")
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def __exit__(self, *exc) -> None:
|
|
43
|
+
if self._cap is not None:
|
|
44
|
+
self._cap.release()
|
|
45
|
+
self._cap = None
|
|
46
|
+
|
|
47
|
+
def read(self):
|
|
48
|
+
assert self._cap is not None, "use Camera as a context manager"
|
|
49
|
+
ok, frame = self._cap.read()
|
|
50
|
+
if not ok:
|
|
51
|
+
raise FaceKeyError("failed to read a frame from the camera")
|
|
52
|
+
return frame
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def capture_until_match(
|
|
56
|
+
lock,
|
|
57
|
+
*,
|
|
58
|
+
camera: int = 0,
|
|
59
|
+
timeout: float = 5.0,
|
|
60
|
+
enrollee: "Optional[str]" = None,
|
|
61
|
+
interval: float = 0.15,
|
|
62
|
+
) -> bool:
|
|
63
|
+
"""Poll the camera until :meth:`FaceLock.authenticate` grants or time runs out."""
|
|
64
|
+
deadline = time.monotonic() + timeout
|
|
65
|
+
with Camera(camera) as cam:
|
|
66
|
+
while time.monotonic() < deadline:
|
|
67
|
+
try:
|
|
68
|
+
if lock.authenticate(cam.read(), enrollee=enrollee).granted:
|
|
69
|
+
return True
|
|
70
|
+
except FaceKeyError:
|
|
71
|
+
pass # no face / multiple faces this frame; keep trying
|
|
72
|
+
time.sleep(interval)
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def guided_enrollment(
|
|
77
|
+
lock,
|
|
78
|
+
*,
|
|
79
|
+
camera: int = 0,
|
|
80
|
+
label: str = "default",
|
|
81
|
+
on_prompt: "Optional[Callable[[str], None]]" = None,
|
|
82
|
+
attempts_per_pose: int = 60,
|
|
83
|
+
interval: float = 0.1,
|
|
84
|
+
) -> FaceTemplate:
|
|
85
|
+
"""Walk the user through every required pose, then commit and store.
|
|
86
|
+
|
|
87
|
+
``on_prompt`` receives short instruction strings ("Turn your head to your
|
|
88
|
+
left", ...) so you can render them however you like.
|
|
89
|
+
"""
|
|
90
|
+
say = on_prompt or (lambda _msg: None)
|
|
91
|
+
session = lock.enroll(label)
|
|
92
|
+
|
|
93
|
+
with Camera(camera) as cam:
|
|
94
|
+
last_prompt = None
|
|
95
|
+
while session.needs:
|
|
96
|
+
target = session.needs[0]
|
|
97
|
+
bucket = PoseBucket(target)
|
|
98
|
+
prompt = PROMPTS.get(bucket, f"Show pose: {target}")
|
|
99
|
+
if prompt != last_prompt:
|
|
100
|
+
say(prompt)
|
|
101
|
+
last_prompt = prompt
|
|
102
|
+
|
|
103
|
+
got_it = False
|
|
104
|
+
for _ in range(attempts_per_pose):
|
|
105
|
+
result = session.capture(cam.read())
|
|
106
|
+
if result.accepted and result.bucket == bucket:
|
|
107
|
+
say(f"Got it ({target}).")
|
|
108
|
+
got_it = True
|
|
109
|
+
break
|
|
110
|
+
time.sleep(interval)
|
|
111
|
+
if not got_it:
|
|
112
|
+
say(f"Still need {target} - trying again.")
|
|
113
|
+
|
|
114
|
+
template = session.commit()
|
|
115
|
+
lock.store.put(template)
|
|
116
|
+
say("Enrollment complete.")
|
|
117
|
+
return template
|
facekey/cli.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""``facekey`` command-line interface.
|
|
2
|
+
|
|
3
|
+
facekey download-models pre-fetch the embedder model pack
|
|
4
|
+
facekey enroll [--label L] guided enrollment via the webcam
|
|
5
|
+
facekey test try one unlock and print the result
|
|
6
|
+
facekey list show enrolled labels
|
|
7
|
+
facekey remove --label L delete a template
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _add_common(p: argparse.ArgumentParser) -> None:
|
|
17
|
+
p.add_argument("--store", default=None, help="path to the template store (default: user data dir)")
|
|
18
|
+
p.add_argument("--camera", type=int, default=0, help="camera index (default: 0)")
|
|
19
|
+
p.add_argument("--no-liveness", action="store_true", help="disable the passive liveness check")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _build_lock(args):
|
|
23
|
+
from .core import FaceLock
|
|
24
|
+
|
|
25
|
+
return FaceLock(store=args.store, liveness=not args.no_liveness)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def cmd_download_models(args) -> int:
|
|
29
|
+
from .embedders import get_embedder
|
|
30
|
+
|
|
31
|
+
emb = get_embedder("buffalo_l" if not getattr(args, "model", None) else args.model)
|
|
32
|
+
print(f"Preparing embedder {emb.name!r} (first run downloads ~300 MB)...")
|
|
33
|
+
emb.detect(_black_frame())
|
|
34
|
+
print("Model pack ready.")
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cmd_enroll(args) -> int:
|
|
39
|
+
lock = _build_lock(args)
|
|
40
|
+
print(f"Enrolling {args.label!r}. Follow the prompts.")
|
|
41
|
+
lock.run_enrollment(camera=args.camera, label=args.label, on_prompt=print)
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def cmd_test(args) -> int:
|
|
46
|
+
lock = _build_lock(args)
|
|
47
|
+
if not lock.is_enrolled():
|
|
48
|
+
print("Nothing enrolled yet. Run `facekey enroll` first.", file=sys.stderr)
|
|
49
|
+
return 2
|
|
50
|
+
granted = lock.unlock(camera=args.camera, timeout=args.timeout)
|
|
51
|
+
print("ACCESS GRANTED" if granted else "ACCESS DENIED")
|
|
52
|
+
return 0 if granted else 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cmd_list(args) -> int:
|
|
56
|
+
lock = _build_lock(args)
|
|
57
|
+
labels = lock.enrolled()
|
|
58
|
+
print("\n".join(labels) if labels else "(nothing enrolled)")
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def cmd_remove(args) -> int:
|
|
63
|
+
lock = _build_lock(args)
|
|
64
|
+
print("removed" if lock.remove(args.label) else "no such label")
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _black_frame():
|
|
69
|
+
import numpy as np
|
|
70
|
+
|
|
71
|
+
return np.zeros((320, 320, 3), dtype=np.uint8)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
75
|
+
parser = argparse.ArgumentParser(prog="facekey", description=__doc__,
|
|
76
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
77
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
78
|
+
|
|
79
|
+
p = sub.add_parser("download-models", help="pre-fetch the embedder model pack")
|
|
80
|
+
p.add_argument("--model", default="buffalo_l")
|
|
81
|
+
p.set_defaults(func=cmd_download_models)
|
|
82
|
+
|
|
83
|
+
p = sub.add_parser("enroll", help="guided webcam enrollment")
|
|
84
|
+
_add_common(p)
|
|
85
|
+
p.add_argument("--label", default="default")
|
|
86
|
+
p.set_defaults(func=cmd_enroll)
|
|
87
|
+
|
|
88
|
+
p = sub.add_parser("test", help="attempt one unlock")
|
|
89
|
+
_add_common(p)
|
|
90
|
+
p.add_argument("--timeout", type=float, default=5.0)
|
|
91
|
+
p.set_defaults(func=cmd_test)
|
|
92
|
+
|
|
93
|
+
p = sub.add_parser("list", help="show enrolled labels")
|
|
94
|
+
_add_common(p)
|
|
95
|
+
p.set_defaults(func=cmd_list)
|
|
96
|
+
|
|
97
|
+
p = sub.add_parser("remove", help="delete a template")
|
|
98
|
+
_add_common(p)
|
|
99
|
+
p.add_argument("--label", required=True)
|
|
100
|
+
p.set_defaults(func=cmd_remove)
|
|
101
|
+
|
|
102
|
+
return parser
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main(argv: "list[str] | None" = None) -> int:
|
|
106
|
+
args = build_parser().parse_args(argv)
|
|
107
|
+
return args.func(args)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
raise SystemExit(main())
|
facekey/core.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""``FaceLock`` — the top-level entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from ._image import Frame, QualityPolicy, to_bgr
|
|
9
|
+
from .embedders import Embedder, get_embedder
|
|
10
|
+
from .enrollment import EnrollmentSession
|
|
11
|
+
from .errors import (
|
|
12
|
+
MultipleFacesDetected,
|
|
13
|
+
NoFaceDetected,
|
|
14
|
+
NotEnrolled,
|
|
15
|
+
StoreFull,
|
|
16
|
+
)
|
|
17
|
+
from .liveness import DEFAULT_LIVENESS_THRESHOLD, LivenessChecker, make_liveness
|
|
18
|
+
from .matching import DEFAULT_THRESHOLD, decide, identify, match_template
|
|
19
|
+
from .models import AuthResult, FaceObservation
|
|
20
|
+
from .stores import FileStore, Store
|
|
21
|
+
|
|
22
|
+
__version__ = "0.0.1"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FaceLock:
|
|
26
|
+
"""Enroll a face, then authenticate camera frames against it.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
store:
|
|
31
|
+
``None`` for a JSON store under the per-user data dir, a path for a
|
|
32
|
+
:class:`~facekey.stores.FileStore` there, or any :class:`Store`.
|
|
33
|
+
threshold:
|
|
34
|
+
Cosine-similarity cut-off for a match (higher = stricter).
|
|
35
|
+
liveness:
|
|
36
|
+
``True`` (default) runs passive anti-spoofing; ``False`` skips it.
|
|
37
|
+
model:
|
|
38
|
+
Embedder name passed to :func:`~facekey.embedders.get_embedder` unless
|
|
39
|
+
an explicit ``embedder`` is given.
|
|
40
|
+
max_enrollees:
|
|
41
|
+
Upper bound on stored templates. ``1`` today; raise for a shared device.
|
|
42
|
+
gpu:
|
|
43
|
+
``True`` or a device index to run the embedder on GPU.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
store: "Store | str | Path | None" = None,
|
|
49
|
+
*,
|
|
50
|
+
threshold: float = DEFAULT_THRESHOLD,
|
|
51
|
+
liveness: bool = True,
|
|
52
|
+
model: str = "buffalo_l",
|
|
53
|
+
max_enrollees: int = 1,
|
|
54
|
+
gpu: "bool | int" = False,
|
|
55
|
+
embedder: "Optional[Embedder]" = None,
|
|
56
|
+
liveness_checker: "Optional[LivenessChecker]" = None,
|
|
57
|
+
liveness_threshold: float = DEFAULT_LIVENESS_THRESHOLD,
|
|
58
|
+
quality: "Optional[QualityPolicy]" = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
if isinstance(store, (str, Path)):
|
|
61
|
+
self.store: Store = FileStore(store)
|
|
62
|
+
elif store is None:
|
|
63
|
+
self.store = FileStore()
|
|
64
|
+
else:
|
|
65
|
+
self.store = store
|
|
66
|
+
|
|
67
|
+
self.threshold = threshold
|
|
68
|
+
self.max_enrollees = max_enrollees
|
|
69
|
+
self.quality = quality or QualityPolicy()
|
|
70
|
+
self.liveness_threshold = liveness_threshold
|
|
71
|
+
|
|
72
|
+
self._embedder = embedder or get_embedder(model, gpu=gpu)
|
|
73
|
+
self._liveness = liveness_checker or make_liveness(liveness, threshold=liveness_threshold)
|
|
74
|
+
|
|
75
|
+
# -- enrollment ------------------------------------------------------
|
|
76
|
+
def enroll(self, label: str = "default") -> EnrollmentSession:
|
|
77
|
+
existing = set(self.store.list())
|
|
78
|
+
if label not in existing and len(existing) >= self.max_enrollees:
|
|
79
|
+
raise StoreFull(
|
|
80
|
+
f"max_enrollees={self.max_enrollees} reached ({sorted(existing)})"
|
|
81
|
+
)
|
|
82
|
+
return EnrollmentSession(
|
|
83
|
+
label=label,
|
|
84
|
+
embedder=self._embedder,
|
|
85
|
+
liveness=self._liveness,
|
|
86
|
+
model_name=self._embedder.name,
|
|
87
|
+
quality=self.quality,
|
|
88
|
+
liveness_threshold=self.liveness_threshold,
|
|
89
|
+
version=__version__,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def enrolled(self) -> "list[str]":
|
|
93
|
+
return self.store.list()
|
|
94
|
+
|
|
95
|
+
def is_enrolled(self, label: "Optional[str]" = None) -> bool:
|
|
96
|
+
labels = self.store.list()
|
|
97
|
+
return bool(labels) if label is None else label in labels
|
|
98
|
+
|
|
99
|
+
def remove(self, label: str = "default") -> bool:
|
|
100
|
+
return self.store.delete(label)
|
|
101
|
+
|
|
102
|
+
def commit(self, session: EnrollmentSession, *, allow_partial: bool = False) -> None:
|
|
103
|
+
"""Persist a completed :class:`EnrollmentSession`. ``session.commit()``
|
|
104
|
+
builds the template; this stores it and enforces ``max_enrollees``."""
|
|
105
|
+
template = session.commit(allow_partial=allow_partial)
|
|
106
|
+
existing = set(self.store.list())
|
|
107
|
+
if template.label not in existing and len(existing) >= self.max_enrollees:
|
|
108
|
+
raise StoreFull(f"max_enrollees={self.max_enrollees} reached")
|
|
109
|
+
self.store.put(template)
|
|
110
|
+
|
|
111
|
+
# -- authentication -----------------------------------------------
|
|
112
|
+
def authenticate(self, frame: Frame, enrollee: "Optional[str]" = None) -> AuthResult:
|
|
113
|
+
"""1:1 verification. If ``enrollee`` is omitted, the sole enrolled label
|
|
114
|
+
is used (raising :class:`NotEnrolled` when there is not exactly one)."""
|
|
115
|
+
labels = self.store.list()
|
|
116
|
+
if not labels:
|
|
117
|
+
raise NotEnrolled("no faces enrolled")
|
|
118
|
+
if enrollee is None:
|
|
119
|
+
if len(labels) != 1:
|
|
120
|
+
raise NotEnrolled(
|
|
121
|
+
f"{len(labels)} faces enrolled; pass enrollee= to pick one"
|
|
122
|
+
)
|
|
123
|
+
enrollee = labels[0]
|
|
124
|
+
|
|
125
|
+
template = self.store.get(enrollee)
|
|
126
|
+
if template is None:
|
|
127
|
+
raise NotEnrolled(f"no template for {enrollee!r}")
|
|
128
|
+
|
|
129
|
+
obs, liveness_state, live_reason = self._observe(frame)
|
|
130
|
+
if obs is None:
|
|
131
|
+
return AuthResult(False, 0.0, liveness_state, enrollee, live_reason)
|
|
132
|
+
|
|
133
|
+
score = match_template(obs.embedding, template)
|
|
134
|
+
granted = liveness_state != "failed" and decide(score, self.threshold)
|
|
135
|
+
reason = "match" if granted else ("liveness failed" if liveness_state == "failed"
|
|
136
|
+
else f"below threshold ({score:.3f} < {self.threshold})")
|
|
137
|
+
return AuthResult(granted, score, liveness_state, enrollee, reason)
|
|
138
|
+
|
|
139
|
+
def identify(self, frame: Frame) -> AuthResult:
|
|
140
|
+
"""1:N — compare against every enrolled template, return the best."""
|
|
141
|
+
labels = self.store.list()
|
|
142
|
+
if not labels:
|
|
143
|
+
raise NotEnrolled("no faces enrolled")
|
|
144
|
+
|
|
145
|
+
obs, liveness_state, live_reason = self._observe(frame)
|
|
146
|
+
if obs is None:
|
|
147
|
+
return AuthResult(False, 0.0, liveness_state, None, live_reason)
|
|
148
|
+
|
|
149
|
+
templates = [t for t in (self.store.get(x) for x in labels) if t is not None]
|
|
150
|
+
label, score = identify(obs.embedding, templates)
|
|
151
|
+
granted = liveness_state != "failed" and decide(score, self.threshold)
|
|
152
|
+
reason = (
|
|
153
|
+
f"matched {label!r}" if granted
|
|
154
|
+
else ("liveness failed" if liveness_state == "failed"
|
|
155
|
+
else f"best {score:.3f} < {self.threshold}")
|
|
156
|
+
)
|
|
157
|
+
return AuthResult(granted, score, liveness_state, label if granted else None, reason)
|
|
158
|
+
|
|
159
|
+
# -- camera helpers (thin; require a working cv2.VideoCapture) --------
|
|
160
|
+
def unlock(self, camera: int = 0, timeout: float = 5.0, *, enrollee: "Optional[str]" = None,
|
|
161
|
+
interval: float = 0.15) -> bool:
|
|
162
|
+
from .camera import capture_until_match
|
|
163
|
+
|
|
164
|
+
return capture_until_match(
|
|
165
|
+
self, camera=camera, timeout=timeout, enrollee=enrollee, interval=interval
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def run_enrollment(self, camera: int = 0, *, label: str = "default", on_prompt=None,
|
|
169
|
+
attempts_per_pose: int = 60):
|
|
170
|
+
from .camera import guided_enrollment
|
|
171
|
+
|
|
172
|
+
return guided_enrollment(
|
|
173
|
+
self, camera=camera, label=label, on_prompt=on_prompt,
|
|
174
|
+
attempts_per_pose=attempts_per_pose,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# -- internals -----------------------------------------------------
|
|
178
|
+
def _observe(self, frame: Frame) -> "tuple[Optional[FaceObservation], str, str]":
|
|
179
|
+
"""Detect exactly one face and run liveness.
|
|
180
|
+
|
|
181
|
+
Returns ``(observation | None, liveness_state, reason)`` where
|
|
182
|
+
``liveness_state`` is ``"passed"``, ``"failed"`` or ``"skipped"``.
|
|
183
|
+
"""
|
|
184
|
+
bgr = to_bgr(frame)
|
|
185
|
+
faces = self._embedder.detect(bgr)
|
|
186
|
+
if not faces:
|
|
187
|
+
raise NoFaceDetected("no face in frame")
|
|
188
|
+
if len(faces) > 1:
|
|
189
|
+
raise MultipleFacesDetected(f"{len(faces)} faces in frame")
|
|
190
|
+
|
|
191
|
+
obs = faces[0]
|
|
192
|
+
if not self._liveness.enabled:
|
|
193
|
+
return obs, "skipped", "liveness disabled"
|
|
194
|
+
live = self._liveness.score(bgr, obs)
|
|
195
|
+
obs.liveness_score = live
|
|
196
|
+
if live < self.liveness_threshold:
|
|
197
|
+
return None, "failed", f"liveness {live:.2f} < {self.liveness_threshold}"
|
|
198
|
+
return obs, "passed", "ok"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Pluggable face-embedding backends.
|
|
2
|
+
|
|
3
|
+
An embedder turns a BGR frame into zero or more :class:`FaceObservation`s
|
|
4
|
+
(bounding box, 5 keypoints, detection score, and an L2-normalized embedding).
|
|
5
|
+
Swap the default by passing ``embedder=`` to :class:`facekey.FaceLock` or by
|
|
6
|
+
registering a factory here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Callable, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from ..models import FaceObservation
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class Embedder(Protocol):
|
|
20
|
+
"""The interface every recognition backend must satisfy."""
|
|
21
|
+
|
|
22
|
+
#: Short identifier stored on templates so a mismatch can be detected later.
|
|
23
|
+
name: str
|
|
24
|
+
|
|
25
|
+
def detect(self, bgr: np.ndarray) -> "list[FaceObservation]":
|
|
26
|
+
"""Return all faces in ``bgr``, best detection first."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_REGISTRY: "dict[str, Callable[..., Embedder]]" = {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def register(key: str, factory: "Callable[..., Embedder]") -> None:
|
|
34
|
+
_REGISTRY[key] = factory
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_embedder(model: str = "buffalo_l", **kwargs) -> Embedder:
|
|
38
|
+
"""Build a registered embedder by name.
|
|
39
|
+
|
|
40
|
+
``buffalo_l`` and ``buffalo_s`` map to the InsightFace backend. Any other
|
|
41
|
+
name must have been :func:`register`-ed first.
|
|
42
|
+
"""
|
|
43
|
+
if model in _REGISTRY:
|
|
44
|
+
return _REGISTRY[model](**kwargs)
|
|
45
|
+
if model.startswith("buffalo_"):
|
|
46
|
+
from .insightface import InsightFaceEmbedder
|
|
47
|
+
|
|
48
|
+
return InsightFaceEmbedder(model=model, **kwargs)
|
|
49
|
+
raise KeyError(
|
|
50
|
+
f"unknown embedder {model!r}; register it via facekey.embedders.register()"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = ["Embedder", "get_embedder", "register"]
|