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 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
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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"]