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.
@@ -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,6 @@
1
+ """Face embedding. See ``base`` for the protocol these implement."""
2
+
3
+ from alchemyface.embedding.base import Embedder
4
+ from alchemyface.embedding.sface import SFaceEmbedder
5
+
6
+ __all__ = ["Embedder", "SFaceEmbedder"]
@@ -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)
@@ -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,6 @@
1
+ """Gallery backends. See ``base`` for the protocol they implement."""
2
+
3
+ from alchemyface.store.base import FaceStore
4
+ from alchemyface.store.memory import InMemoryStore
5
+
6
+ __all__ = ["FaceStore", "InMemoryStore"]
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/alchemyface.svg)](https://pypi.org/project/alchemyface/)
36
+ [![CI](https://github.com/kouya-marino/AlchemyFace/actions/workflows/ci.yml/badge.svg)](https://github.com/kouya-marino/AlchemyFace/actions/workflows/ci.yml)
37
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
38
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ alchemyface = alchemyface.cli:app
@@ -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