face-engine 3.0.0__tar.gz → 3.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {face_engine-3.0.0/face_engine.egg-info → face_engine-3.2.0}/PKG-INFO +59 -7
  2. {face_engine-3.0.0 → face_engine-3.2.0}/README.rst +58 -6
  3. {face_engine-3.0.0 → face_engine-3.2.0}/face_engine/__init__.py +1 -1
  4. {face_engine-3.0.0 → face_engine-3.2.0}/face_engine/core.py +136 -7
  5. {face_engine-3.0.0 → face_engine-3.2.0}/face_engine/exceptions.py +4 -0
  6. {face_engine-3.0.0 → face_engine-3.2.0}/face_engine/fetching.py +3 -0
  7. {face_engine-3.0.0 → face_engine-3.2.0}/face_engine/models/__init__.py +24 -1
  8. face_engine-3.2.0/face_engine/models/_onnx.py +19 -0
  9. {face_engine-3.0.0 → face_engine-3.2.0}/face_engine/models/insightface_models.py +1 -13
  10. face_engine-3.2.0/face_engine/models/minifasnet.py +127 -0
  11. {face_engine-3.0.0 → face_engine-3.2.0}/face_engine/tools.py +5 -1
  12. {face_engine-3.0.0 → face_engine-3.2.0/face_engine.egg-info}/PKG-INFO +59 -7
  13. {face_engine-3.0.0 → face_engine-3.2.0}/face_engine.egg-info/SOURCES.txt +4 -1
  14. {face_engine-3.0.0 → face_engine-3.2.0}/tests/test_face_engine.py +126 -20
  15. {face_engine-3.0.0 → face_engine-3.2.0}/tests/test_face_engine_models.py +38 -2
  16. {face_engine-3.0.0 → face_engine-3.2.0}/tests/test_face_engine_persistence.py +24 -0
  17. face_engine-3.2.0/tests/test_tools.py +34 -0
  18. {face_engine-3.0.0 → face_engine-3.2.0}/LICENSE +0 -0
  19. {face_engine-3.0.0 → face_engine-3.2.0}/MANIFEST.in +0 -0
  20. {face_engine-3.0.0 → face_engine-3.2.0}/docs/source/conf.py +0 -0
  21. {face_engine-3.0.0 → face_engine-3.2.0}/face_engine/models/basic_estimator.py +0 -0
  22. {face_engine-3.0.0 → face_engine-3.2.0}/face_engine/models/dlib_models.py +0 -0
  23. {face_engine-3.0.0 → face_engine-3.2.0}/face_engine.egg-info/dependency_links.txt +0 -0
  24. {face_engine-3.0.0 → face_engine-3.2.0}/face_engine.egg-info/requires.txt +0 -0
  25. {face_engine-3.0.0 → face_engine-3.2.0}/face_engine.egg-info/top_level.txt +0 -0
  26. {face_engine-3.0.0 → face_engine-3.2.0}/pyproject.toml +0 -0
  27. {face_engine-3.0.0 → face_engine-3.2.0}/setup.cfg +0 -0
  28. {face_engine-3.0.0 → face_engine-3.2.0}/tests/test_fetching.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: face-engine
3
- Version: 3.0.0
3
+ Version: 3.2.0
4
4
  Summary: Face Recognition Engine
5
5
  Author-email: Daniyar Kussainov <ohw0sseug@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -68,23 +68,25 @@ FaceEngine is supported only on Python 3.11 and above.
68
68
  Models
69
69
  ------
70
70
 
71
- FaceEngine is built on top of three model interfaces ``Detector``, ``Embedder``
72
- and ``Estimator`` (see `models`_), and leans on user provided implementations
73
- of these models.
71
+ FaceEngine is built on top of four model interfaces ``Detector``, ``Embedder``,
72
+ ``Estimator`` and ``Antispoof`` (see `models`_), and leans on user provided
73
+ implementations of these models.
74
74
 
75
75
  The default backend is `insightface`_ (the ``[insightface]`` extra), with
76
76
  these bundled model implementations:
77
77
 
78
- ======================= ========== ============ ===========================
78
+ ======================= ========== ============ ==========================
79
79
  name role model pack notes
80
- ======================= ========== ============ ===========================
80
+ ======================= ========== ============ ==========================
81
81
  ``scrfd`` detector buffalo_l default; deprecated alias
82
82
  ``retina_face``
83
83
  ``arcface`` embedder buffalo_l default, 512-d
84
84
  ``scrfd_antelopev2`` detector antelopev2 opt-in
85
85
  ``arcface_antelopev2`` embedder antelopev2 strongest insightface
86
86
  embedder, 512-d
87
- ======================= ========== ============ ===========================
87
+ ``minifasnet`` antispoof -- passive anti-spoofing,
88
+ opt-in
89
+ ======================= ========== ============ ==========================
88
90
 
89
91
  .. note::
90
92
  The ``[insightface]`` extra installs the CPU build of onnxruntime.
@@ -111,6 +113,40 @@ To work with your own custom models you have to implement required
111
113
  `models`_ and import it. FaceEngine models are used to register all inheriting
112
114
  imported subclasses (subclass registration `PEP 487`_).
113
115
 
116
+ Face anti-spoofing
117
+ ------------------
118
+
119
+ Since 3.1 the engine has an opt-in liveness (presentation attack detection)
120
+ step powered by the ``Antispoof`` model interface:
121
+
122
+ .. code-block:: python
123
+
124
+ >>> engine = FaceEngine(antispoof="minifasnet")
125
+ >>> engine.check_liveness('bubbles1.jpg')
126
+ array([0.971], dtype=float32)
127
+
128
+ The bundled ``minifasnet`` model is an ensemble of the two released
129
+ `Silent-Face-Anti-Spoofing`_ MiniFASNet models (requires ``onnxruntime``,
130
+ already present with the ``[insightface]`` extra). It is effective against
131
+ printed photos and basic screen replays; it is **not** a certified
132
+ (ISO/IEC 30107-3) liveness solution.
133
+
134
+ Face verification
135
+ -----------------
136
+
137
+ Since 3.2 the engine has a model-agnostic 1:1 verification primitive —
138
+ cosine similarity between two embeddings of the same embedder:
139
+
140
+ .. code-block:: python
141
+
142
+ >>> bbs, extra = engine.find_faces(image, limit=1)
143
+ >>> source = engine.compute_embeddings(image, bbs, **extra)[0]
144
+ >>> engine.compare(source, target)
145
+ 0.83
146
+
147
+ ``compare`` returns the raw score in ``[-1, 1]``; accept/reject
148
+ thresholding is left to the caller and should be calibrated per embedder.
149
+
114
150
  Model weights licensing
115
151
  -----------------------
116
152
 
@@ -120,6 +156,21 @@ come with their own terms:
120
156
  * insightface model packs (``buffalo_l``, ``antelopev2``) are available for
121
157
  **non-commercial research purposes only** (see `insightface`_).
122
158
  * dlib model files have their own terms, see `dlib-models`_.
159
+ * ``minifasnet`` model weights are **Apache-2.0** (usable commercially) —
160
+ ONNX exports of the `Silent-Face-Anti-Spoofing`_ checkpoints, reproducible
161
+ with ``extra/export_minifasnet.py``.
162
+
163
+ Changes in 3.2
164
+ --------------
165
+
166
+ * Unknown explicit model names now raise ``ModelNotFoundError`` instead of
167
+ warning and falling back to the abstract no-op models. This applies to the
168
+ ``FaceEngine`` constructor, the model property setters, and
169
+ ``load_engine``. Empty names keep the installed-backend fallback chains.
170
+ * New ``engine.compare(source, target)`` — cosine similarity between two
171
+ embeddings for 1:1 verification (raw score, no thresholding).
172
+ * ``tools.imread`` now reads raw ``bytes`` image content, as its
173
+ documentation always claimed.
123
174
 
124
175
  Breaking changes in 3.0
125
176
  -----------------------
@@ -139,6 +190,7 @@ For more information read the full `documentation`_.
139
190
 
140
191
  .. _models: https://github.com/guesswh0/face_engine/blob/master/face_engine/models/__init__.py
141
192
  .. _insightface: https://github.com/deepinsight/insightface
193
+ .. _Silent-Face-Anti-Spoofing: https://github.com/minivision-ai/Silent-Face-Anti-Spoofing
142
194
  .. _dlib python api: http://dlib.net/python/index.html
143
195
  .. _files: https://github.com/davisking/dlib-models
144
196
  .. _dlib-models: https://github.com/davisking/dlib-models
@@ -29,23 +29,25 @@ FaceEngine is supported only on Python 3.11 and above.
29
29
  Models
30
30
  ------
31
31
 
32
- FaceEngine is built on top of three model interfaces ``Detector``, ``Embedder``
33
- and ``Estimator`` (see `models`_), and leans on user provided implementations
34
- of these models.
32
+ FaceEngine is built on top of four model interfaces ``Detector``, ``Embedder``,
33
+ ``Estimator`` and ``Antispoof`` (see `models`_), and leans on user provided
34
+ implementations of these models.
35
35
 
36
36
  The default backend is `insightface`_ (the ``[insightface]`` extra), with
37
37
  these bundled model implementations:
38
38
 
39
- ======================= ========== ============ ===========================
39
+ ======================= ========== ============ ==========================
40
40
  name role model pack notes
41
- ======================= ========== ============ ===========================
41
+ ======================= ========== ============ ==========================
42
42
  ``scrfd`` detector buffalo_l default; deprecated alias
43
43
  ``retina_face``
44
44
  ``arcface`` embedder buffalo_l default, 512-d
45
45
  ``scrfd_antelopev2`` detector antelopev2 opt-in
46
46
  ``arcface_antelopev2`` embedder antelopev2 strongest insightface
47
47
  embedder, 512-d
48
- ======================= ========== ============ ===========================
48
+ ``minifasnet`` antispoof -- passive anti-spoofing,
49
+ opt-in
50
+ ======================= ========== ============ ==========================
49
51
 
50
52
  .. note::
51
53
  The ``[insightface]`` extra installs the CPU build of onnxruntime.
@@ -72,6 +74,40 @@ To work with your own custom models you have to implement required
72
74
  `models`_ and import it. FaceEngine models are used to register all inheriting
73
75
  imported subclasses (subclass registration `PEP 487`_).
74
76
 
77
+ Face anti-spoofing
78
+ ------------------
79
+
80
+ Since 3.1 the engine has an opt-in liveness (presentation attack detection)
81
+ step powered by the ``Antispoof`` model interface:
82
+
83
+ .. code-block:: python
84
+
85
+ >>> engine = FaceEngine(antispoof="minifasnet")
86
+ >>> engine.check_liveness('bubbles1.jpg')
87
+ array([0.971], dtype=float32)
88
+
89
+ The bundled ``minifasnet`` model is an ensemble of the two released
90
+ `Silent-Face-Anti-Spoofing`_ MiniFASNet models (requires ``onnxruntime``,
91
+ already present with the ``[insightface]`` extra). It is effective against
92
+ printed photos and basic screen replays; it is **not** a certified
93
+ (ISO/IEC 30107-3) liveness solution.
94
+
95
+ Face verification
96
+ -----------------
97
+
98
+ Since 3.2 the engine has a model-agnostic 1:1 verification primitive —
99
+ cosine similarity between two embeddings of the same embedder:
100
+
101
+ .. code-block:: python
102
+
103
+ >>> bbs, extra = engine.find_faces(image, limit=1)
104
+ >>> source = engine.compute_embeddings(image, bbs, **extra)[0]
105
+ >>> engine.compare(source, target)
106
+ 0.83
107
+
108
+ ``compare`` returns the raw score in ``[-1, 1]``; accept/reject
109
+ thresholding is left to the caller and should be calibrated per embedder.
110
+
75
111
  Model weights licensing
76
112
  -----------------------
77
113
 
@@ -81,6 +117,21 @@ come with their own terms:
81
117
  * insightface model packs (``buffalo_l``, ``antelopev2``) are available for
82
118
  **non-commercial research purposes only** (see `insightface`_).
83
119
  * dlib model files have their own terms, see `dlib-models`_.
120
+ * ``minifasnet`` model weights are **Apache-2.0** (usable commercially) —
121
+ ONNX exports of the `Silent-Face-Anti-Spoofing`_ checkpoints, reproducible
122
+ with ``extra/export_minifasnet.py``.
123
+
124
+ Changes in 3.2
125
+ --------------
126
+
127
+ * Unknown explicit model names now raise ``ModelNotFoundError`` instead of
128
+ warning and falling back to the abstract no-op models. This applies to the
129
+ ``FaceEngine`` constructor, the model property setters, and
130
+ ``load_engine``. Empty names keep the installed-backend fallback chains.
131
+ * New ``engine.compare(source, target)`` — cosine similarity between two
132
+ embeddings for 1:1 verification (raw score, no thresholding).
133
+ * ``tools.imread`` now reads raw ``bytes`` image content, as its
134
+ documentation always claimed.
84
135
 
85
136
  Breaking changes in 3.0
86
137
  -----------------------
@@ -100,6 +151,7 @@ For more information read the full `documentation`_.
100
151
 
101
152
  .. _models: https://github.com/guesswh0/face_engine/blob/master/face_engine/models/__init__.py
102
153
  .. _insightface: https://github.com/deepinsight/insightface
154
+ .. _Silent-Face-Anti-Spoofing: https://github.com/minivision-ai/Silent-Face-Anti-Spoofing
103
155
  .. _dlib python api: http://dlib.net/python/index.html
104
156
  .. _files: https://github.com/davisking/dlib-models
105
157
  .. _dlib-models: https://github.com/davisking/dlib-models
@@ -3,7 +3,7 @@ Face Recognition Engine
3
3
  """
4
4
 
5
5
  __all__ = ["logger", "RESOURCES", "FaceEngine", "_models", "load_engine"]
6
- __version__ = "3.0.0"
6
+ __version__ = "3.2.0"
7
7
  __author__ = "Daniyar Kussainov"
8
8
  __license__ = "Apache License, Version 2.0"
9
9
  __copyright__ = "Copyright 2019-2026 Daniyar Kussainov"
@@ -8,8 +8,8 @@ import os
8
8
 
9
9
  import numpy as np
10
10
 
11
- from . import __version__, logger
12
- from .exceptions import FaceNotFoundError
11
+ from . import __version__
12
+ from .exceptions import FaceNotFoundError, ModelNotFoundError
13
13
  from .models import _models
14
14
  from .tools import imread
15
15
 
@@ -17,11 +17,32 @@ from .tools import imread
17
17
  _DETECTOR_DEFAULTS = ("scrfd", "hog")
18
18
  _EMBEDDER_DEFAULTS = ("arcface", "resnet")
19
19
  _ESTIMATOR_DEFAULTS = ("basic",)
20
+ # antispoof is opt-in: the default is the abstract no-op model
21
+ _ANTISPOOF_DEFAULTS = ()
22
+
23
+ # in-tree optional models: absence from the registry means the backend
24
+ # dependency failed to import (tools.import_module swallows it), not that
25
+ # a plugin-module import was forgotten
26
+ _OPTIONAL_BACKENDS = {
27
+ "scrfd": "insightface",
28
+ "scrfd_antelopev2": "insightface",
29
+ "retina_face": "insightface",
30
+ "arcface": "insightface",
31
+ "arcface_antelopev2": "insightface",
32
+ "hog": "dlib",
33
+ "mmod": "dlib",
34
+ "resnet": "dlib",
35
+ "minifasnet": "onnxruntime",
36
+ }
20
37
 
21
38
 
22
39
  def _resolve(name: Optional[str], defaults: Tuple[str, ...], kind: str) -> str:
23
40
  """Resolve model name to a registered one, with installed-backend
24
- fallbacks for empty names and abstract fallback for unknown names."""
41
+ fallbacks for empty names.
42
+
43
+ Explicit unknown names raise :class:`~.exceptions.ModelNotFoundError`
44
+ (since 3.2; previously they warned and fell back to the abstract no-op).
45
+ """
25
46
 
26
47
  if not name:
27
48
  for candidate in defaults:
@@ -29,8 +50,17 @@ def _resolve(name: Optional[str], defaults: Tuple[str, ...], kind: str) -> str:
29
50
  return candidate
30
51
  return "abstract_" + kind
31
52
  if name not in _models:
32
- logger.warning("%s model '%s' not found!", kind.capitalize(), name)
33
- return "abstract_" + kind
53
+ if name in _OPTIONAL_BACKENDS:
54
+ raise ModelNotFoundError(
55
+ "%s model '%s' is not registered because its backend "
56
+ "dependency '%s' is not installed"
57
+ % (kind.capitalize(), name, _OPTIONAL_BACKENDS[name])
58
+ )
59
+ raise ModelNotFoundError(
60
+ "%s model '%s' is not registered; make sure the module "
61
+ "providing it is imported. Registered models: %s"
62
+ % (kind.capitalize(), name, sorted(_models))
63
+ )
34
64
  return name
35
65
 
36
66
 
@@ -59,6 +89,8 @@ def load_engine(filename: str) -> "FaceEngine":
59
89
  :rtype: :class:`.FaceEngine`
60
90
 
61
91
  :raises RuntimeError: on legacy (pre-3.0) pickle files
92
+ :raises ModelNotFoundError: when the file names a model that is not
93
+ registered (e.g. its plugin module is not imported)
62
94
  """
63
95
 
64
96
  with open(filename, "rb") as file:
@@ -84,6 +116,8 @@ def load_engine(filename: str) -> "FaceEngine":
84
116
  detector=data.get("detector"),
85
117
  embedder=data.get("embedder"),
86
118
  estimator=data.get("estimator"),
119
+ # absent in files saved by face-engine < 3.1
120
+ antispoof=data.get("antispoof"),
87
121
  )
88
122
  engine.n_classes = data.get("n_classes", 0)
89
123
  engine.n_samples = data.get("n_samples", 0)
@@ -107,6 +141,7 @@ class FaceEngine:
107
141
  * detector (str) -- face detector model to use
108
142
  * embedder (str) -- face embedder model to use
109
143
  * estimator (str) -- face estimator model to use
144
+ * antispoof (str) -- face anti-spoofing model to use (opt-in)
110
145
  """
111
146
 
112
147
  def __init__(self, **kwargs: Any) -> None:
@@ -116,6 +151,8 @@ class FaceEngine:
116
151
  self.detector = kwargs.get("detector")
117
152
  self.embedder = kwargs.get("embedder")
118
153
  self.estimator = kwargs.get("estimator")
154
+ # opt-in anti-spoofing model, abstract no-op by default
155
+ self.antispoof = kwargs.get("antispoof")
119
156
  # keep last fitted number of classes and samples
120
157
  self.n_classes = 0
121
158
  self.n_samples = 0
@@ -191,6 +228,28 @@ class FaceEngine:
191
228
  Estimator = _models.get(_resolve(name, _ESTIMATOR_DEFAULTS, "estimator"))
192
229
  self._estimator = Estimator()
193
230
 
231
+ @property
232
+ def antispoof(self) -> str:
233
+ """
234
+ :return: antispoof model name
235
+ :rtype: str
236
+ """
237
+
238
+ return self._antispoof.name
239
+
240
+ @antispoof.setter
241
+ def antispoof(self, name: Optional[str]) -> None:
242
+ """Face anti-spoofing model to use:
243
+ - 'minifasnet': MiniFASNet passive anti-spoofing ensemble
244
+ - by default the abstract no-op model is used
245
+
246
+ :param name: antispoof model name
247
+ :type name: str
248
+ """
249
+
250
+ Antispoof = _models.get(_resolve(name, _ANTISPOOF_DEFAULTS, "antispoof"))
251
+ self._antispoof = Antispoof()
252
+
194
253
  def save(self, filename: str) -> None:
195
254
  """Save engine object state to the file as JSON.
196
255
 
@@ -205,12 +264,19 @@ class FaceEngine:
205
264
  if self.n_samples > 0:
206
265
  self._estimator.save(os.path.dirname(filename))
207
266
 
208
- # everything beyond the model trio and counters goes to "extra"
267
+ # everything beyond the models and counters goes to "extra"
209
268
  extra = {
210
269
  key: value
211
270
  for key, value in self.__dict__.items()
212
271
  if key
213
- not in ("_detector", "_embedder", "_estimator", "n_classes", "n_samples")
272
+ not in (
273
+ "_detector",
274
+ "_embedder",
275
+ "_estimator",
276
+ "_antispoof",
277
+ "n_classes",
278
+ "n_samples",
279
+ )
214
280
  }
215
281
  data = {
216
282
  "format": "face-engine",
@@ -219,6 +285,7 @@ class FaceEngine:
219
285
  "detector": self.detector,
220
286
  "embedder": self.embedder,
221
287
  "estimator": self.estimator,
288
+ "antispoof": self.antispoof,
222
289
  "n_classes": self.n_classes,
223
290
  "n_samples": self.n_samples,
224
291
  }
@@ -405,6 +472,40 @@ class FaceEngine:
405
472
  bbs = bbs / ([width, height] * 2)
406
473
  return bbs, extra
407
474
 
475
+ def check_liveness(
476
+ self,
477
+ image: Union[str, bytes, os.PathLike, np.ndarray],
478
+ bounding_boxes: Optional[np.ndarray] = None,
479
+ ) -> np.ndarray:
480
+ """Predict live-face probabilities for faces in the image.
481
+
482
+ Antispoof's :meth:`~face_engine.models.Antispoof.predict`
483
+ wrapping method. When ``bounding_boxes`` is not given, faces are
484
+ located with :meth:`.find_faces` first.
485
+
486
+ .. note::
487
+ The default abstract antispoof model raises
488
+ ``NotImplementedError`` — set :attr:`antispoof` first
489
+ (e.g. ``engine.antispoof = "minifasnet"``).
490
+
491
+ :param image: RGB image content or image file uri.
492
+ :type image: Union[str, bytes, file, os.PathLike, numpy.ndarray]
493
+
494
+ :param bounding_boxes: face bounding boxes, found when omitted
495
+ :type bounding_boxes: numpy.ndarray
496
+
497
+ :returns: live-face probabilities in [0, 1] with shape (n_faces,)
498
+ :rtype: numpy.ndarray
499
+
500
+ :raises: FaceNotFoundError
501
+ """
502
+
503
+ if not hasattr(image, "shape"):
504
+ image = imread(image)
505
+ if bounding_boxes is None:
506
+ bounding_boxes, _ = self.find_faces(image)
507
+ return self._antispoof.predict(image, bounding_boxes)
508
+
408
509
  def compute_embeddings(
409
510
  self, image: np.ndarray, bounding_boxes: np.ndarray, **kwargs: Any
410
511
  ) -> np.ndarray:
@@ -427,3 +528,31 @@ class FaceEngine:
427
528
 
428
529
  embeddings = self._embedder.compute_embeddings(image, bounding_boxes, **kwargs)
429
530
  return embeddings
531
+
532
+ def compare(self, source: np.ndarray, target: np.ndarray) -> float:
533
+ """Compare two face embeddings by cosine similarity.
534
+
535
+ The 1:1 verification primitive: returns the raw similarity score
536
+ in ``[-1, 1]`` (1 — same direction, 0 — orthogonal, -1 — opposite;
537
+ 0 also for zero-norm inputs). Both embeddings must come from the
538
+ same embedder model. Accept/reject thresholding is left to the
539
+ caller and should be calibrated per embedder.
540
+
541
+ :param source: embedding vector with shape (embedding_dim,)
542
+ or (1, embedding_dim)
543
+ :type source: numpy.ndarray
544
+
545
+ :param target: embedding vector with shape (embedding_dim,)
546
+ or (1, embedding_dim)
547
+ :type target: numpy.ndarray
548
+
549
+ :returns: cosine similarity score
550
+ :rtype: float
551
+ """
552
+
553
+ source = np.asarray(source, dtype=np.float64).ravel()
554
+ target = np.asarray(target, dtype=np.float64).ravel()
555
+ denominator = float(np.linalg.norm(source) * np.linalg.norm(target))
556
+ if denominator == 0.0:
557
+ return 0.0
558
+ return float(np.dot(source, target) / denominator)
@@ -7,5 +7,9 @@ class FaceNotFoundError(Exception):
7
7
  """Raised when the face is not found in the image"""
8
8
 
9
9
 
10
+ class ModelNotFoundError(Exception):
11
+ """Raised when an explicit model name is not in the registry"""
12
+
13
+
10
14
  class TrainError(Exception):
11
15
  """Raised when the fit(train) process is failed"""
@@ -27,6 +27,9 @@ KNOWN_HASHES = {
27
27
  # insightface model packs (v0.7 github release assets)
28
28
  "buffalo_l.zip": "80ffe37d8a5940d59a7384c201a2a38d4741f2f3c51eef46ebb28218a7b0ca2f", # noqa: E501
29
29
  "antelopev2.zip": "8e182f14fc6e80b3bfa375b33eb6cff7ee05d8ef7633e738d1c89021dcf0c5c5", # noqa: E501
30
+ # minifasnet anti-spoofing onnx exports, see extra/export_minifasnet.py
31
+ "2.7_80x80_MiniFASNetV2.onnx": "3052e9d1d97270f5d9f197bed4f039cae23b8c14d6282e879ca3a63073792a97", # noqa: E501
32
+ "4_0_0_80x80_MiniFASNetV1SE.onnx": "2348be428f787149bf28dca49f802bd7ab9280ff3333475f76df1aafd2dada4f", # noqa: E501
30
33
  # test assets (https://github.com/guesswh0/storage)
31
34
  "test.zip": "7e967899f1a106908798d750f81d3a966422300b479757b600eaf3ea4d96723b",
32
35
  "train.zip": "d61d21d6b0198df9b2f045de784924984177042a595a1a5fd42c3771b2b24b15",
@@ -2,7 +2,7 @@
2
2
  FaceEngine models API.
3
3
  """
4
4
 
5
- __all__ = ["Detector", "Embedder", "Estimator", "_models"]
5
+ __all__ = ["Detector", "Embedder", "Estimator", "Antispoof", "_models"]
6
6
 
7
7
  from face_engine.tools import import_package
8
8
 
@@ -88,6 +88,29 @@ class Embedder(Model):
88
88
  raise NotImplementedError()
89
89
 
90
90
 
91
+ class Antispoof(Model):
92
+ """Face anti-spoofing (presentation attack detection) model base class.
93
+
94
+ Estimates whether the faces in the image belong to live persons or
95
+ to presentation attacks (printed photos, screen replays, masks).
96
+ """
97
+
98
+ def predict(self, image, bounding_boxes):
99
+ """Predict live-face probabilities for given bounding boxes.
100
+
101
+ :param image: RGB image with shape (rows, cols, 3)
102
+ :type image: numpy.ndarray
103
+
104
+ :param bounding_boxes: bounding boxes with shape (n_faces, 4)
105
+ :type bounding_boxes: numpy.ndarray
106
+
107
+ :returns: live-face probabilities in [0, 1] with shape (n_faces,)
108
+ :rtype: numpy.ndarray
109
+ """
110
+
111
+ raise NotImplementedError()
112
+
113
+
91
114
  class Estimator(Model):
92
115
  """Estimator model base class. Used to make predictions for face
93
116
  embedding vectors.
@@ -0,0 +1,19 @@
1
+ """Shared helpers for onnxruntime backed models.
2
+
3
+ Underscore-prefixed so ``import_package`` skips it: importing it must stay
4
+ an explicit choice of modules that already require onnxruntime.
5
+ """
6
+
7
+ import onnxruntime
8
+
9
+
10
+ def _providers():
11
+ """Cuda when available, cpu otherwise.
12
+
13
+ Insightface requests CUDAExecutionProvider unconditionally, making
14
+ onnxruntime warn on every non-CUDA machine.
15
+ """
16
+ available = onnxruntime.get_available_providers()
17
+ return [
18
+ p for p in ("CUDAExecutionProvider", "CPUExecutionProvider") if p in available
19
+ ]
@@ -2,7 +2,6 @@ import os
2
2
  import warnings
3
3
 
4
4
  import numpy as np
5
- import onnxruntime
6
5
  from insightface.model_zoo import model_zoo
7
6
  from insightface.utils import face_align
8
7
 
@@ -10,6 +9,7 @@ from face_engine import RESOURCES
10
9
  from face_engine.exceptions import FaceNotFoundError
11
10
  from face_engine.fetching import fetch_file
12
11
  from face_engine.models import Detector, Embedder
12
+ from face_engine.models._onnx import _providers
13
13
 
14
14
  # model packs from the insightface v0.7 release assets
15
15
  # (storage.insightface.ai is no longer available);
@@ -17,18 +17,6 @@ from face_engine.models import Detector, Embedder
17
17
  _PACK_URL = "https://github.com/deepinsight/insightface/releases/download/v0.7/{}.zip"
18
18
 
19
19
 
20
- def _providers():
21
- """Cuda when available, cpu otherwise.
22
-
23
- Insightface requests CUDAExecutionProvider unconditionally, making
24
- onnxruntime warn on every non-CUDA machine.
25
- """
26
- available = onnxruntime.get_available_providers()
27
- return [
28
- p for p in ("CUDAExecutionProvider", "CPUExecutionProvider") if p in available
29
- ]
30
-
31
-
32
20
  def _fetch_pack(pack, filename):
33
21
  """Fetch insightface model pack and return path to the model file."""
34
22
  extract_dir = os.path.join(RESOURCES, "models", pack)
@@ -0,0 +1,127 @@
1
+ import os
2
+
3
+ import numpy as np
4
+ import onnxruntime
5
+
6
+ from face_engine import RESOURCES
7
+ from face_engine.fetching import fetch_file
8
+ from face_engine.models import Antispoof
9
+ from face_engine.models._onnx import _providers
10
+
11
+ # apache-2.0 onnx exports, see extra/export_minifasnet.py
12
+ _ASSET_URL = "https://github.com/guesswh0/face_engine/releases/download/v3.0.0/{}"
13
+
14
+
15
+ def _softmax(logits):
16
+ exp = np.exp(logits - logits.max(axis=1, keepdims=True))
17
+ return exp / exp.sum(axis=1, keepdims=True)
18
+
19
+
20
+ def _resize_bilinear(image, out_h, out_w):
21
+ """cv2.resize(INTER_LINEAR) equivalent, to avoid an opencv dependency."""
22
+ in_h, in_w = image.shape[:2]
23
+ ys = (np.arange(out_h) + 0.5) * in_h / out_h - 0.5
24
+ xs = (np.arange(out_w) + 0.5) * in_w / out_w - 0.5
25
+ y0 = np.floor(ys).astype(int)
26
+ x0 = np.floor(xs).astype(int)
27
+ wy = (ys - y0)[:, None, None]
28
+ wx = (xs - x0)[None, :, None]
29
+ y0c, y1c = np.clip(y0, 0, in_h - 1), np.clip(y0 + 1, 0, in_h - 1)
30
+ x0c, x1c = np.clip(x0, 0, in_w - 1), np.clip(x0 + 1, 0, in_w - 1)
31
+ # gather the sampled rows first, cast only the gathered data
32
+ top_rows = image[y0c].astype(np.float32)
33
+ bottom_rows = image[y1c].astype(np.float32)
34
+ top = top_rows[:, x0c] * (1 - wx) + top_rows[:, x1c] * wx
35
+ bottom = bottom_rows[:, x0c] * (1 - wx) + bottom_rows[:, x1c] * wx
36
+ # official pipeline resizes uint8 images, so round like cv2 does
37
+ return np.rint(top * (1 - wy) + bottom * wy)
38
+
39
+
40
+ class MiniFASNetAntispoof(Antispoof, name="minifasnet"):
41
+ """MiniFASNet passive anti-spoofing ensemble (Silent-Face-Anti-Spoofing).
42
+
43
+ Ensemble of the two released 80x80 models, each run on its own
44
+ scale-cropped patch around the face; the averaged softmax gives the
45
+ live-face probability.
46
+
47
+ .. note::
48
+ * both code and model weights are Apache-2.0 — usable in
49
+ commercial products.
50
+ * effective against printed photos and basic screen replays;
51
+ not a certified (ISO/IEC 30107-3) liveness solution.
52
+
53
+ References:
54
+ 1. https://github.com/minivision-ai/Silent-Face-Anti-Spoofing
55
+ """
56
+
57
+ # filename -> official crop scale (each model has its own)
58
+ files = (
59
+ ("2.7_80x80_MiniFASNetV2.onnx", 2.7),
60
+ ("4_0_0_80x80_MiniFASNetV1SE.onnx", 4.0),
61
+ )
62
+ input_size = (80, 80)
63
+
64
+ def __init__(self):
65
+ extract_dir = os.path.join(RESOURCES, "models", "minifasnet")
66
+ self._sessions = []
67
+ for filename, scale in self.files:
68
+ fetch_file(_ASSET_URL.format(filename), extract_dir)
69
+ session = onnxruntime.InferenceSession(
70
+ os.path.join(extract_dir, filename), providers=_providers()
71
+ )
72
+ self._sessions.append((session, scale))
73
+
74
+ def predict(self, image, bounding_boxes):
75
+ n_faces = len(bounding_boxes)
76
+ if n_faces == 0:
77
+ return np.empty(0, dtype=np.float32)
78
+
79
+ # models are trained on BGR images
80
+ image = image[..., ::-1]
81
+ live_probs = np.zeros(n_faces, dtype=np.float32)
82
+ for session, scale in self._sessions:
83
+ batch = np.stack([self._patch(image, bb, scale) for bb in bounding_boxes])
84
+ logits = session.run(None, {"input": batch})[0]
85
+ # softmax classes are (fake, live, fake)
86
+ live_probs += _softmax(logits)[:, 1]
87
+ return live_probs / len(self._sessions)
88
+
89
+ def _patch(self, image, bounding_box, scale):
90
+ """Scale-cropped face patch as float32 CHW tensor in [0, 255].
91
+
92
+ Reproduces the official CropImage box math: aspect-preserving
93
+ scaled box around the face, shifted back inside the image, then
94
+ resized to the model input size (no normalization by design —
95
+ the models were trained on raw 0-255 inputs).
96
+ """
97
+ src_h, src_w = image.shape[:2]
98
+ left, upper, right, lower = bounding_box
99
+ box_w, box_h = right - left + 1, lower - upper + 1
100
+
101
+ scale = min((src_h - 1) / box_h, (src_w - 1) / box_w, scale)
102
+ new_w, new_h = box_w * scale, box_h * scale
103
+ center_x, center_y = left + box_w / 2, upper + box_h / 2
104
+
105
+ left_top_x = center_x - new_w / 2
106
+ left_top_y = center_y - new_h / 2
107
+ right_bottom_x = center_x + new_w / 2
108
+ right_bottom_y = center_y + new_h / 2
109
+ if left_top_x < 0:
110
+ right_bottom_x -= left_top_x
111
+ left_top_x = 0
112
+ if left_top_y < 0:
113
+ right_bottom_y -= left_top_y
114
+ left_top_y = 0
115
+ if right_bottom_x > src_w - 1:
116
+ left_top_x -= right_bottom_x - src_w + 1
117
+ right_bottom_x = src_w - 1
118
+ if right_bottom_y > src_h - 1:
119
+ left_top_y -= right_bottom_y - src_h + 1
120
+ right_bottom_y = src_h - 1
121
+
122
+ patch = image[
123
+ int(left_top_y) : int(right_bottom_y) + 1,
124
+ int(left_top_x) : int(right_bottom_x) + 1,
125
+ ]
126
+ patch = _resize_bilinear(patch, *self.input_size)
127
+ return patch.transpose((2, 0, 1)).astype(np.float32)
@@ -58,6 +58,10 @@ def imread(uri, mode=None):
58
58
  data = _download_url(uri)
59
59
  uri = io.BytesIO(data)
60
60
 
61
+ # raw image bytes: PIL treats a bare bytes object as a file path
62
+ if isinstance(uri, bytes):
63
+ uri = io.BytesIO(uri)
64
+
61
65
  image = Image.open(uri)
62
66
  if mode:
63
67
  image = image.convert(mode)
@@ -80,7 +84,7 @@ def import_module(filepath):
80
84
  spec.loader.exec_module(module)
81
85
  except ImportError:
82
86
  # skip raising on project [optional] default models
83
- if path.stem not in ["dlib_models", "insightface_models"]:
87
+ if path.stem not in ["dlib_models", "insightface_models", "minifasnet"]:
84
88
  raise
85
89
 
86
90
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: face-engine
3
- Version: 3.0.0
3
+ Version: 3.2.0
4
4
  Summary: Face Recognition Engine
5
5
  Author-email: Daniyar Kussainov <ohw0sseug@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -68,23 +68,25 @@ FaceEngine is supported only on Python 3.11 and above.
68
68
  Models
69
69
  ------
70
70
 
71
- FaceEngine is built on top of three model interfaces ``Detector``, ``Embedder``
72
- and ``Estimator`` (see `models`_), and leans on user provided implementations
73
- of these models.
71
+ FaceEngine is built on top of four model interfaces ``Detector``, ``Embedder``,
72
+ ``Estimator`` and ``Antispoof`` (see `models`_), and leans on user provided
73
+ implementations of these models.
74
74
 
75
75
  The default backend is `insightface`_ (the ``[insightface]`` extra), with
76
76
  these bundled model implementations:
77
77
 
78
- ======================= ========== ============ ===========================
78
+ ======================= ========== ============ ==========================
79
79
  name role model pack notes
80
- ======================= ========== ============ ===========================
80
+ ======================= ========== ============ ==========================
81
81
  ``scrfd`` detector buffalo_l default; deprecated alias
82
82
  ``retina_face``
83
83
  ``arcface`` embedder buffalo_l default, 512-d
84
84
  ``scrfd_antelopev2`` detector antelopev2 opt-in
85
85
  ``arcface_antelopev2`` embedder antelopev2 strongest insightface
86
86
  embedder, 512-d
87
- ======================= ========== ============ ===========================
87
+ ``minifasnet`` antispoof -- passive anti-spoofing,
88
+ opt-in
89
+ ======================= ========== ============ ==========================
88
90
 
89
91
  .. note::
90
92
  The ``[insightface]`` extra installs the CPU build of onnxruntime.
@@ -111,6 +113,40 @@ To work with your own custom models you have to implement required
111
113
  `models`_ and import it. FaceEngine models are used to register all inheriting
112
114
  imported subclasses (subclass registration `PEP 487`_).
113
115
 
116
+ Face anti-spoofing
117
+ ------------------
118
+
119
+ Since 3.1 the engine has an opt-in liveness (presentation attack detection)
120
+ step powered by the ``Antispoof`` model interface:
121
+
122
+ .. code-block:: python
123
+
124
+ >>> engine = FaceEngine(antispoof="minifasnet")
125
+ >>> engine.check_liveness('bubbles1.jpg')
126
+ array([0.971], dtype=float32)
127
+
128
+ The bundled ``minifasnet`` model is an ensemble of the two released
129
+ `Silent-Face-Anti-Spoofing`_ MiniFASNet models (requires ``onnxruntime``,
130
+ already present with the ``[insightface]`` extra). It is effective against
131
+ printed photos and basic screen replays; it is **not** a certified
132
+ (ISO/IEC 30107-3) liveness solution.
133
+
134
+ Face verification
135
+ -----------------
136
+
137
+ Since 3.2 the engine has a model-agnostic 1:1 verification primitive —
138
+ cosine similarity between two embeddings of the same embedder:
139
+
140
+ .. code-block:: python
141
+
142
+ >>> bbs, extra = engine.find_faces(image, limit=1)
143
+ >>> source = engine.compute_embeddings(image, bbs, **extra)[0]
144
+ >>> engine.compare(source, target)
145
+ 0.83
146
+
147
+ ``compare`` returns the raw score in ``[-1, 1]``; accept/reject
148
+ thresholding is left to the caller and should be calibrated per embedder.
149
+
114
150
  Model weights licensing
115
151
  -----------------------
116
152
 
@@ -120,6 +156,21 @@ come with their own terms:
120
156
  * insightface model packs (``buffalo_l``, ``antelopev2``) are available for
121
157
  **non-commercial research purposes only** (see `insightface`_).
122
158
  * dlib model files have their own terms, see `dlib-models`_.
159
+ * ``minifasnet`` model weights are **Apache-2.0** (usable commercially) —
160
+ ONNX exports of the `Silent-Face-Anti-Spoofing`_ checkpoints, reproducible
161
+ with ``extra/export_minifasnet.py``.
162
+
163
+ Changes in 3.2
164
+ --------------
165
+
166
+ * Unknown explicit model names now raise ``ModelNotFoundError`` instead of
167
+ warning and falling back to the abstract no-op models. This applies to the
168
+ ``FaceEngine`` constructor, the model property setters, and
169
+ ``load_engine``. Empty names keep the installed-backend fallback chains.
170
+ * New ``engine.compare(source, target)`` — cosine similarity between two
171
+ embeddings for 1:1 verification (raw score, no thresholding).
172
+ * ``tools.imread`` now reads raw ``bytes`` image content, as its
173
+ documentation always claimed.
123
174
 
124
175
  Breaking changes in 3.0
125
176
  -----------------------
@@ -139,6 +190,7 @@ For more information read the full `documentation`_.
139
190
 
140
191
  .. _models: https://github.com/guesswh0/face_engine/blob/master/face_engine/models/__init__.py
141
192
  .. _insightface: https://github.com/deepinsight/insightface
193
+ .. _Silent-Face-Anti-Spoofing: https://github.com/minivision-ai/Silent-Face-Anti-Spoofing
142
194
  .. _dlib python api: http://dlib.net/python/index.html
143
195
  .. _files: https://github.com/davisking/dlib-models
144
196
  .. _dlib-models: https://github.com/davisking/dlib-models
@@ -14,10 +14,13 @@ face_engine.egg-info/dependency_links.txt
14
14
  face_engine.egg-info/requires.txt
15
15
  face_engine.egg-info/top_level.txt
16
16
  face_engine/models/__init__.py
17
+ face_engine/models/_onnx.py
17
18
  face_engine/models/basic_estimator.py
18
19
  face_engine/models/dlib_models.py
19
20
  face_engine/models/insightface_models.py
21
+ face_engine/models/minifasnet.py
20
22
  tests/test_face_engine.py
21
23
  tests/test_face_engine_models.py
22
24
  tests/test_face_engine_persistence.py
23
- tests/test_fetching.py
25
+ tests/test_fetching.py
26
+ tests/test_tools.py
@@ -1,13 +1,12 @@
1
- import logging
2
1
  import unittest
3
2
 
4
3
  import numpy as np
5
4
 
6
5
  from face_engine import FaceEngine
7
- from face_engine.exceptions import FaceNotFoundError, TrainError
8
- from face_engine.models import Detector, Embedder, Estimator
6
+ from face_engine.exceptions import FaceNotFoundError, ModelNotFoundError, TrainError
7
+ from face_engine.models import Antispoof, Detector, Embedder, Estimator
9
8
  from face_engine.tools import imread
10
- from tests import TestCase, dlib, insightface
9
+ from tests import TestCase, dlib, insightface, onnxruntime
11
10
 
12
11
 
13
12
  class TestFaceEngine(TestCase):
@@ -48,14 +47,13 @@ class TestFaceEngine(TestCase):
48
47
  self.assertEqual(self.empty_engine.embedder, "abstract_embedder")
49
48
  self.assertEqual(self.empty_engine.estimator, "abstract_estimator")
50
49
 
51
- def test_explicit_init_with_not_existing_models(self):
52
- logging.disable(logging.WARNING)
53
- engine = FaceEngine(
54
- detector="my_detector", embedder="my_embedder", estimator="my_estimator"
55
- )
56
- self.assertEqual(engine.detector, "abstract_detector")
57
- self.assertEqual(engine.embedder, "abstract_embedder")
58
- self.assertEqual(engine.estimator, "abstract_estimator")
50
+ def test_explicit_init_with_not_existing_models_raises(self):
51
+ with self.assertRaises(ModelNotFoundError):
52
+ FaceEngine(detector="my_detector")
53
+ with self.assertRaises(ModelNotFoundError):
54
+ FaceEngine(embedder="my_embedder")
55
+ with self.assertRaises(ModelNotFoundError):
56
+ FaceEngine(estimator="my_estimator")
59
57
 
60
58
  @unittest.skipUnless(dlib, "dlib package is not installed")
61
59
  def test_setters_with_dlib_models(self):
@@ -95,16 +93,19 @@ class TestFaceEngine(TestCase):
95
93
  self.assertIsInstance(self.test_engine._estimator, Estimator)
96
94
  self.assertEqual(self.test_engine.estimator, "abstract_estimator")
97
95
 
98
- def test_setters_with_not_existing_models(self):
99
- logging.disable(logging.WARNING)
100
- self.empty_engine.detector = "my_detector"
101
- self.empty_engine.embedder = "my_embedder"
102
- self.empty_engine.estimator = "my_estimator"
103
- self.assertNotEqual(self.empty_engine.detector, "my_detector")
96
+ def test_setters_with_not_existing_models_raise(self):
97
+ with self.assertRaises(ModelNotFoundError) as context:
98
+ self.empty_engine.detector = "my_detector"
99
+ # the message names the model and hints at the plugin import
100
+ self.assertIn("my_detector", str(context.exception))
101
+ self.assertIn("imported", str(context.exception))
102
+ with self.assertRaises(ModelNotFoundError):
103
+ self.empty_engine.embedder = "my_embedder"
104
+ with self.assertRaises(ModelNotFoundError):
105
+ self.empty_engine.estimator = "my_estimator"
106
+ # the failed assignments left the previous models in place
104
107
  self.assertEqual(self.empty_engine.detector, "abstract_detector")
105
- self.assertNotEqual(self.empty_engine.embedder, "my_embedder")
106
108
  self.assertEqual(self.empty_engine.embedder, "abstract_embedder")
107
- self.assertNotEqual(self.empty_engine.estimator, "my_estimator")
108
109
  self.assertEqual(self.empty_engine.estimator, "abstract_estimator")
109
110
 
110
111
  @unittest.skipUnless(insightface, "insightface package is not installed")
@@ -140,6 +141,111 @@ class TestFaceEngine(TestCase):
140
141
  self.assertIsInstance(self.test_engine.detector, str)
141
142
  self.assertIsInstance(self.test_engine.embedder, str)
142
143
  self.assertIsInstance(self.test_engine.estimator, str)
144
+ self.assertIsInstance(self.test_engine.antispoof, str)
145
+
146
+ def test_init_default_antispoof_is_abstract(self):
147
+ # antispoof is opt-in regardless of installed backends
148
+ self.assertEqual(self.test_engine.antispoof, "abstract_antispoof")
149
+
150
+ def test_setter_antispoof_with_not_existing_model_raises(self):
151
+ with self.assertRaises(ModelNotFoundError):
152
+ self.test_engine.antispoof = "my_antispoof"
153
+ self.assertEqual(self.test_engine.antispoof, "abstract_antispoof")
154
+
155
+ def test_unregistered_in_tree_model_names_missing_backend(self):
156
+ # simulate an install where minifasnet's backend failed to import
157
+ from unittest import mock
158
+
159
+ from face_engine import core
160
+
161
+ with mock.patch.dict(core._models):
162
+ core._models.pop("minifasnet", None)
163
+ with self.assertRaises(ModelNotFoundError) as context:
164
+ core._resolve("minifasnet", (), "antispoof")
165
+ # the hint names the missing dependency, not a module import
166
+ self.assertIn("onnxruntime", str(context.exception))
167
+ self.assertNotIn("imported", str(context.exception))
168
+
169
+ def test_check_liveness_with_abstract_raises(self):
170
+ with self.assertRaises(NotImplementedError):
171
+ self.test_engine.check_liveness(
172
+ imread(self.bubbles1), np.array([[278, 132, 618, 471]])
173
+ )
174
+
175
+ @unittest.skipUnless(onnxruntime, "onnxruntime package is not installed")
176
+ def test_setter_antispoof_minifasnet(self):
177
+ self.test_engine.antispoof = "minifasnet"
178
+ self.assertEqual(self.test_engine.antispoof, "minifasnet")
179
+ self.assertIsInstance(self.test_engine._antispoof, Antispoof)
180
+
181
+ @unittest.skipUnless(onnxruntime, "onnxruntime package is not installed")
182
+ def test_check_liveness_with_bounding_boxes(self):
183
+ self.test_engine.antispoof = "minifasnet"
184
+ scores = self.test_engine.check_liveness(
185
+ imread(self.bubbles1), np.array([[278, 132, 618, 471]])
186
+ )
187
+ self.assertEqual(scores.shape, (1,))
188
+ self.assertTrue(0.0 <= scores[0] <= 1.0)
189
+
190
+ @unittest.skipUnless(insightface, "insightface package is not installed")
191
+ def test_check_liveness_finds_faces(self):
192
+ engine = FaceEngine(antispoof="minifasnet")
193
+ scores = engine.check_liveness(self.family)
194
+ self.assertGreater(len(scores), 1)
195
+ self.assertTrue(all(0.0 <= s <= 1.0 for s in scores))
196
+
197
+ def test_compare_identical_embeddings(self):
198
+ embedding = np.array([0.5, -0.25, 0.75])
199
+ self.assertAlmostEqual(self.test_engine.compare(embedding, embedding), 1.0)
200
+
201
+ def test_compare_orthogonal_embeddings(self):
202
+ source = np.array([1.0, 0.0])
203
+ target = np.array([0.0, 1.0])
204
+ self.assertAlmostEqual(self.test_engine.compare(source, target), 0.0)
205
+
206
+ def test_compare_opposite_embeddings(self):
207
+ embedding = np.array([0.5, -0.25, 0.75])
208
+ self.assertAlmostEqual(self.test_engine.compare(embedding, -embedding), -1.0)
209
+
210
+ def test_compare_zero_vector_scores_zero(self):
211
+ source = np.zeros(3)
212
+ target = np.array([1.0, 2.0, 3.0])
213
+ self.assertEqual(self.test_engine.compare(source, target), 0.0)
214
+
215
+ def test_compare_accepts_single_row_matrices(self):
216
+ # compute_embeddings returns (n_faces, dim); a single-face row
217
+ # must be comparable without an explicit squeeze
218
+ source = np.array([[1.0, 2.0, 3.0]])
219
+ target = np.array([1.0, 2.0, 3.0])
220
+ self.assertAlmostEqual(self.test_engine.compare(source, target), 1.0)
221
+
222
+ def test_compare_returns_python_float(self):
223
+ source = np.array([1.0, 2.0], dtype=np.float32)
224
+ target = np.array([2.0, 1.0], dtype=np.float32)
225
+ self.assertIsInstance(self.test_engine.compare(source, target), float)
226
+
227
+ def _assert_same_face_scores_higher(self, engine):
228
+ image1 = imread(self.bubbles1)
229
+ bbs1, extra1 = engine.find_faces(image1, limit=1)
230
+ same1 = engine.compute_embeddings(image1, bbs1, **extra1)[0]
231
+ image2 = imread(self.bubbles2)
232
+ bbs2, extra2 = engine.find_faces(image2, limit=1)
233
+ same2 = engine.compute_embeddings(image2, bbs2, **extra2)[0]
234
+ image3 = imread(self.drive)
235
+ bbs3, extra3 = engine.find_faces(image3, limit=1)
236
+ other = engine.compute_embeddings(image3, bbs3, **extra3)[0]
237
+ same_score = engine.compare(same1, same2)
238
+ other_score = engine.compare(same1, other)
239
+ self.assertGreater(same_score, other_score)
240
+
241
+ @unittest.skipUnless(dlib, "dlib package is not installed")
242
+ def test_compare_same_face_scores_higher_dlib(self):
243
+ self._assert_same_face_scores_higher(self.dlib_engine)
244
+
245
+ @unittest.skipUnless(insightface, "insightface package is not installed")
246
+ def test_compare_same_face_scores_higher_insightface(self):
247
+ engine = FaceEngine(detector="scrfd", embedder="arcface")
248
+ self._assert_same_face_scores_higher(engine)
143
249
 
144
250
  @unittest.skipUnless(dlib, "dlib package is not installed")
145
251
  def test_fit_bubbles(self):
@@ -5,10 +5,10 @@ import numpy as np
5
5
 
6
6
  from extra.dataset import load_dataset
7
7
  from face_engine.exceptions import FaceNotFoundError
8
- from face_engine.models import Detector, Embedder, Estimator
8
+ from face_engine.models import Detector, Embedder, Estimator, _models
9
9
  from face_engine.models.basic_estimator import BasicEstimator
10
10
  from face_engine.tools import imread
11
- from tests import TestCase, dlib, insightface
11
+ from tests import TestCase, dlib, insightface, onnxruntime
12
12
 
13
13
  if dlib:
14
14
  from face_engine.models.dlib_models import HOGDetector, MMODDetector, ResNetEmbedder
@@ -160,6 +160,42 @@ class TestArcFaceAntelopeV2Embedder(TestInsightFaceEmbedder):
160
160
  self.embedder = ArcFaceAntelopeV2Embedder()
161
161
 
162
162
 
163
+ @unittest.skipUnless(onnxruntime, "onnxruntime package is not installed")
164
+ class TestMiniFASNetAntispoof(TestCase):
165
+
166
+ def setUp(self):
167
+ from face_engine.models.minifasnet import MiniFASNetAntispoof
168
+
169
+ self.antispoof = MiniFASNetAntispoof()
170
+ self.image = imread(self.bubbles1)
171
+ self.bbs = np.array([[278, 132, 618, 471]])
172
+
173
+ def test_registered_under_canonical_name(self):
174
+ self.assertIs(_models["minifasnet"], type(self.antispoof))
175
+
176
+ def test_predict_return_data_shape_and_range(self):
177
+ scores = self.antispoof.predict(self.image, self.bbs)
178
+ self.assertIsInstance(scores, np.ndarray)
179
+ self.assertEqual(scores.shape, (1,))
180
+ self.assertTrue(0.0 <= scores[0] <= 1.0)
181
+
182
+ def test_predict_live_photo_scores_high(self):
183
+ scores = self.antispoof.predict(self.image, self.bbs)
184
+ self.assertGreater(scores[0], 0.5)
185
+
186
+ def test_predict_multiple_faces(self):
187
+ image = imread(self.family)
188
+ bbs = np.array(
189
+ [[100, 100, 200, 220], [300, 120, 400, 240], [500, 110, 600, 230]]
190
+ )
191
+ scores = self.antispoof.predict(image, bbs)
192
+ self.assertEqual(scores.shape, (3,))
193
+
194
+ def test_predict_empty_bounding_boxes(self):
195
+ scores = self.antispoof.predict(self.image, np.empty((0, 4)))
196
+ self.assertEqual(scores.shape, (0,))
197
+
198
+
163
199
  class TestEstimator(TestCase):
164
200
  """Test cases to test estimators.
165
201
 
@@ -8,6 +8,7 @@ import numpy as np
8
8
  from tests import TestCase, dlib, insightface
9
9
 
10
10
  from face_engine import FaceEngine, load_engine
11
+ from face_engine.exceptions import ModelNotFoundError
11
12
  from face_engine.models.basic_estimator import BasicEstimator
12
13
 
13
14
 
@@ -53,6 +54,18 @@ class TestFaceEnginePersistence(TestCase):
53
54
  self.assertEqual(engine.detector, self.test_engine.detector)
54
55
  self.assertEqual(engine.embedder, self.test_engine.embedder)
55
56
  self.assertEqual(engine.estimator, self.test_engine.estimator)
57
+ self.assertEqual(engine.antispoof, self.test_engine.antispoof)
58
+
59
+ def test_load_engine_pre31_file_without_antispoof(self):
60
+ self.test_engine.save(self.filename)
61
+ with open(self.filename) as file:
62
+ data = json.load(file)
63
+ # files saved by face-engine 3.0 have no antispoof key
64
+ del data["antispoof"]
65
+ with open(self.filename, "w") as file:
66
+ json.dump(data, file)
67
+ engine = load_engine(self.filename)
68
+ self.assertEqual(engine.antispoof, "abstract_antispoof")
56
69
 
57
70
  @unittest.skipUnless(dlib or insightface, "no face recognition backend installed")
58
71
  def test_load_engine_with_estimator_state(self):
@@ -73,6 +86,17 @@ class TestFaceEnginePersistence(TestCase):
73
86
  np.testing.assert_array_equal(bbs, restored_bbs)
74
87
  self.assertEqual(class_names, restored_class_names)
75
88
 
89
+ def test_load_engine_unregistered_model_raises(self):
90
+ self.test_engine.save(self.filename)
91
+ with open(self.filename) as file:
92
+ data = json.load(file)
93
+ # e.g. a plugin model whose module is not imported on this install
94
+ data["detector"] = "unregistered_plugin_detector"
95
+ with open(self.filename, "w") as file:
96
+ json.dump(data, file)
97
+ with self.assertRaises(ModelNotFoundError):
98
+ load_engine(self.filename)
99
+
76
100
  def test_load_engine_legacy_pickle_raises(self):
77
101
  with open("test_engine.p", "wb") as file:
78
102
  pickle.dump({"detector": "hog"}, file)
@@ -0,0 +1,34 @@
1
+ import io
2
+ import unittest
3
+
4
+ import numpy as np
5
+
6
+ from tests import TestCase
7
+
8
+ from face_engine.tools import imread
9
+
10
+
11
+ class TestImread(TestCase):
12
+
13
+ def test_imread_file_path(self):
14
+ image = imread(self.bubbles1)
15
+ self.assertEqual(image.dtype, np.uint8)
16
+ self.assertEqual(image.ndim, 3)
17
+
18
+ def test_imread_bytes(self):
19
+ with open(self.bubbles1, "rb") as file:
20
+ data = file.read()
21
+ np.testing.assert_array_equal(imread(data), imread(self.bubbles1))
22
+
23
+ def test_imread_file_object(self):
24
+ with open(self.bubbles1, "rb") as file:
25
+ data = file.read()
26
+ np.testing.assert_array_equal(imread(io.BytesIO(data)), imread(self.bubbles1))
27
+
28
+ def test_imread_mode_conversion(self):
29
+ image = imread(self.bubbles1, mode="L")
30
+ self.assertEqual(image.ndim, 2)
31
+
32
+
33
+ if __name__ == "__main__":
34
+ unittest.main()
File without changes
File without changes
File without changes
File without changes