mirror of
https://github.com/mudler/LocalAI
synced 2026-05-24 09:28:23 +00:00
265 lines
9.1 KiB
Python
265 lines
9.1 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Smoke-test every face recognition model configuration shipped in the
|
||
|
|
gallery. Simulates what LocalAI does at runtime: for each config, sets
|
||
|
|
up a models directory, fetches any required files via URL (as the
|
||
|
|
gallery's `files:` list would), then loads + detects + embeds via the
|
||
|
|
in-process BackendServicer — matching the gRPC surface end users hit.
|
||
|
|
|
||
|
|
Run inside the built backend image (venv already has insightface /
|
||
|
|
onnxruntime / opencv-python-headless):
|
||
|
|
|
||
|
|
python smoke.py
|
||
|
|
|
||
|
|
Network is required for the insightface packs (fetched via upstream's
|
||
|
|
FaceAnalysis auto-download at first LoadModel) and for downloading
|
||
|
|
the OpenCV Zoo ONNX files on first run.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import base64
|
||
|
|
import hashlib
|
||
|
|
import os
|
||
|
|
import sys
|
||
|
|
import traceback
|
||
|
|
import urllib.request
|
||
|
|
|
||
|
|
import cv2
|
||
|
|
import numpy as np
|
||
|
|
|
||
|
|
sys.path.insert(0, os.path.dirname(__file__))
|
||
|
|
|
||
|
|
import backend_pb2 # noqa: E402
|
||
|
|
from backend import BackendServicer # noqa: E402
|
||
|
|
|
||
|
|
|
||
|
|
# Gallery `files:` for the OpenCV variants — same URIs + SHA-256s as
|
||
|
|
# gallery/index.yaml lists. Tuples: (filename, uri, sha256).
|
||
|
|
OPENCV_FILES = {
|
||
|
|
"fp32": [
|
||
|
|
(
|
||
|
|
"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",
|
||
|
|
),
|
||
|
|
],
|
||
|
|
"int8": [
|
||
|
|
(
|
||
|
|
"face_detection_yunet_2023mar_int8.onnx",
|
||
|
|
"https://github.com/opencv/opencv_zoo/raw/main/models/face_detection_yunet/face_detection_yunet_2023mar_int8.onnx",
|
||
|
|
"321aa5a6afabf7ecc46a3d06bfab2b579dc96eb5c3be7edd365fa04502ad9294",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
"face_recognition_sface_2021dec_int8.onnx",
|
||
|
|
"https://github.com/opencv/opencv_zoo/raw/main/models/face_recognition_sface/face_recognition_sface_2021dec_int8.onnx",
|
||
|
|
"2b0e941e6f16cc048c20aee0c8e31f569118f65d702914540f7bfdc14048d78a",
|
||
|
|
),
|
||
|
|
],
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
CONFIGS = [
|
||
|
|
{
|
||
|
|
"name": "insightface-buffalo-l",
|
||
|
|
"options": ["engine:insightface", "model_pack:buffalo_l"],
|
||
|
|
"has_analyze": True,
|
||
|
|
"needs_opencv_files": None,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"name": "insightface-buffalo-sc",
|
||
|
|
"options": ["engine:insightface", "model_pack:buffalo_sc"],
|
||
|
|
# buffalo_sc has recognizer only — no landmarks, no genderage.
|
||
|
|
"has_analyze": False,
|
||
|
|
"needs_opencv_files": None,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"name": "insightface-buffalo-s",
|
||
|
|
"options": ["engine:insightface", "model_pack:buffalo_s"],
|
||
|
|
"has_analyze": True,
|
||
|
|
"needs_opencv_files": None,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"name": "insightface-buffalo-m",
|
||
|
|
"options": ["engine:insightface", "model_pack:buffalo_m"],
|
||
|
|
"has_analyze": True,
|
||
|
|
"needs_opencv_files": None,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"name": "insightface-antelopev2",
|
||
|
|
"options": ["engine:insightface", "model_pack:antelopev2"],
|
||
|
|
"has_analyze": True,
|
||
|
|
"needs_opencv_files": None,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"name": "insightface-opencv",
|
||
|
|
"options": [
|
||
|
|
"engine:onnx_direct",
|
||
|
|
"detector_onnx:face_detection_yunet_2023mar.onnx",
|
||
|
|
"recognizer_onnx:face_recognition_sface_2021dec.onnx",
|
||
|
|
],
|
||
|
|
"has_analyze": False,
|
||
|
|
"needs_opencv_files": "fp32",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"name": "insightface-opencv-int8",
|
||
|
|
"options": [
|
||
|
|
"engine:onnx_direct",
|
||
|
|
"detector_onnx:face_detection_yunet_2023mar_int8.onnx",
|
||
|
|
"recognizer_onnx:face_recognition_sface_2021dec_int8.onnx",
|
||
|
|
],
|
||
|
|
"has_analyze": False,
|
||
|
|
"needs_opencv_files": "int8",
|
||
|
|
},
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeContext:
|
||
|
|
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
|
||
|
|
|
||
|
|
|
||
|
|
def _encode_image(img: np.ndarray) -> str:
|
||
|
|
_, buf = cv2.imencode(".jpg", img)
|
||
|
|
return base64.b64encode(buf.tobytes()).decode("ascii")
|
||
|
|
|
||
|
|
|
||
|
|
def _load_sample_image() -> str:
|
||
|
|
from insightface.data import get_image as ins_get_image
|
||
|
|
|
||
|
|
return _encode_image(ins_get_image("t1"))
|
||
|
|
|
||
|
|
|
||
|
|
def _download_if_missing(model_dir: str, filename: str, uri: str, sha256: str) -> None:
|
||
|
|
dest = os.path.join(model_dir, filename)
|
||
|
|
if os.path.isfile(dest):
|
||
|
|
h = hashlib.sha256(open(dest, "rb").read()).hexdigest()
|
||
|
|
if h == sha256:
|
||
|
|
return
|
||
|
|
sys.stderr.write(f" fetching {filename} from {uri}\n")
|
||
|
|
sys.stderr.flush()
|
||
|
|
urllib.request.urlretrieve(uri, dest)
|
||
|
|
h = hashlib.sha256(open(dest, "rb").read()).hexdigest()
|
||
|
|
if h != sha256:
|
||
|
|
raise RuntimeError(f"sha256 mismatch for {filename}: want {sha256}, got {h}")
|
||
|
|
|
||
|
|
|
||
|
|
def _run_one(cfg: dict, img_b64: str, model_dir: str) -> tuple[bool, str]:
|
||
|
|
# Mirror LocalAI's gallery flow: populate model_dir with the
|
||
|
|
# gallery's listed files before calling LoadModel.
|
||
|
|
if cfg["needs_opencv_files"]:
|
||
|
|
for filename, uri, sha256 in OPENCV_FILES[cfg["needs_opencv_files"]]:
|
||
|
|
_download_if_missing(model_dir, filename, uri, sha256)
|
||
|
|
|
||
|
|
svc = BackendServicer()
|
||
|
|
ctx = _FakeContext()
|
||
|
|
|
||
|
|
load_res = svc.LoadModel(
|
||
|
|
backend_pb2.ModelOptions(
|
||
|
|
Model=cfg["name"],
|
||
|
|
Options=cfg["options"],
|
||
|
|
# ModelPath is what the Go loader sets to ml.ModelPath —
|
||
|
|
# LocalAI's models directory. The backend anchors relative
|
||
|
|
# paths and insightface auto-download root here.
|
||
|
|
ModelPath=model_dir,
|
||
|
|
),
|
||
|
|
ctx,
|
||
|
|
)
|
||
|
|
if not load_res.success:
|
||
|
|
return False, f"LoadModel: {load_res.message}"
|
||
|
|
|
||
|
|
det_res = svc.Detect(backend_pb2.DetectOptions(src=img_b64), _FakeContext())
|
||
|
|
if len(det_res.Detections) == 0:
|
||
|
|
return False, "Detect returned no faces"
|
||
|
|
for d in det_res.Detections:
|
||
|
|
if d.class_name != "face":
|
||
|
|
return False, f"Detect returned class_name={d.class_name!r}"
|
||
|
|
|
||
|
|
emb_ctx = _FakeContext()
|
||
|
|
emb_res = svc.Embedding(backend_pb2.PredictOptions(Images=[img_b64]), emb_ctx)
|
||
|
|
if emb_ctx.code is not None:
|
||
|
|
return False, f"Embedding set error code {emb_ctx.code}: {emb_ctx.details}"
|
||
|
|
if len(emb_res.embeddings) == 0:
|
||
|
|
return False, "Embedding returned empty vector"
|
||
|
|
norm_sq = sum(float(x) * float(x) for x in emb_res.embeddings)
|
||
|
|
if not (0.8 <= norm_sq <= 1.2):
|
||
|
|
return False, f"Embedding not L2-normed (sum(x^2)={norm_sq:.3f})"
|
||
|
|
|
||
|
|
ver_ctx = _FakeContext()
|
||
|
|
ver_res = svc.FaceVerify(
|
||
|
|
backend_pb2.FaceVerifyRequest(img1=img_b64, img2=img_b64), ver_ctx
|
||
|
|
)
|
||
|
|
if ver_ctx.code is not None:
|
||
|
|
return False, f"FaceVerify set error code {ver_ctx.code}: {ver_ctx.details}"
|
||
|
|
if not ver_res.verified:
|
||
|
|
return False, f"Same-image FaceVerify not verified (dist={ver_res.distance:.3f})"
|
||
|
|
if ver_res.distance > 0.1:
|
||
|
|
return False, f"Same-image distance suspiciously high ({ver_res.distance:.3f})"
|
||
|
|
|
||
|
|
if cfg["has_analyze"]:
|
||
|
|
an_ctx = _FakeContext()
|
||
|
|
an_res = svc.FaceAnalyze(backend_pb2.FaceAnalyzeRequest(img=img_b64), an_ctx)
|
||
|
|
if an_ctx.code is not None:
|
||
|
|
return False, f"FaceAnalyze set error code {an_ctx.code}: {an_ctx.details}"
|
||
|
|
if len(an_res.faces) == 0:
|
||
|
|
return False, "FaceAnalyze returned no faces"
|
||
|
|
f0 = an_res.faces[0]
|
||
|
|
if f0.age <= 0:
|
||
|
|
return False, f"FaceAnalyze age not populated (age={f0.age})"
|
||
|
|
if f0.dominant_gender not in ("Man", "Woman"):
|
||
|
|
return False, f"FaceAnalyze dominant_gender={f0.dominant_gender!r}"
|
||
|
|
|
||
|
|
n_dets = len(det_res.Detections)
|
||
|
|
dim = len(emb_res.embeddings)
|
||
|
|
return True, f"faces={n_dets} dim={dim} same-dist={ver_res.distance:.3f}"
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
# Honor LOCALAI_MODELS_PATH to re-use cached downloads across runs;
|
||
|
|
# default to a fresh temp dir.
|
||
|
|
model_dir = os.environ.get("LOCALAI_MODELS_PATH")
|
||
|
|
if not model_dir:
|
||
|
|
import tempfile
|
||
|
|
|
||
|
|
model_dir = tempfile.mkdtemp(prefix="face-smoke-")
|
||
|
|
os.makedirs(model_dir, exist_ok=True)
|
||
|
|
print(f"model_dir={model_dir}", file=sys.stderr)
|
||
|
|
|
||
|
|
print("Preparing sample image from insightface.data...", file=sys.stderr)
|
||
|
|
img_b64 = _load_sample_image()
|
||
|
|
|
||
|
|
results: list[tuple[str, bool, str]] = []
|
||
|
|
for cfg in CONFIGS:
|
||
|
|
sys.stderr.write(f"\n=== {cfg['name']} ===\n")
|
||
|
|
sys.stderr.flush()
|
||
|
|
try:
|
||
|
|
ok, detail = _run_one(cfg, img_b64, model_dir)
|
||
|
|
except Exception:
|
||
|
|
ok, detail = False, traceback.format_exc().splitlines()[-1]
|
||
|
|
results.append((cfg["name"], ok, detail))
|
||
|
|
print(f"{'PASS' if ok else 'FAIL'}: {cfg['name']:30s} {detail}")
|
||
|
|
sys.stdout.flush()
|
||
|
|
|
||
|
|
print("\n=== summary ===")
|
||
|
|
passed = sum(1 for _, ok, _ in results if ok)
|
||
|
|
total = len(results)
|
||
|
|
for name, ok, detail in results:
|
||
|
|
mark = "✓" if ok else "✗"
|
||
|
|
print(f" {mark} {name:30s} {detail}")
|
||
|
|
print(f"\n{passed}/{total} passed")
|
||
|
|
return 0 if passed == total else 1
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main())
|