mirror of
https://github.com/mudler/LocalAI.git
synced 2026-04-30 12:08:13 -04:00
* 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
345 lines
13 KiB
Python
345 lines
13 KiB
Python
"""Unit tests for the insightface gRPC backend.
|
|
|
|
The servicer is instantiated in-process (no gRPC channel) and driven
|
|
directly. Images come from insightface.data which ships with the pip
|
|
package — no external downloads.
|
|
|
|
Tests are parametrized over both engines (InsightFaceEngine and
|
|
OnnxDirectEngine) where applicable.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import os
|
|
import sys
|
|
import unittest
|
|
|
|
import cv2
|
|
import grpc
|
|
import numpy as np
|
|
|
|
sys.path.insert(0, os.path.dirname(__file__))
|
|
|
|
import backend_pb2 # noqa: E402
|
|
|
|
from backend import BackendServicer # noqa: E402
|
|
|
|
# OpenCV Zoo face ONNX files — downloaded on demand in OnnxDirectEngineTest
|
|
# to mirror LocalAI's gallery `files:` flow (the backend image itself
|
|
# doesn't ship model weights).
|
|
OPENCV_FILES = [
|
|
(
|
|
"face_detection_yunet_2023mar.onnx",
|
|
"https://github.com/opencv/opencv_zoo/raw/main/models/face_detection_yunet/face_detection_yunet_2023mar.onnx",
|
|
"8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4",
|
|
),
|
|
(
|
|
"face_recognition_sface_2021dec.onnx",
|
|
"https://github.com/opencv/opencv_zoo/raw/main/models/face_recognition_sface/face_recognition_sface_2021dec.onnx",
|
|
"0ba9fbfa01b5270c96627c4ef784da859931e02f04419c829e83484087c34e79",
|
|
),
|
|
]
|
|
|
|
# Silent-Face MiniFASNet ONNX files for antispoofing tests.
|
|
ANTISPOOF_FILES = [
|
|
(
|
|
"MiniFASNetV2.onnx",
|
|
"https://github.com/yakhyo/face-anti-spoofing/releases/download/weights/MiniFASNetV2.onnx",
|
|
"b32929adc2d9c34b9486f8c4c7bc97c1b69bc0ea9befefc380e4faae4e463907",
|
|
),
|
|
(
|
|
"MiniFASNetV1SE.onnx",
|
|
"https://github.com/yakhyo/face-anti-spoofing/releases/download/weights/MiniFASNetV1SE.onnx",
|
|
"ebab7f90c7833fbccd46d3a555410e78d969db5438e169b6524be444862b3676",
|
|
),
|
|
]
|
|
|
|
|
|
def _download_files(specs: list[tuple[str, str, str]], env_var: str, prefix: str) -> str | None:
|
|
"""Download a list of (filename, uri, sha256) into a directory.
|
|
|
|
Returns the directory, or None if any download failed.
|
|
"""
|
|
import hashlib
|
|
import tempfile
|
|
import urllib.request
|
|
|
|
root = os.environ.get(env_var) or tempfile.mkdtemp(prefix=prefix)
|
|
for filename, uri, sha256 in specs:
|
|
dest = os.path.join(root, filename)
|
|
if os.path.isfile(dest):
|
|
if hashlib.sha256(open(dest, "rb").read()).hexdigest() == sha256:
|
|
continue
|
|
try:
|
|
urllib.request.urlretrieve(uri, dest)
|
|
except Exception:
|
|
return None
|
|
if hashlib.sha256(open(dest, "rb").read()).hexdigest() != sha256:
|
|
return None
|
|
return root
|
|
|
|
|
|
def _encode(img: np.ndarray) -> str:
|
|
_, buf = cv2.imencode(".jpg", img)
|
|
return base64.b64encode(buf.tobytes()).decode("ascii")
|
|
|
|
|
|
def _load_insightface_samples() -> dict[str, str]:
|
|
"""Return {'t1': <b64>, 't2': <b64>} from insightface.data.get_image.
|
|
|
|
t1 is a group photo; t2 used to ship as a second sample but newer
|
|
insightface releases dropped it. We fall back to `Tom_Hanks_54745`
|
|
(also bundled) as a distinct second face.
|
|
"""
|
|
from insightface.data import get_image as ins_get_image
|
|
|
|
try:
|
|
second = ins_get_image("t2")
|
|
except AssertionError:
|
|
second = ins_get_image("Tom_Hanks_54745")
|
|
return {
|
|
"t1": _encode(ins_get_image("t1")),
|
|
"t2": _encode(second),
|
|
}
|
|
|
|
|
|
class _FakeContext:
|
|
"""Minimal stand-in for grpc.ServicerContext."""
|
|
|
|
def __init__(self) -> None:
|
|
self.code = None
|
|
self.details = None
|
|
|
|
def set_code(self, code):
|
|
self.code = code
|
|
|
|
def set_details(self, details):
|
|
self.details = details
|
|
|
|
|
|
class _Harness:
|
|
def __init__(self, servicer: BackendServicer) -> None:
|
|
self.svc = servicer
|
|
|
|
def health(self):
|
|
return self.svc.Health(backend_pb2.HealthMessage(), _FakeContext())
|
|
|
|
def load(self, options: list[str], model_path: str = ""):
|
|
return self.svc.LoadModel(
|
|
backend_pb2.ModelOptions(Model="test", Options=options, ModelPath=model_path),
|
|
_FakeContext(),
|
|
)
|
|
|
|
def detect(self, img_b64: str):
|
|
return self.svc.Detect(backend_pb2.DetectOptions(src=img_b64), _FakeContext())
|
|
|
|
def embed(self, img_b64: str):
|
|
ctx = _FakeContext()
|
|
res = self.svc.Embedding(
|
|
backend_pb2.PredictOptions(Images=[img_b64]),
|
|
ctx,
|
|
)
|
|
return res, ctx
|
|
|
|
def verify(self, a: str, b: str, threshold: float = 0.0, anti_spoofing: bool = False):
|
|
ctx = _FakeContext()
|
|
res = self.svc.FaceVerify(
|
|
backend_pb2.FaceVerifyRequest(
|
|
img1=a, img2=b, threshold=threshold, anti_spoofing=anti_spoofing
|
|
),
|
|
ctx,
|
|
)
|
|
return res, ctx
|
|
|
|
def analyze(self, img_b64: str, anti_spoofing: bool = False):
|
|
ctx = _FakeContext()
|
|
res = self.svc.FaceAnalyze(
|
|
backend_pb2.FaceAnalyzeRequest(img=img_b64, anti_spoofing=anti_spoofing),
|
|
ctx,
|
|
)
|
|
return res, ctx
|
|
|
|
|
|
class InsightFaceEngineTest(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.samples = _load_insightface_samples()
|
|
cls.harness = _Harness(BackendServicer())
|
|
load = cls.harness.load(["engine:insightface", "model_pack:buffalo_l"])
|
|
if not load.success:
|
|
raise unittest.SkipTest(f"LoadModel failed: {load.message}")
|
|
|
|
def test_health(self):
|
|
self.assertEqual(self.harness.health().message, b"OK")
|
|
|
|
def test_detect_finds_face(self):
|
|
res = self.harness.detect(self.samples["t1"])
|
|
self.assertGreater(len(res.Detections), 0)
|
|
for d in res.Detections:
|
|
self.assertEqual(d.class_name, "face")
|
|
self.assertGreater(d.width, 0)
|
|
self.assertGreater(d.height, 0)
|
|
|
|
def test_embedding_is_l2_normed(self):
|
|
res, ctx = self.harness.embed(self.samples["t1"])
|
|
self.assertIsNone(ctx.code, f"Embedding error: {ctx.details}")
|
|
self.assertEqual(len(res.embeddings), 512)
|
|
norm_sq = sum(x * x for x in res.embeddings)
|
|
self.assertAlmostEqual(norm_sq, 1.0, places=2)
|
|
|
|
def test_verify_same_image(self):
|
|
res, _ = self.harness.verify(self.samples["t1"], self.samples["t1"])
|
|
self.assertTrue(res.verified)
|
|
self.assertLess(res.distance, 0.05)
|
|
|
|
def test_verify_different_images(self):
|
|
# t1 vs t2 depict different groups of people — top face on each
|
|
# side is unlikely to match.
|
|
res, _ = self.harness.verify(self.samples["t1"], self.samples["t2"])
|
|
# We assert only that some numerical answer came back; the
|
|
# matches-or-not determination depends on which face each side
|
|
# picked and isn't a stable test assertion.
|
|
self.assertGreaterEqual(res.distance, 0.0)
|
|
|
|
def test_analyze_has_age_and_gender(self):
|
|
res, _ = self.harness.analyze(self.samples["t1"])
|
|
self.assertGreater(len(res.faces), 0)
|
|
for face in res.faces:
|
|
self.assertGreater(face.face_confidence, 0.0)
|
|
# Age should be populated for buffalo_l.
|
|
self.assertGreater(face.age, 0.0)
|
|
self.assertIn(face.dominant_gender, ("Man", "Woman"))
|
|
|
|
def test_antispoof_requested_without_model_fails(self):
|
|
# buffalo_l was loaded without antispoof options — requesting
|
|
# liveness should surface a clear FAILED_PRECONDITION instead of
|
|
# silently returning is_real=False.
|
|
_, ctx = self.harness.verify(
|
|
self.samples["t1"], self.samples["t1"], anti_spoofing=True
|
|
)
|
|
self.assertEqual(ctx.code, grpc.StatusCode.FAILED_PRECONDITION)
|
|
self.assertIn("anti_spoofing", ctx.details)
|
|
|
|
|
|
def _prepare_opencv_models_dir() -> str | None:
|
|
return _download_files(OPENCV_FILES, "OPENCV_FACE_MODELS_DIR", "opencv-face-")
|
|
|
|
|
|
def _prepare_antispoof_models_dir(extra_dir: str | None = None) -> str | None:
|
|
"""Download MiniFASNet ONNX files. If `extra_dir` is given, files
|
|
are placed there alongside any existing weights so a single
|
|
`model_path` can serve both detector/recognizer + antispoof.
|
|
"""
|
|
if extra_dir is not None:
|
|
os.environ.setdefault("ANTISPOOF_MODELS_DIR", extra_dir)
|
|
return _download_files(ANTISPOOF_FILES, "ANTISPOOF_MODELS_DIR", "antispoof-")
|
|
|
|
|
|
class OnnxDirectEngineTest(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.samples = _load_insightface_samples()
|
|
cls.model_dir = _prepare_opencv_models_dir()
|
|
if cls.model_dir is None:
|
|
raise unittest.SkipTest("OpenCV Zoo ONNX files could not be downloaded")
|
|
cls.harness = _Harness(BackendServicer())
|
|
load = cls.harness.load(
|
|
[
|
|
"engine:onnx_direct",
|
|
"detector_onnx:face_detection_yunet_2023mar.onnx",
|
|
"recognizer_onnx:face_recognition_sface_2021dec.onnx",
|
|
],
|
|
model_path=cls.model_dir,
|
|
)
|
|
if not load.success:
|
|
raise unittest.SkipTest(f"LoadModel failed: {load.message}")
|
|
|
|
def test_detect_finds_face(self):
|
|
res = self.harness.detect(self.samples["t1"])
|
|
self.assertGreater(len(res.Detections), 0)
|
|
for d in res.Detections:
|
|
self.assertEqual(d.class_name, "face")
|
|
|
|
def test_embedding_nonempty(self):
|
|
res, ctx = self.harness.embed(self.samples["t1"])
|
|
self.assertIsNone(ctx.code, f"Embedding error: {ctx.details}")
|
|
self.assertGreater(len(res.embeddings), 0)
|
|
|
|
def test_verify_same_image(self):
|
|
res, _ = self.harness.verify(self.samples["t1"], self.samples["t1"], threshold=0.4)
|
|
self.assertTrue(res.verified)
|
|
|
|
def test_analyze_returns_regions_without_demographics(self):
|
|
# OnnxDirectEngine intentionally doesn't populate age/gender.
|
|
res, _ = self.harness.analyze(self.samples["t1"])
|
|
self.assertGreater(len(res.faces), 0)
|
|
for face in res.faces:
|
|
self.assertEqual(face.dominant_gender, "")
|
|
self.assertEqual(face.age, 0.0)
|
|
|
|
|
|
class AntispoofingTest(unittest.TestCase):
|
|
"""End-to-end FaceVerify / FaceAnalyze with anti_spoofing=True.
|
|
|
|
Loads the OpenCV-Zoo (Apache-2.0) face engine alongside the Silent-Face
|
|
MiniFASNet ensemble. Real photos from insightface's bundled samples
|
|
are expected to come back as is_real=True with score above threshold.
|
|
A printed-photo style fake (the same photo re-encoded with heavy
|
|
JPEG and a synthetic moiré overlay) is expected to flip the verdict.
|
|
"""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
# Reuse one directory for both detector/recognizer + antispoof
|
|
# weights so a single LoadModel options block points at all of them.
|
|
opencv_dir = _prepare_opencv_models_dir()
|
|
if opencv_dir is None:
|
|
raise unittest.SkipTest("OpenCV Zoo ONNX files could not be downloaded")
|
|
antispoof_dir = _prepare_antispoof_models_dir(extra_dir=opencv_dir)
|
|
if antispoof_dir is None:
|
|
raise unittest.SkipTest("MiniFASNet ONNX files could not be downloaded")
|
|
|
|
# Antispoof only needs a single real-face sample; `t1` ships in
|
|
# insightface.data across every release.
|
|
from insightface.data import get_image as ins_get_image
|
|
|
|
cls.samples = {"t1": _encode(ins_get_image("t1"))}
|
|
cls.harness = _Harness(BackendServicer())
|
|
load = cls.harness.load(
|
|
[
|
|
"engine:onnx_direct",
|
|
"detector_onnx:face_detection_yunet_2023mar.onnx",
|
|
"recognizer_onnx:face_recognition_sface_2021dec.onnx",
|
|
"antispoof_v2_onnx:MiniFASNetV2.onnx",
|
|
"antispoof_v1se_onnx:MiniFASNetV1SE.onnx",
|
|
],
|
|
model_path=opencv_dir,
|
|
)
|
|
if not load.success:
|
|
raise unittest.SkipTest(f"LoadModel failed: {load.message}")
|
|
|
|
def test_verify_returns_per_image_liveness(self):
|
|
res, ctx = self.harness.verify(
|
|
self.samples["t1"], self.samples["t1"], threshold=0.4, anti_spoofing=True
|
|
)
|
|
self.assertIsNone(ctx.code, f"FaceVerify error: {ctx.details}")
|
|
# Score is the averaged "real" probability; both images are the
|
|
# same real photo so should both populate non-zero scores.
|
|
self.assertGreater(res.img1_antispoof_score, 0.0)
|
|
self.assertGreater(res.img2_antispoof_score, 0.0)
|
|
# Self-comparison: similarity must still match; final verified
|
|
# combines similarity AND liveness, so we only assert it's set.
|
|
self.assertIsInstance(res.verified, bool)
|
|
|
|
def test_analyze_populates_is_real_and_score(self):
|
|
res, ctx = self.harness.analyze(self.samples["t1"], anti_spoofing=True)
|
|
self.assertIsNone(ctx.code, f"FaceAnalyze error: {ctx.details}")
|
|
self.assertGreater(len(res.faces), 0)
|
|
for face in res.faces:
|
|
self.assertGreaterEqual(face.antispoof_score, 0.0)
|
|
self.assertLessEqual(face.antispoof_score, 1.0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|