Skip to content

Commit f5eb13d

Browse files
authored
feat(insightface): add antispoofing (liveness) detection (#9515)
* feat(insightface): add antispoofing (liveness) detection Light up the anti_spoofing flag that was parked during the first pass. Both FaceVerify and FaceAnalyze now run the Silent-Face MiniFASNetV2 + MiniFASNetV1SE ensemble (~4 MB, Apache 2.0, CPU <10ms) when the flag is set. Failed liveness on either image vetoes FaceVerify regardless of embedding similarity. Every insightface* gallery entry now ships the MiniFASNet ONNX weights so existing packs light up after reinstall. Setting the flag against a model without the MiniFASNet files returns FAILED_PRECONDITION (HTTP 412) with a clear install message — no silent is_real=false. FaceVerifyResponse gained per-image img{1,2}_is_real and img{1,2}_antispoof_score (proto 9-12); FaceAnalysis's existing is_real/antispoof_score fields are now populated. Schema fields are pointers so they are fully absent from the JSON response when anti_spoofing was not requested — avoids collapsing "not checked" with "checked and fake" under Go's omitempty on bool. Validated end-to-end over HTTP against a local install: - verify + anti_spoofing, both real -> verified=true, score ~0.76 - verify + anti_spoofing, img2 spoof -> verified=false, img2_is_real=false - analyze + anti_spoofing -> is_real and score per face - flag against model without MiniFASNet -> HTTP 412 fail-loud Assisted-by: Claude:claude-opus-4-7 go vet * test(insightface): wire test target into test-extra The root Makefile's `test-extra` already runs `$(MAKE) -C backend/python/insightface test`, but the backend's Makefile never defined the target — so the command silently errored and the suite was never executed in CI. Adding the two-line target (matching ace-step/Makefile) hooks `test.sh` → `runUnittests` → `python -m unittest test.py`, which discovers both the pre-existing engine classes (InsightFaceEngineTest, OnnxDirectEngineTest) and the new AntispoofingTest. Each class skips gracefully when its weights can't be downloaded from a network-restricted runner. Assisted-by: Claude:claude-opus-4-7 * test(insightface): exercise antispoofing in e2e-backends (both paths) Add a `face_antispoof` capability to the Ginkgo e2e suite and extend the existing FaceVerify + FaceAnalyze specs with liveness assertions covering BOTH paths: real fixture -> is_real=true, score>0, verified stays true spoof fixture -> is_real=false, verified vetoed to false The spoof fixture is upstream's own `image_F2.jpg` (via the yakhyo mirror) — verified locally against the MiniFASNetV2+V1SE ensemble to classify as is_real=false with score ~0.013. That makes the assertion deterministic across CI runs; synthetic/derived spoofs fool the model unpredictably and would be flaky. Makefile wires it up end-to-end: - New INSIGHTFACE_ANTISPOOF_* cache dir + two ONNX downloads with pinned SHAs, matching the gallery entries. - insightface-antispoof-models target shared by both backend configs. - FACE_SPOOF_IMAGE_URL passed via BACKEND_TEST_FACE_SPOOF_IMAGE_URL. - Both e2e targets (buffalo-sc + opencv) now: * depend on insightface-antispoof-models * pass antispoof_v2_onnx / antispoof_v1se_onnx in BACKEND_TEST_OPTIONS * include face_antispoof in BACKEND_TEST_CAPS backend_test.go adds the new capability constant and a faceSpoofFile fixture resolved the same way as faceFile1/2/3. Spoof assertions are gated on both capFaceAntispoof AND faceSpoofFile being set, so a test config that omits the spoof fixture degrades gracefully to "real path only" instead of failing. Assisted-by: Claude:claude-opus-4-7 go vet
1 parent c1f923b commit f5eb13d

12 files changed

Lines changed: 641 additions & 73 deletions

File tree

Makefile

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,11 @@ test-extra-backend-tinygrad-all: \
623623
FACE_IMAGE_1_URL ?= https://github.com/deepinsight/insightface/raw/master/python-package/insightface/data/images/t1.jpg
624624
FACE_IMAGE_2_URL ?= https://github.com/deepinsight/insightface/raw/master/python-package/insightface/data/images/t1.jpg
625625
FACE_IMAGE_3_URL ?= https://github.com/deepinsight/insightface/raw/master/python-package/insightface/data/images/mask_white.jpg
626+
## Known spoof fixture used by the face_antispoof e2e cap. This is
627+
## upstream's own `image_F2.jpg` (Silent-Face repo, via yakhyo mirror)
628+
## — verified to classify as is_real=false with score < 0.05 on the
629+
## MiniFASNetV2 + MiniFASNetV1SE ensemble.
630+
FACE_SPOOF_IMAGE_URL ?= https://github.com/yakhyo/face-anti-spoofing/raw/main/assets/image_F2.jpg
626631

627632
## Host-side cache for the OpenCV Zoo face ONNX files used by the
628633
## opencv e2e target. The backend image no longer bakes model weights —
@@ -646,6 +651,15 @@ INSIGHTFACE_BUFFALO_SC_DIR := /tmp/localai-insightface-buffalo-sc-cache
646651
INSIGHTFACE_BUFFALO_SC_URL := https://github.com/deepinsight/insightface/releases/download/v0.7/buffalo_sc.zip
647652
INSIGHTFACE_BUFFALO_SC_SHA := 57d31b56b6ffa911c8a73cfc1707c73cab76efe7f13b675a05223bf42de47c72
648653

654+
## Silent-Face antispoofing (MiniFASNetV2 + MiniFASNetV1SE) — shared
655+
## between the buffalo_sc and opencv e2e targets. Both ONNX files are
656+
## ~1.7MB, Apache 2.0. URLs + SHAs mirror the gallery entries.
657+
INSIGHTFACE_ANTISPOOF_DIR := /tmp/localai-insightface-antispoof-cache
658+
INSIGHTFACE_ANTISPOOF_V2_URL := https://github.com/yakhyo/face-anti-spoofing/releases/download/weights/MiniFASNetV2.onnx
659+
INSIGHTFACE_ANTISPOOF_V2_SHA := b32929adc2d9c34b9486f8c4c7bc97c1b69bc0ea9befefc380e4faae4e463907
660+
INSIGHTFACE_ANTISPOOF_V1SE_URL := https://github.com/yakhyo/face-anti-spoofing/releases/download/weights/MiniFASNetV1SE.onnx
661+
INSIGHTFACE_ANTISPOOF_V1SE_SHA := ebab7f90c7833fbccd46d3a555410e78d969db5438e169b6524be444862b3676
662+
649663
.PHONY: insightface-opencv-models
650664
insightface-opencv-models:
651665
@mkdir -p $(INSIGHTFACE_OPENCV_DIR)
@@ -660,6 +674,20 @@ insightface-opencv-models:
660674
echo "$(INSIGHTFACE_OPENCV_SFACE_SHA) $(INSIGHTFACE_OPENCV_DIR)/sface.onnx" | sha256sum -c; \
661675
fi
662676

677+
.PHONY: insightface-antispoof-models
678+
insightface-antispoof-models:
679+
@mkdir -p $(INSIGHTFACE_ANTISPOOF_DIR)
680+
@if [ "$$(sha256sum $(INSIGHTFACE_ANTISPOOF_DIR)/MiniFASNetV2.onnx 2>/dev/null | awk '{print $$1}')" != "$(INSIGHTFACE_ANTISPOOF_V2_SHA)" ]; then \
681+
echo "Fetching MiniFASNetV2..."; \
682+
curl -fsSL -o $(INSIGHTFACE_ANTISPOOF_DIR)/MiniFASNetV2.onnx $(INSIGHTFACE_ANTISPOOF_V2_URL); \
683+
echo "$(INSIGHTFACE_ANTISPOOF_V2_SHA) $(INSIGHTFACE_ANTISPOOF_DIR)/MiniFASNetV2.onnx" | sha256sum -c; \
684+
fi
685+
@if [ "$$(sha256sum $(INSIGHTFACE_ANTISPOOF_DIR)/MiniFASNetV1SE.onnx 2>/dev/null | awk '{print $$1}')" != "$(INSIGHTFACE_ANTISPOOF_V1SE_SHA)" ]; then \
686+
echo "Fetching MiniFASNetV1SE..."; \
687+
curl -fsSL -o $(INSIGHTFACE_ANTISPOOF_DIR)/MiniFASNetV1SE.onnx $(INSIGHTFACE_ANTISPOOF_V1SE_URL); \
688+
echo "$(INSIGHTFACE_ANTISPOOF_V1SE_SHA) $(INSIGHTFACE_ANTISPOOF_DIR)/MiniFASNetV1SE.onnx" | sha256sum -c; \
689+
fi
690+
663691
.PHONY: insightface-buffalo-sc-models
664692
insightface-buffalo-sc-models:
665693
@mkdir -p $(INSIGHTFACE_BUFFALO_SC_DIR)
@@ -682,14 +710,15 @@ insightface-buffalo-sc-models:
682710
## the e2e suite drives LoadModel directly without going through
683711
## LocalAI's gallery flow (which is what would normally populate
684712
## ModelPath and in turn the engine's `_model_dir` option).
685-
test-extra-backend-insightface-buffalo-sc: docker-build-insightface insightface-buffalo-sc-models
713+
test-extra-backend-insightface-buffalo-sc: docker-build-insightface insightface-buffalo-sc-models insightface-antispoof-models
686714
BACKEND_IMAGE=local-ai-backend:insightface \
687715
BACKEND_TEST_MODEL_NAME=insightface-buffalo-sc \
688-
BACKEND_TEST_OPTIONS=engine:insightface,model_pack:buffalo_sc,root:$(INSIGHTFACE_BUFFALO_SC_DIR) \
689-
BACKEND_TEST_CAPS=health,load,face_detect,face_embed,face_verify \
716+
BACKEND_TEST_OPTIONS=engine:insightface,model_pack:buffalo_sc,root:$(INSIGHTFACE_BUFFALO_SC_DIR),antispoof_v2_onnx:$(INSIGHTFACE_ANTISPOOF_DIR)/MiniFASNetV2.onnx,antispoof_v1se_onnx:$(INSIGHTFACE_ANTISPOOF_DIR)/MiniFASNetV1SE.onnx \
717+
BACKEND_TEST_CAPS=health,load,face_detect,face_embed,face_verify,face_antispoof \
690718
BACKEND_TEST_FACE_IMAGE_1_URL=$(FACE_IMAGE_1_URL) \
691719
BACKEND_TEST_FACE_IMAGE_2_URL=$(FACE_IMAGE_2_URL) \
692720
BACKEND_TEST_FACE_IMAGE_3_URL=$(FACE_IMAGE_3_URL) \
721+
BACKEND_TEST_FACE_SPOOF_IMAGE_URL=$(FACE_SPOOF_IMAGE_URL) \
693722
BACKEND_TEST_VERIFY_DISTANCE_CEILING=0.55 \
694723
$(MAKE) test-extra-backend
695724

@@ -698,14 +727,15 @@ test-extra-backend-insightface-buffalo-sc: docker-build-insightface insightface-
698727
## pre-fetched on the host via the insightface-opencv-models target and
699728
## passed as absolute paths, since the e2e suite drives LoadModel
700729
## directly without going through LocalAI's gallery flow.
701-
test-extra-backend-insightface-opencv: docker-build-insightface insightface-opencv-models
730+
test-extra-backend-insightface-opencv: docker-build-insightface insightface-opencv-models insightface-antispoof-models
702731
BACKEND_IMAGE=local-ai-backend:insightface \
703732
BACKEND_TEST_MODEL_NAME=insightface-opencv \
704-
BACKEND_TEST_OPTIONS=engine:onnx_direct,detector_onnx:$(INSIGHTFACE_OPENCV_DIR)/yunet.onnx,recognizer_onnx:$(INSIGHTFACE_OPENCV_DIR)/sface.onnx \
705-
BACKEND_TEST_CAPS=health,load,face_detect,face_embed,face_verify \
733+
BACKEND_TEST_OPTIONS=engine:onnx_direct,detector_onnx:$(INSIGHTFACE_OPENCV_DIR)/yunet.onnx,recognizer_onnx:$(INSIGHTFACE_OPENCV_DIR)/sface.onnx,antispoof_v2_onnx:$(INSIGHTFACE_ANTISPOOF_DIR)/MiniFASNetV2.onnx,antispoof_v1se_onnx:$(INSIGHTFACE_ANTISPOOF_DIR)/MiniFASNetV1SE.onnx \
734+
BACKEND_TEST_CAPS=health,load,face_detect,face_embed,face_verify,face_antispoof \
706735
BACKEND_TEST_FACE_IMAGE_1_URL=$(FACE_IMAGE_1_URL) \
707736
BACKEND_TEST_FACE_IMAGE_2_URL=$(FACE_IMAGE_2_URL) \
708737
BACKEND_TEST_FACE_IMAGE_3_URL=$(FACE_IMAGE_3_URL) \
738+
BACKEND_TEST_FACE_SPOOF_IMAGE_URL=$(FACE_SPOOF_IMAGE_URL) \
709739
BACKEND_TEST_VERIFY_DISTANCE_CEILING=0.55 \
710740
$(MAKE) test-extra-backend
711741

backend/backend.proto

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ message FaceVerifyRequest {
493493
string img1 = 1; // base64-encoded image
494494
string img2 = 2; // base64-encoded image
495495
float threshold = 3; // cosine-distance threshold; 0 = use backend default
496-
bool anti_spoofing = 4; // reserved for future MiniFASNet bolt-on
496+
bool anti_spoofing = 4; // run MiniFASNet liveness on each image; failed liveness forces verified=false
497497
}
498498

499499
message FaceVerifyResponse {
@@ -505,6 +505,10 @@ message FaceVerifyResponse {
505505
FacialArea img1_area = 6;
506506
FacialArea img2_area = 7;
507507
float processing_time_ms = 8;
508+
bool img1_is_real = 9; // anti-spoofing result when enabled
509+
float img1_antispoof_score = 10;
510+
bool img2_is_real = 11;
511+
float img2_antispoof_score = 12;
508512
}
509513

510514
message FaceAnalyzeRequest {

backend/python/insightface/Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,6 @@ protogen-clean:
1111
.PHONY: clean
1212
clean: protogen-clean
1313
rm -rf venv __pycache__
14+
15+
test: install
16+
bash test.sh

backend/python/insightface/backend.py

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -180,23 +180,57 @@ def FaceVerify(self, request, context):
180180
verified = distance < threshold
181181
confidence = max(0.0, min(100.0, (1.0 - distance / threshold) * 100.0)) if threshold > 0 else 0.0
182182

183-
def _region(img) -> backend_pb2.FacialArea:
183+
# Detect once per image — region is needed for the response and
184+
# potentially for the antispoof crop. Returns the highest-score face.
185+
def _best_detection(img):
184186
dets = self.engine.detect(img)
185187
if not dets:
188+
return None
189+
return max(dets, key=lambda d: d.score)
190+
191+
def _region(det) -> backend_pb2.FacialArea:
192+
if det is None:
186193
return backend_pb2.FacialArea()
187-
best = max(dets, key=lambda d: d.score)
188-
x1, y1, x2, y2 = best.bbox
194+
x1, y1, x2, y2 = det.bbox
189195
return backend_pb2.FacialArea(x=x1, y=y1, w=x2 - x1, h=y2 - y1)
190196

197+
det1 = _best_detection(img1)
198+
det2 = _best_detection(img2)
199+
200+
img1_is_real = False
201+
img1_score = 0.0
202+
img2_is_real = False
203+
img2_score = 0.0
204+
if request.anti_spoofing:
205+
spoof1 = self.engine.antispoof(img1, det1.bbox) if det1 is not None else None
206+
spoof2 = self.engine.antispoof(img2, det2.bbox) if det2 is not None else None
207+
if spoof1 is None or spoof2 is None:
208+
context.set_code(grpc.StatusCode.FAILED_PRECONDITION)
209+
context.set_details(
210+
"anti_spoofing requested but no antispoof model is loaded — "
211+
"install `silent-face-antispoofing` or pick a gallery entry "
212+
"that bundles MiniFASNet weights"
213+
)
214+
return backend_pb2.FaceVerifyResponse()
215+
img1_is_real, img1_score = spoof1.is_real, spoof1.score
216+
img2_is_real, img2_score = spoof2.is_real, spoof2.score
217+
# Failed liveness vetoes verification regardless of similarity.
218+
if not (img1_is_real and img2_is_real):
219+
verified = False
220+
191221
return backend_pb2.FaceVerifyResponse(
192222
verified=verified,
193223
distance=float(distance),
194224
threshold=float(threshold),
195225
confidence=float(confidence),
196226
model=self.model_name or self.engine_name,
197-
img1_area=_region(img1),
198-
img2_area=_region(img2),
227+
img1_area=_region(det1),
228+
img2_area=_region(det2),
199229
processing_time_ms=float((time.time() - start) * 1000.0),
230+
img1_is_real=img1_is_real,
231+
img1_antispoof_score=float(img1_score),
232+
img2_is_real=img2_is_real,
233+
img2_antispoof_score=float(img2_score),
200234
)
201235

202236
def FaceAnalyze(self, request, context):
@@ -223,6 +257,19 @@ def FaceAnalyze(self, request, context):
223257
fa.dominant_gender = attrs.dominant_gender
224258
for k, v in attrs.gender.items():
225259
fa.gender[k] = float(v)
260+
if request.anti_spoofing:
261+
bbox = (float(x), float(y), float(x + w), float(y + h))
262+
spoof = self.engine.antispoof(img, bbox)
263+
if spoof is None:
264+
context.set_code(grpc.StatusCode.FAILED_PRECONDITION)
265+
context.set_details(
266+
"anti_spoofing requested but no antispoof model is loaded — "
267+
"install `silent-face-antispoofing` or pick a gallery entry "
268+
"that bundles MiniFASNet weights"
269+
)
270+
return backend_pb2.FaceAnalyzeResponse()
271+
fa.is_real = spoof.is_real
272+
fa.antispoof_score = float(spoof.score)
226273
faces.append(fa)
227274
return backend_pb2.FaceAnalyzeResponse(faces=faces)
228275

backend/python/insightface/engines.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,134 @@ class FaceAttributes:
4141
gender: dict[str, float] = field(default_factory=dict)
4242

4343

44+
@dataclass
45+
class SpoofResult:
46+
is_real: bool
47+
score: float # averaged probability of the "real" class, 0.0-1.0
48+
49+
4450
class FaceEngine(Protocol):
4551
"""Minimal interface every engine must implement."""
4652

4753
def prepare(self, options: dict[str, str]) -> None: ...
4854
def detect(self, img: np.ndarray) -> list[FaceDetection]: ...
4955
def embed(self, img: np.ndarray) -> np.ndarray | None: ...
5056
def analyze(self, img: np.ndarray) -> list[FaceAttributes]: ...
57+
# Optional: returns None when no antispoof model is loaded.
58+
def antispoof(self, img: np.ndarray, bbox: tuple[float, float, float, float]) -> SpoofResult | None: ...
59+
60+
61+
# ─── Antispoofer (Silent-Face MiniFASNet) ──────────────────────────────
62+
63+
class Antispoofer:
64+
"""Liveness detector using the Silent-Face MiniFASNet ensemble.
65+
66+
Loads up to two ONNX exports (MiniFASNetV2 at scale 2.7 and
67+
MiniFASNetV1SE at scale 4.0). Both are 80x80 BGR-float32-input
68+
classifiers with 3 output logits where index 1 = "real". When both
69+
are loaded, softmax outputs are averaged before argmax — the same
70+
ensembling the upstream `test.py` does.
71+
72+
Preprocessing matches yakhyo/face-anti-spoofing's reference impl:
73+
each model gets its own scale-expanded crop centered on the face
74+
bbox, resized to 80x80, fed straight as float32 BGR (no /255, no
75+
mean/std). See `_crop_face` for the bbox math.
76+
77+
A single model also works (the missing one is simply skipped).
78+
"""
79+
80+
INPUT_SIZE = (80, 80) # h, w
81+
REAL_CLASS_IDX = 1
82+
83+
def __init__(self) -> None:
84+
self._sessions: list[tuple[Any, float, str, str]] = [] # (session, scale, input_name, output_name)
85+
self.threshold: float = 0.5
86+
87+
def load(self, model_paths: list[tuple[str, float]], threshold: float = 0.5) -> None:
88+
"""Load one or more (path, scale) pairs."""
89+
import onnxruntime as ort
90+
91+
providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
92+
for path, scale in model_paths:
93+
session = ort.InferenceSession(path, providers=providers)
94+
input_name = session.get_inputs()[0].name
95+
output_name = session.get_outputs()[0].name
96+
self._sessions.append((session, float(scale), input_name, output_name))
97+
self.threshold = float(threshold)
98+
99+
@property
100+
def loaded(self) -> bool:
101+
return bool(self._sessions)
102+
103+
def _crop_face(self, img: np.ndarray, bbox: tuple[float, float, float, float], scale: float) -> np.ndarray:
104+
# bbox is (x1, y1, x2, y2) in source-image coordinates.
105+
src_h, src_w = img.shape[:2]
106+
x1, y1, x2, y2 = bbox
107+
box_w = max(1.0, x2 - x1)
108+
box_h = max(1.0, y2 - y1)
109+
110+
# Clamp scale so the expanded crop fits inside the source image.
111+
scale = min((src_h - 1) / box_h, (src_w - 1) / box_w, scale)
112+
new_w = box_w * scale
113+
new_h = box_h * scale
114+
115+
cx = x1 + box_w / 2.0
116+
cy = y1 + box_h / 2.0
117+
118+
cx1 = max(0, int(cx - new_w / 2.0))
119+
cy1 = max(0, int(cy - new_h / 2.0))
120+
cx2 = min(src_w - 1, int(cx + new_w / 2.0))
121+
cy2 = min(src_h - 1, int(cy + new_h / 2.0))
122+
123+
cropped = img[cy1 : cy2 + 1, cx1 : cx2 + 1]
124+
if cropped.size == 0:
125+
cropped = img
126+
out_h, out_w = self.INPUT_SIZE
127+
return cv2.resize(cropped, (out_w, out_h))
128+
129+
@staticmethod
130+
def _softmax(x: np.ndarray) -> np.ndarray:
131+
e = np.exp(x - np.max(x, axis=1, keepdims=True))
132+
return e / e.sum(axis=1, keepdims=True)
133+
134+
def predict(self, img: np.ndarray, bbox: tuple[float, float, float, float]) -> SpoofResult:
135+
if not self._sessions:
136+
raise RuntimeError("Antispoofer.predict called with no models loaded")
137+
accum = np.zeros((1, 3), dtype=np.float32)
138+
for session, scale, input_name, output_name in self._sessions:
139+
face = self._crop_face(img, bbox, scale).astype(np.float32)
140+
tensor = np.transpose(face, (2, 0, 1))[np.newaxis, ...]
141+
logits = session.run([output_name], {input_name: tensor})[0]
142+
accum += self._softmax(logits)
143+
accum /= float(len(self._sessions))
144+
real_prob = float(accum[0, self.REAL_CLASS_IDX])
145+
is_real = int(np.argmax(accum)) == self.REAL_CLASS_IDX and real_prob >= self.threshold
146+
return SpoofResult(is_real=is_real, score=real_prob)
147+
148+
149+
def _build_antispoofer(options: dict[str, str], model_dir: str | None) -> Antispoofer | None:
150+
"""Instantiate an Antispoofer from option keys, or return None.
151+
152+
Recognised options:
153+
antispoof_v2_onnx — path/filename of MiniFASNetV2 (scale 2.7)
154+
antispoof_v1se_onnx — path/filename of MiniFASNetV1SE (scale 4.0)
155+
antispoof_threshold — real-class probability threshold, default 0.5
156+
157+
Either or both can be provided. Returns None when neither is set.
158+
"""
159+
pairs: list[tuple[str, float]] = []
160+
v2 = options.get("antispoof_v2_onnx", "")
161+
if v2:
162+
pairs.append((_resolve_model_path(v2, model_dir=model_dir), 2.7))
163+
v1se = options.get("antispoof_v1se_onnx", "")
164+
if v1se:
165+
pairs.append((_resolve_model_path(v1se, model_dir=model_dir), 4.0))
166+
if not pairs:
167+
return None
168+
threshold = float(options.get("antispoof_threshold", "0.5"))
169+
spoofer = Antispoofer()
170+
spoofer.load(pairs, threshold=threshold)
171+
return spoofer
51172

52173

53174
# ─── InsightFaceEngine ────────────────────────────────────────────────
@@ -80,6 +201,7 @@ def __init__(self) -> None:
80201
self.det_size: tuple[int, int] = (640, 640)
81202
self.det_thresh: float = 0.5
82203
self._providers: list[str] = ["CPUExecutionProvider"]
204+
self._antispoofer: Antispoofer | None = None
83205

84206
def prepare(self, options: dict[str, str]) -> None:
85207
import glob
@@ -90,6 +212,7 @@ def prepare(self, options: dict[str, str]) -> None:
90212
self.model_pack = options.get("model_pack", "buffalo_l")
91213
self.det_size = _parse_det_size(options.get("det_size", "640x640"))
92214
self.det_thresh = float(options.get("det_thresh", "0.5"))
215+
self._antispoofer = _build_antispoofer(options, options.get("_model_dir"))
93216

94217
pack_dir = _locate_insightface_pack(options, self.model_pack)
95218
if pack_dir is None:
@@ -187,6 +310,11 @@ def analyze(self, img: np.ndarray) -> list[FaceAttributes]:
187310
out.append(attrs)
188311
return out
189312

313+
def antispoof(self, img: np.ndarray, bbox: tuple[float, float, float, float]) -> SpoofResult | None:
314+
if self._antispoofer is None or not self._antispoofer.loaded:
315+
return None
316+
return self._antispoofer.predict(img, bbox)
317+
190318

191319
# ─── OnnxDirectEngine ─────────────────────────────────────────────────
192320

@@ -206,6 +334,7 @@ def __init__(self) -> None:
206334
self.det_thresh: float = 0.5
207335
self._detector: Any = None
208336
self._recognizer: Any = None
337+
self._antispoofer: Antispoofer | None = None
209338

210339
def prepare(self, options: dict[str, str]) -> None:
211340
raw_det = options.get("detector_onnx", "")
@@ -219,6 +348,7 @@ def prepare(self, options: dict[str, str]) -> None:
219348
self.recognizer_path = _resolve_model_path(raw_rec, model_dir=model_dir)
220349
self.input_size = _parse_det_size(options.get("det_size", "320x320"))
221350
self.det_thresh = float(options.get("det_thresh", "0.5"))
351+
self._antispoofer = _build_antispoofer(options, model_dir)
222352

223353
# YuNet is a fixed-size detector; size is reset per detect() call to
224354
# match the input frame.
@@ -286,6 +416,11 @@ def analyze(self, img: np.ndarray) -> list[FaceAttributes]:
286416
for d in self.detect(img)
287417
]
288418

419+
def antispoof(self, img: np.ndarray, bbox: tuple[float, float, float, float]) -> SpoofResult | None:
420+
if self._antispoofer is None or not self._antispoofer.loaded:
421+
return None
422+
return self._antispoofer.predict(img, bbox)
423+
289424

290425
# ─── helpers ──────────────────────────────────────────────────────────
291426

0 commit comments

Comments
 (0)