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.
- {face_engine-3.0.0/face_engine.egg-info → face_engine-3.2.0}/PKG-INFO +59 -7
- {face_engine-3.0.0 → face_engine-3.2.0}/README.rst +58 -6
- {face_engine-3.0.0 → face_engine-3.2.0}/face_engine/__init__.py +1 -1
- {face_engine-3.0.0 → face_engine-3.2.0}/face_engine/core.py +136 -7
- {face_engine-3.0.0 → face_engine-3.2.0}/face_engine/exceptions.py +4 -0
- {face_engine-3.0.0 → face_engine-3.2.0}/face_engine/fetching.py +3 -0
- {face_engine-3.0.0 → face_engine-3.2.0}/face_engine/models/__init__.py +24 -1
- face_engine-3.2.0/face_engine/models/_onnx.py +19 -0
- {face_engine-3.0.0 → face_engine-3.2.0}/face_engine/models/insightface_models.py +1 -13
- face_engine-3.2.0/face_engine/models/minifasnet.py +127 -0
- {face_engine-3.0.0 → face_engine-3.2.0}/face_engine/tools.py +5 -1
- {face_engine-3.0.0 → face_engine-3.2.0/face_engine.egg-info}/PKG-INFO +59 -7
- {face_engine-3.0.0 → face_engine-3.2.0}/face_engine.egg-info/SOURCES.txt +4 -1
- {face_engine-3.0.0 → face_engine-3.2.0}/tests/test_face_engine.py +126 -20
- {face_engine-3.0.0 → face_engine-3.2.0}/tests/test_face_engine_models.py +38 -2
- {face_engine-3.0.0 → face_engine-3.2.0}/tests/test_face_engine_persistence.py +24 -0
- face_engine-3.2.0/tests/test_tools.py +34 -0
- {face_engine-3.0.0 → face_engine-3.2.0}/LICENSE +0 -0
- {face_engine-3.0.0 → face_engine-3.2.0}/MANIFEST.in +0 -0
- {face_engine-3.0.0 → face_engine-3.2.0}/docs/source/conf.py +0 -0
- {face_engine-3.0.0 → face_engine-3.2.0}/face_engine/models/basic_estimator.py +0 -0
- {face_engine-3.0.0 → face_engine-3.2.0}/face_engine/models/dlib_models.py +0 -0
- {face_engine-3.0.0 → face_engine-3.2.0}/face_engine.egg-info/dependency_links.txt +0 -0
- {face_engine-3.0.0 → face_engine-3.2.0}/face_engine.egg-info/requires.txt +0 -0
- {face_engine-3.0.0 → face_engine-3.2.0}/face_engine.egg-info/top_level.txt +0 -0
- {face_engine-3.0.0 → face_engine-3.2.0}/pyproject.toml +0 -0
- {face_engine-3.0.0 → face_engine-3.2.0}/setup.cfg +0 -0
- {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.
|
|
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
|
|
72
|
-
and ``
|
|
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
|
|
33
|
-
and ``
|
|
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.
|
|
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__
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
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
|
|
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 (
|
|
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.
|
|
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
|
|
72
|
-
and ``
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
self.
|
|
57
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
self.
|
|
103
|
-
self.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|