Prismai / utils /retinaface_detector.py
Satyam S
PRISM AI — with demo attendance button
af32e72
Raw
History Blame
19.4 kB
from __future__ import annotations
import os
from math import ceil
from pathlib import Path
from itertools import product
import cv2
import numpy as np
import onnxruntime as ort
import torch
import torch.nn.functional as F
from insightface.utils import face_align
try:
from insightface.app import FaceAnalysis
except Exception:
FaceAnalysis = None
_CFG_MNET = {
"name": "mobilenet0.25",
"min_sizes": [[16, 32], [64, 128], [256, 512]],
"steps": [8, 16, 32],
"variance": [0.1, 0.2],
"clip": False,
}
class _PriorBox:
def __init__(self, cfg: dict, image_size: tuple[int, int]):
self.min_sizes = cfg["min_sizes"]
self.steps = cfg["steps"]
self.clip = cfg["clip"]
self.image_size = image_size
self.feature_maps = [[ceil(self.image_size[0] / step), ceil(self.image_size[1] / step)] for step in self.steps]
def forward(self) -> torch.Tensor:
anchors: list[float] = []
for k, feature_map in enumerate(self.feature_maps):
min_sizes = self.min_sizes[k]
for i, j in product(range(feature_map[0]), range(feature_map[1])):
for min_size in min_sizes:
s_kx = min_size / self.image_size[1]
s_ky = min_size / self.image_size[0]
dense_cx = [x * self.steps[k] / self.image_size[1] for x in [j + 0.5]]
dense_cy = [y * self.steps[k] / self.image_size[0] for y in [i + 0.5]]
for cy, cx in product(dense_cy, dense_cx):
anchors += [cx, cy, s_kx, s_ky]
output = torch.tensor(anchors, dtype=torch.float32).view(-1, 4)
if self.clip:
output.clamp_(max=1, min=0)
return output
def _decode(loc: torch.Tensor, priors: torch.Tensor, variances: list[float]) -> torch.Tensor:
boxes = torch.cat(
(
priors[:, :2] + loc[:, :2] * variances[0] * priors[:, 2:],
priors[:, 2:] * torch.exp(loc[:, 2:] * variances[1]),
),
1,
)
boxes[:, :2] -= boxes[:, 2:] / 2
boxes[:, 2:] += boxes[:, :2]
return boxes
def _decode_landm(pre: torch.Tensor, priors: torch.Tensor, variances: list[float]) -> torch.Tensor:
return torch.cat(
(
priors[:, :2] + pre[:, :2] * variances[0] * priors[:, 2:],
priors[:, :2] + pre[:, 2:4] * variances[0] * priors[:, 2:],
priors[:, :2] + pre[:, 4:6] * variances[0] * priors[:, 2:],
priors[:, :2] + pre[:, 6:8] * variances[0] * priors[:, 2:],
priors[:, :2] + pre[:, 8:10] * variances[0] * priors[:, 2:],
),
dim=1,
)
def _py_cpu_nms(dets: np.ndarray, thresh: float) -> list[int]:
if dets.size == 0:
return []
x1 = dets[:, 0]
y1 = dets[:, 1]
x2 = dets[:, 2]
y2 = dets[:, 3]
scores = dets[:, 4]
areas = (x2 - x1) * (y2 - y1)
order = scores.argsort()[::-1]
keep: list[int] = []
while order.size > 0:
i = int(order[0])
keep.append(i)
if order.size == 1:
break
xx1 = np.maximum(x1[i], x1[order[1:]])
yy1 = np.maximum(y1[i], y1[order[1:]])
xx2 = np.minimum(x2[i], x2[order[1:]])
yy2 = np.minimum(y2[i], y2[order[1:]])
w = np.maximum(0.0, xx2 - xx1)
h = np.maximum(0.0, yy2 - yy1)
inter = w * h
iou = inter / (areas[i] + areas[order[1:]] - inter)
order = order[np.where(iou <= thresh)[0] + 1]
return keep
def sahi_face_detect(
fa_get,
img: np.ndarray,
slice_size: int = 640,
overlap: float = 0.2,
nms_thresh: float = 0.4,
max_patches: int | None = None,
) -> list:
"""SAHI: full-image detection combined with overlapping-patch detection, merged via NMS.
The full-image pass preserves all detections that the detector already finds
(no regression). The patch passes recover small/distant faces that were being
downscaled below the detection minimum — each 640-px patch is upscaled 2× by the
detector (det_size=1280), so a 20-px back-row face becomes 40 px and is found.
fa_get — callable matching fa.get(img) → objects with
.bbox, .det_score, .kps, .normed_embedding, .embedding.
"""
import types as _types
h, w = img.shape[:2]
def _collect(source_faces, x0: int = 0, y0: int = 0):
for face in source_faces:
bbox = np.asarray(face.bbox, dtype=np.float32).ravel()[:4].copy()
bbox[[0, 2]] += x0
bbox[[1, 3]] += y0
kps = getattr(face, "kps", None)
if kps is not None:
kps = np.asarray(kps, dtype=np.float32).copy()
kps[:, 0] += x0
kps[:, 1] += y0
all_boxes.append(np.append(bbox, float(face.det_score)))
all_kps.append(kps)
# Copy embeddings immediately — InsightFace reuses its internal
# output buffer across inference calls, so without a copy every
# face in all_faces ends up pointing to the same array (the last
# one written), making all embeddings appear identical.
ne = getattr(face, "normed_embedding", None)
e = getattr(face, "embedding", None)
import types as _t
all_faces.append(_t.SimpleNamespace(
bbox=bbox.copy(),
kps=kps,
det_score=float(face.det_score),
normed_embedding=np.array(ne, dtype=np.float32).copy() if ne is not None else None,
embedding=np.array(e, dtype=np.float32).copy() if e is not None else None,
))
all_boxes: list[np.ndarray] = []
all_faces: list = []
all_kps: list = []
# Pass 1 — full image (guarantees no regression vs. plain detection)
_collect(fa_get(img))
# Pass 2 — SAHI patches (recovers small faces lost to downscaling)
if w > slice_size or h > slice_size:
stride = max(1, int(slice_size * (1 - overlap)))
def _positions(dim: int) -> list[int]:
if dim <= slice_size:
return [0]
starts = list(range(0, dim - slice_size, stride))
starts.append(dim - slice_size)
return sorted(set(starts))
xs = _positions(w)
ys = _positions(h)
# Skip patch pass if it would exceed the memory budget
if max_patches is None or len(xs) * len(ys) <= max_patches:
for y0 in ys:
for x0 in xs:
patch = img[y0:y0 + slice_size, x0:x0 + slice_size]
_collect(fa_get(patch), x0=x0, y0=y0)
if not all_boxes:
return []
keep = _py_cpu_nms(np.stack(all_boxes, axis=0), nms_thresh)
return [
_types.SimpleNamespace(
bbox=all_boxes[i][:4],
kps=all_kps[i],
det_score=float(all_boxes[i][4]),
normed_embedding=getattr(all_faces[i], "normed_embedding", None),
embedding=getattr(all_faces[i], "embedding", None),
)
for i in keep
]
def _default_model_candidates() -> list[Path]:
repo_root = Path(__file__).resolve().parents[1]
candidates: list[Path] = []
env_path = os.environ.get("RETINAFACE_MODEL_PATH", "").strip()
if env_path:
candidates.append(Path(env_path).expanduser())
candidates.extend([
repo_root / "models" / "retinaface_finetune" / "mobile0.25_retinaface.onnx",
repo_root / "retinaface_finetune" / "mobile0.25_retinaface.onnx",
Path("/Users/satyam/Downloads/retinaface_finetune/mobile0.25_retinaface.onnx"),
])
return candidates
def default_retinaface_model_path() -> Path:
for candidate in _default_model_candidates():
if candidate.exists():
return candidate
raise FileNotFoundError(
"Could not find the fine-tuned RetinaFace ONNX model. Set RETINAFACE_MODEL_PATH or place "
"mobile0.25_retinaface.onnx in a supported location."
)
def _local_face_embedding(img: np.ndarray, kps: np.ndarray | None) -> np.ndarray | None:
if img is None or kps is None:
return None
try:
aligned = face_align.norm_crop(img, landmark=np.asarray(kps, dtype=np.float32), image_size=112)
except Exception:
return None
gray = cv2.cvtColor(aligned, cv2.COLOR_BGR2GRAY)
gray = cv2.equalizeHist(gray)
gray = cv2.resize(gray, (16, 32), interpolation=cv2.INTER_AREA)
embedding = gray.astype(np.float32).reshape(-1)
embedding -= float(embedding.mean())
std = float(embedding.std())
if std > 0:
embedding /= std
norm = float(np.linalg.norm(embedding))
if norm > 0:
embedding /= norm
return embedding
class FineTunedRetinaFaceDetector:
def __init__(self, model_path: str | Path | None = None, providers: list[str] | None = None):
self.model_path = Path(model_path).expanduser() if model_path is not None else default_retinaface_model_path()
self.providers = providers or ["CPUExecutionProvider"]
self.session = ort.InferenceSession(str(self.model_path), providers=self.providers)
self.input_name = self.session.get_inputs()[0].name
self.cfg = _CFG_MNET
self.variance = self.cfg["variance"]
self.det_thresh = 0.5
self.input_size = (640, 640)
self.nms_threshold = 0.4
self.top_k = 5000
self.keep_top_k = 750
def prepare(self, ctx_id: int, input_size: tuple[int, int] = (640, 640), det_thresh: float = 0.5):
self.input_size = tuple(int(v) for v in input_size)
self.det_thresh = float(det_thresh)
def _preprocess(self, img: np.ndarray) -> tuple[np.ndarray, float, tuple[int, int]]:
if img is None or img.size == 0:
raise ValueError("img cannot be empty")
height, width = img.shape[:2]
target_h, target_w = self.input_size
scale = min(target_w / max(1, width), target_h / max(1, height))
resized_w = max(1, int(round(width * scale)))
resized_h = max(1, int(round(height * scale)))
resized = cv2.resize(img, (resized_w, resized_h), interpolation=cv2.INTER_LINEAR)
canvas = np.zeros((target_h, target_w, 3), dtype=np.float32)
canvas[:resized_h, :resized_w] = resized.astype(np.float32)
canvas -= np.array((104.0, 117.0, 123.0), dtype=np.float32)
tensor = canvas.transpose(2, 0, 1)[None, ...]
return tensor, scale, (height, width)
def detect(self, img: np.ndarray, max_num: int = 0, metric: str = "default"):
del max_num, metric
input_tensor, scale, original_shape = self._preprocess(img)
target_h, target_w = self.input_size
priors = _PriorBox(self.cfg, image_size=(target_h, target_w)).forward()
outputs = self.session.run(None, {self.input_name: input_tensor.astype(np.float32)})
loc = torch.from_numpy(outputs[0])[0]
conf = torch.from_numpy(outputs[1])[0]
landms = torch.from_numpy(outputs[2])[0]
scores = F.softmax(conf, dim=-1)[:, 1]
keep = scores > self.det_thresh
if not torch.any(keep):
return np.empty((0, 5), dtype=np.float32), None
loc = loc[keep]
scores = scores[keep]
landms = landms[keep]
priors = priors[keep]
boxes = _decode(loc, priors, self.variance)
landms = _decode_landm(landms, priors, self.variance)
scale_xyxy = torch.tensor([target_w, target_h, target_w, target_h], dtype=torch.float32)
scale_landm = torch.tensor([target_w, target_h] * 5, dtype=torch.float32)
boxes = boxes * scale_xyxy
landms = landms * scale_landm
boxes_np = boxes.cpu().numpy()
landms_np = landms.cpu().numpy()
scores_np = scores.cpu().numpy()
order = scores_np.argsort()[::-1][: self.top_k]
boxes_np = boxes_np[order]
landms_np = landms_np[order]
scores_np = scores_np[order]
dets = np.hstack((boxes_np, scores_np[:, np.newaxis])).astype(np.float32, copy=False)
keep_indices = _py_cpu_nms(dets, self.nms_threshold)
dets = dets[keep_indices, :]
landms_np = landms_np[keep_indices]
dets = dets[: self.keep_top_k, :]
landms_np = landms_np[: self.keep_top_k, :]
if dets.size == 0:
return np.empty((0, 5), dtype=np.float32), None
dets[:, [0, 2]] /= max(scale, 1e-6)
dets[:, [1, 3]] /= max(scale, 1e-6)
landms_np[:, 0::2] /= max(scale, 1e-6)
landms_np[:, 1::2] /= max(scale, 1e-6)
height, width = original_shape
dets[:, 0] = np.clip(dets[:, 0], 0, width - 1)
dets[:, 1] = np.clip(dets[:, 1], 0, height - 1)
dets[:, 2] = np.clip(dets[:, 2], 0, width - 1)
dets[:, 3] = np.clip(dets[:, 3], 0, height - 1)
landms_np[:, 0::2] = np.clip(landms_np[:, 0::2], 0, width - 1)
landms_np[:, 1::2] = np.clip(landms_np[:, 1::2], 0, height - 1)
landms_np = landms_np.reshape(-1, 5, 2)
return dets, landms_np
def _get_mtcnn():
"""Lazy-load facenet-pytorch MTCNN once. Returns None if not installed."""
if not hasattr(_get_mtcnn, "_instance"):
try:
from facenet_pytorch import MTCNN as _MTCNN
_get_mtcnn._instance = _MTCNN(
keep_all=True,
device="cpu",
min_face_size=20,
thresholds=[0.5, 0.6, 0.7],
post_process=False,
)
except Exception:
_get_mtcnn._instance = None
return _get_mtcnn._instance
def _get_rec_session():
"""Lazy-load buffalo_l recognition ONNX for embedding extraction on MTCNN-only faces."""
if not hasattr(_get_rec_session, "_session"):
rec_path = Path.home() / ".insightface" / "models" / "buffalo_l" / "w600k_r50.onnx"
try:
_get_rec_session._session = ort.InferenceSession(
str(rec_path), providers=["CPUExecutionProvider"]
)
except Exception:
_get_rec_session._session = None
return _get_rec_session._session
def _embed_from_crop(img_bgr: np.ndarray, kps5: np.ndarray | None) -> np.ndarray | None:
"""Align a face crop and extract a buffalo_l normed embedding."""
session = _get_rec_session()
if session is None:
return None
try:
from insightface.utils import face_align as _fa
if kps5 is not None:
aligned = _fa.norm_crop(img_bgr, landmark=np.asarray(kps5, dtype=np.float32), image_size=112)
else:
aligned = cv2.resize(img_bgr, (112, 112), interpolation=cv2.INTER_LINEAR)
blob = ((aligned.astype(np.float32) - 127.5) / 127.5).transpose(2, 0, 1)[np.newaxis]
emb = session.run(None, {session.get_inputs()[0].name: blob})[0][0]
norm = float(np.linalg.norm(emb))
return emb / norm if norm > 0 else emb
except Exception:
return None
def build_face_analysis_with_retinaface_detector(
*,
name: str = "retinaface_local",
providers: list[str] | None = None,
model_path: str | Path | None = None,
):
"""Return a lightweight FaceAnalysis-like adapter using the fine-tuned
RetinaFace detector plus a local, non-Buffalo embedding extractor.
The returned object implements `prepare(ctx_id, det_size, det_thresh)` and
`get(img)`, and produces objects with `bbox`, `kps`, `det_score`, and
embedding attributes for enrollment and matching.
"""
providers = providers or ["CPUExecutionProvider"]
# Always use InsightFace FaceAnalysis (buffalo) for detection and embeddings.
# If InsightFace is not installed or models are missing, raise an informative error.
if FaceAnalysis is None:
raise RuntimeError(
"InsightFace (FaceAnalysis) is required for buffalo backend. Install the `insightface` package and place buffalo models under ~/.insightface/models/buffalo_l/."
)
fa = FaceAnalysis(name="antelopev2", allowed_modules=["detection", "recognition"], providers=providers)
class FaceAnalysisAdapter:
def __init__(self, fa, providers):
self._fa = fa
self._providers = providers
def prepare(self, ctx_id: int, det_size=(640, 640), det_thresh: float = 0.5):
try:
self._fa.prepare(ctx_id=ctx_id, det_size=det_size)
except Exception:
self._fa.prepare(ctx_id=ctx_id)
def get(self, img, max_num=0):
import types as _types
# --- Pass 1: buffalo_l + SAHI (already includes embeddings) ---
bl_faces = sahi_face_detect(self._fa.get, img, max_patches=None)
# --- Pass 2: MTCNN (different cascade architecture, fills gaps) ---
mtcnn_faces: list = []
mtcnn = _get_mtcnn()
if mtcnn is not None:
try:
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
boxes, probs, landmarks = mtcnn.detect(img_rgb, landmarks=True)
if boxes is not None:
for box, prob, lm in zip(boxes, probs, landmarks):
x1, y1, x2, y2 = box
mtcnn_faces.append(_types.SimpleNamespace(
bbox=np.array([x1, y1, x2, y2], dtype=np.float32),
kps=lm.astype(np.float32), # (5, 2): l_eye r_eye nose ml mr
det_score=float(prob),
normed_embedding=None,
embedding=None,
))
except Exception:
pass
# --- Merge via NMS ---
all_faces = bl_faces + mtcnn_faces
if not all_faces:
return []
boxes_arr = np.array(
[[*f.bbox[:4], float(f.det_score)] for f in all_faces], dtype=np.float32
)
keep = _py_cpu_nms(boxes_arr, 0.4)
n_bl = len(bl_faces)
out = []
for idx in keep:
f = all_faces[idx]
if idx < n_bl:
# Buffalo_l face — normalize its embedding
emb = getattr(f, "normed_embedding", None)
if emb is None:
emb = getattr(f, "embedding", None)
normed = None
if emb is not None:
v = np.asarray(emb, dtype=np.float32).reshape(-1)
norm = float(np.linalg.norm(v))
normed = v / norm if norm > 0 else v
else:
# MTCNN-only face — extract embedding via buffalo_l rec model
x1, y1, x2, y2 = (int(v) for v in f.bbox)
crop = img[max(0, y1):max(0, y2), max(0, x1):max(0, x2)]
normed = _embed_from_crop(crop, f.kps) if crop.size > 0 else None
emb = normed
out.append(_types.SimpleNamespace(
bbox=f.bbox,
kps=getattr(f, "kps", None),
det_score=f.det_score,
normed_embedding=normed,
embedding=emb,
))
return out
return FaceAnalysisAdapter(fa=fa, providers=providers)