face-recognition / model.py
shalev396's picture
Add face-recognition model: weights, model.py, Inference Endpoint handler, card
c895eab
Raw History Blame Contribute Delete
25.3 kB
"""Face Recognition: who is in this photo, and are two photos the same person?
Single source of truth for the pipeline used by the training code (`training/src/`), the
Hugging Face Space (`space/app.py`), the Inference Endpoint (`handler.py`) and anyone who
downloads this repo from the Hub:
1. Our FaceDetector (a single-shot CNN trained in this project, `face_detector.safetensors`)
finds the largest face and its 5 landmarks (eyes, nose tip, mouth corners), and the face is
rotated and scaled onto a fixed 128x128 template. When no face is found, the center square
is used instead.
2. Face embedders trained in this project (torchvision CNNs + a 512-d projection, trained with
a CosFace margin loss on the LFW identities) turn the face into an L2-normalised embedding;
the model is an ensemble of them (see `FaceRecognizer`). No pretrained face network is used:
every network in this file was trained in this project.
3. A small MLP head (embedding -> 256 -> 42) names the person. Verification compares two embeddings
with the cosine distance.
import model
predictor = model.load("path/to/this/repo", device="cpu")
predictor.predict("face.jpg") # {"George W Bush": 0.99, ...} (all 42 people)
predictor.verify("a.jpg", "b.jpg") # {"same_person": True, "distance": 0.31, ...}
predictor.embed("face.jpg") # 1024 floats (512 per embedder)
"""
from __future__ import annotations
import base64
import binascii
import io
import os
from pathlib import Path
import numpy as np
import torch
import torch.nn.functional as F
import torchvision
from huggingface_hub import PyTorchModelHubMixin
from PIL import Image
from safetensors.torch import load_file
from torch import nn
REPO_ID = "shalev396/face-recognition"
FRAMEWORK = "pytorch"
WEIGHTS = "model.safetensors" # embedders + identity head (PyTorchModelHubMixin)
MAX_SIDE = 1024 # larger photos are downscaled before detection (speed); LFW photos are 250 px
EMBEDDING_DIM = 512
IMAGENET_MEAN = (0.485, 0.456, 0.406)
IMAGENET_STD = (0.229, 0.224, 0.225)
def cuda_available() -> bool:
return torch.cuda.is_available()
# --------------------------------------------------------------------------- inputs
def to_pil(image) -> Image.Image:
"""PIL image, file path, raw bytes, base64 / data-URL string or HxWx3 uint8 array -> RGB PIL."""
if isinstance(image, Image.Image):
return image.convert("RGB")
if isinstance(image, np.ndarray):
return Image.fromarray(np.asarray(image, dtype=np.uint8)).convert("RGB")
if isinstance(image, (bytes, bytearray)):
with Image.open(io.BytesIO(image)) as img:
return img.convert("RGB")
if isinstance(image, (str, os.PathLike)):
text = str(image)
if len(text) < 1024 and Path(text).is_file():
with Image.open(text) as img:
return img.convert("RGB")
payload = text.split(",", 1)[1] if text.startswith("data:") else text
try:
return to_pil(base64.b64decode(payload, validate=True))
except (binascii.Error, ValueError, OSError) as err:
raise ValueError("expected an image file path or a base64-encoded image") from err
raise TypeError(f"unsupported image input: {type(image).__name__}")
def _limit_size(img: Image.Image) -> Image.Image:
if max(img.size) <= MAX_SIDE:
return img
img = img.copy()
img.thumbnail((MAX_SIDE, MAX_SIDE), Image.BILINEAR)
return img
def center_crop_face(img: Image.Image, crop_size: int) -> Image.Image:
"""Fallback when no face is found: the center square, resized to crop_size."""
w, h = img.size
s = min(w, h)
left, top = (w - s) // 2, (h - s) // 2
return img.crop((left, top, left + s, top + s)).resize((crop_size, crop_size), Image.BILINEAR)
def face_to_pil(face: torch.Tensor) -> Image.Image:
"""(3, H, W) face tensor with values 0..255 -> viewable PIL crop."""
arr = face.detach().float().cpu().round().clamp(0, 255).byte().permute(1, 2, 0).numpy()
return Image.fromarray(arr)
def cosine_distance(a, b) -> float:
"""1 - cosine similarity of two embeddings: 0 = identical direction, 2 = opposite."""
a, b = np.asarray(a, dtype=np.float64), np.asarray(b, dtype=np.float64)
a = a / (np.linalg.norm(a) + 1e-12)
b = b / (np.linalg.norm(b) + 1e-12)
return float(1.0 - np.dot(a, b))
# --------------------------------------------------------------------------- networks
def build_backbone(arch: str, pretrained: bool = False) -> tuple[nn.Module, int]:
"""torchvision `arch` without its classifier -> (module, feature size). `pretrained` loads the
ImageNet weights (training only; the deployed weights come from model.safetensors)."""
net = getattr(torchvision.models, arch)(weights="DEFAULT" if pretrained else None)
if hasattr(net, "fc"): # resnet*
features = net.fc.in_features
net.fc = nn.Identity()
else: # efficientnet* / mobilenet* / convnext*
features = net.classifier[-1].in_features
net.classifier[-1] = nn.Identity()
return net, features
class FaceEmbedNet(nn.Module):
"""One embedder: torchvision backbone -> Dropout -> Linear -> BatchNorm = `embedding_dim` features,
reading faces resized to `input_size`."""
def __init__(self, arch: str, input_size: int, embedding_dim: int = EMBEDDING_DIM):
super().__init__()
self.arch, self.input_size = arch, int(input_size)
self.backbone, features = build_backbone(arch)
self.neck = nn.Sequential(nn.Dropout(0.2), nn.Linear(features, embedding_dim), nn.BatchNorm1d(embedding_dim))
self.register_buffer("mean", torch.tensor(IMAGENET_MEAN).view(1, 3, 1, 1), persistent=False)
self.register_buffer("std", torch.tensor(IMAGENET_STD).view(1, 3, 1, 1), persistent=False)
def resize(self, faces: torch.Tensor) -> torch.Tensor:
"""(N, 3, crop, crop) faces 0..255 -> float faces at input_size (still 0..255)."""
return F.interpolate(faces.float(), size=(self.input_size, self.input_size), mode="bilinear",
antialias=True, align_corners=False)
def normalize(self, x: torch.Tensor) -> torch.Tensor:
"""Faces at input_size, 0..255 -> ImageNet-normalised."""
return (x / 255.0 - self.mean) / self.std
def features(self, x: torch.Tensor) -> torch.Tensor:
"""Normalised faces -> raw (unnormalised) embeddings. Used by training."""
return self.neck(self.backbone(x))
def embed(self, faces: torch.Tensor) -> torch.Tensor:
"""(N, 3, crop, crop) faces 0..255 -> (N, embedding_dim) L2-normalised, flip-averaged embeddings."""
x = self.normalize(self.resize(faces))
return F.normalize(self.features(x) + self.features(torch.flip(x, dims=[3])), dim=1)
class FaceRecognizer(
nn.Module,
PyTorchModelHubMixin,
tags=["ml-lab", "face-recognition", "image-classification"],
repo_url="https://github.com/shalev396/ml-lab",
pipeline_tag="image-classification",
license="mit",
):
"""An ensemble of face embedders + the identity MLP head.
`members` lists the embedders ({"arch", "input_size"}); each gives a 512-d L2-normalised
embedding and the face embedding is their concatenation divided by sqrt(len(members)), so it
is L2-normalised too and the cosine distance averages the members' cosine distances.
`forward()` maps faces to identity logits through the head. The extra args are runtime
metadata written to config.json by the mixin: crop size and the verification threshold.
"""
def __init__(self, class_names: list[str], members: list[dict] | None = None,
embedding_dim: int = EMBEDDING_DIM, hidden_units: int = 256, dropout: float = 0.3,
crop_size: int = 128, verify_threshold: float = 0.5):
super().__init__()
members = members or [{"arch": "resnet18", "input_size": 112}]
self.class_names = list(class_names)
self.crop_size = int(crop_size)
self.verify_threshold = float(verify_threshold)
self.members = nn.ModuleList(FaceEmbedNet(m["arch"], m["input_size"], embedding_dim) for m in members)
self.embedding_dim = embedding_dim * len(members)
self.head = nn.Sequential(nn.Linear(self.embedding_dim, hidden_units), nn.ReLU(), nn.Dropout(dropout),
nn.Linear(hidden_units, len(self.class_names)))
def set_verify_threshold(self, threshold: float) -> None:
"""Change the default verification threshold, also in the config that save_pretrained writes."""
self.verify_threshold = float(threshold)
if isinstance(self._hub_mixin_config, dict):
self._hub_mixin_config["verify_threshold"] = self.verify_threshold
def embed(self, faces: torch.Tensor) -> torch.Tensor:
"""(N, 3, crop, crop) faces 0..255 -> (N, 512 * members) L2-normalised embeddings."""
parts = [member.embed(faces) for member in self.members]
return torch.cat(parts, dim=1) / len(parts) ** 0.5
def classify(self, embeddings: torch.Tensor) -> torch.Tensor:
"""(N, embedding_dim) normalised embeddings -> (N, n_classes) logits."""
return self.head(embeddings)
def forward(self, faces: torch.Tensor) -> torch.Tensor:
return self.classify(self.embed(faces))
def _require(path: Path) -> Path:
if not path.is_file():
raise FileNotFoundError(
f"{path.name} not found in {path.parent}: export the model first or download it with "
f"huggingface_hub.snapshot_download('{REPO_ID}')."
)
return path
# --------------------------------------------------------------------------- face detector
# FaceDetector: our own single-shot face + 5-landmark detector (trained in training/). Ideas from
# MTCNN (Zhang et al. 2016): three tasks per candidate (face / box / landmarks), PReLU, online
# hard-negative mining. Unlike MTCNN: one fully-convolutional network with a feature pyramid
# (strides 8/16/32) and anchors instead of an image pyramid + three cascaded networks.
DETECTOR_WEIGHTS = "face_detector.safetensors"
DETECTOR_WIDTH = 32
DETECT_THRESHOLD = 0.5 # face score above which a detection counts
ANCHOR_LEVELS = ((8, (16, 24, 32)), (16, (48, 64, 96)), (32, (128, 192, 256, 384))) # stride, anchor sides
BOX_VAR = (0.1, 0.2)
def _conv_bn(cin, cout, k=3, s=1, groups=1):
return nn.Sequential(nn.Conv2d(cin, cout, k, s, k // 2, groups=groups, bias=False), nn.BatchNorm2d(cout),
nn.PReLU(cout))
class _Block(nn.Module):
"""Residual block: (3x3 depthwise + 1x1 pointwise) twice; the stride is on the first depthwise."""
def __init__(self, cin, cout, stride):
super().__init__()
self.body = nn.Sequential(_conv_bn(cin, cin, 3, stride, groups=cin), _conv_bn(cin, cout, 1),
_conv_bn(cout, cout, 3, 1, groups=cout), _conv_bn(cout, cout, 1))
self.skip = None if stride == 1 and cin == cout else nn.Sequential(
nn.AvgPool2d(stride) if stride > 1 else nn.Identity(), nn.Conv2d(cin, cout, 1, bias=False), nn.BatchNorm2d(cout))
def forward(self, x):
return self.body(x) + (x if self.skip is None else self.skip(x))
class FaceDetector(nn.Module):
"""(N, 3, H, W) RGB 0..1 (H, W multiples of 32) -> per anchor: face logit, box deltas (4) and
landmark offsets (10). `detect()` turns them into faces for one PIL image."""
def __init__(self, width: int = DETECTOR_WIDTH):
super().__init__()
w = width
self.stem = nn.Sequential(_conv_bn(3, w, 3, 2), _conv_bn(w, w, 3, 1, groups=w), _conv_bn(w, w, 1)) # /2
self.s1 = nn.Sequential(_Block(w, 2 * w, 2), _Block(2 * w, 2 * w, 1)) # /4
self.s2 = nn.Sequential(_Block(2 * w, 4 * w, 2), _Block(4 * w, 4 * w, 1), _Block(4 * w, 4 * w, 1)) # /8
self.s3 = nn.Sequential(_Block(4 * w, 8 * w, 2), _Block(8 * w, 8 * w, 1), _Block(8 * w, 8 * w, 1)) # /16
self.s4 = nn.Sequential(_Block(8 * w, 16 * w, 2), _Block(16 * w, 16 * w, 1)) # /32
f = 4 * w
self.lat = nn.ModuleList([nn.Conv2d(c, f, 1) for c in (4 * w, 8 * w, 16 * w)])
self.smooth = nn.ModuleList([_conv_bn(f, f) for _ in range(3)])
self.context = nn.ModuleList([nn.Sequential(_conv_bn(f, f), _conv_bn(f, f, 3, 1, groups=f), _conv_bn(f, f, 1))
for _ in range(3)])
self.n_anchor = [len(sizes) for _, sizes in ANCHOR_LEVELS]
self.cls = nn.ModuleList([nn.Conv2d(f, a, 1) for a in self.n_anchor])
self.box = nn.ModuleList([nn.Conv2d(f, a * 4, 1) for a in self.n_anchor])
self.ldm = nn.ModuleList([nn.Conv2d(f, a * 10, 1) for a in self.n_anchor])
for c in self.cls:
nn.init.constant_(c.bias, -4.595) # log(0.01 / 0.99): start at p(face) = 0.01
def forward(self, x):
x = (x - 0.5) / 0.5
c2 = self.s2(self.s1(self.stem(x)))
c3 = self.s3(c2)
c4 = self.s4(c3)
p5 = self.lat[2](c4)
p4 = self.lat[1](c3) + F.interpolate(p5, size=c3.shape[-2:], mode="nearest")
p3 = self.lat[0](c2) + F.interpolate(p4, size=c2.shape[-2:], mode="nearest")
outs = []
for i, p in enumerate((p3, p4, p5)):
p = self.smooth[i](p)
p = p + self.context[i](p)
n, _, h, w = p.shape
a = self.n_anchor[i]
outs.append((self.cls[i](p).permute(0, 2, 3, 1).reshape(n, h * w * a),
self.box[i](p).permute(0, 2, 3, 1).reshape(n, h * w * a, 4),
self.ldm[i](p).permute(0, 2, 3, 1).reshape(n, h * w * a, 10)))
return tuple(torch.cat([o[k] for o in outs], 1) for k in range(3))
@torch.inference_mode()
def detect(self, images: list[Image.Image], score: float = DETECT_THRESHOLD, nms_iou: float = 0.4,
max_side: int = 1024, min_side: int = 320) -> list[tuple[np.ndarray, np.ndarray, np.ndarray]]:
"""Same-size PIL images -> per image (boxes (K, 4), scores (K,), landmarks (K, 5, 2)) in
image pixels, best first. The longer side is scaled into [min_side, max_side] first."""
w0, h0 = images[0].size
s = min(max_side / max(w0, h0), 1.0)
if max(w0, h0) < min_side: # small photos are upscaled (faces this small are hard to see)
s = min_side / max(w0, h0)
w, h = max(32, round(w0 * s / 32) * 32), max(32, round(h0 * s / 32) * 32)
dev = next(self.parameters()).device
x = torch.from_numpy(np.stack([np.asarray(im.resize((w, h), Image.BILINEAR), dtype=np.float32) for im in images]))
logits, d_box, d_ldm = self(x.permute(0, 3, 1, 2).div(255).to(dev))
anc = anchors(h, w, dev)
sx, sy = w0 / w, h0 / h
out = []
for i in range(len(images)):
p = logits[i].float().sigmoid()
keep = p > score
boxes, ldm = decode_detections(d_box[i][keep].float(), d_ldm[i][keep].float(), anc[keep])
p = p[keep]
k = torchvision.ops.nms(boxes, p, nms_iou)
boxes = (boxes[k] * torch.tensor([sx, sy, sx, sy], device=dev)).cpu().numpy()
ldm = (ldm[k].view(-1, 5, 2) * torch.tensor([sx, sy], device=dev)).cpu().numpy()
out.append((boxes, p[k].cpu().numpy(), ldm))
return out
_ANCHORS: dict = {}
def anchors(h: int, w: int, device) -> torch.Tensor:
"""(A, 4) anchors (cx, cy, side, side) for an h x w input, in the order of FaceDetector's outputs."""
key = (h, w, str(device))
if key not in _ANCHORS:
out = []
for stride, sizes in ANCHOR_LEVELS:
fh, fw = -(-h // stride), -(-w // stride)
ys, xs = torch.meshgrid(torch.arange(fh), torch.arange(fw), indexing="ij")
cx = (xs.reshape(-1, 1).float() + 0.5) * stride
cy = (ys.reshape(-1, 1).float() + 0.5) * stride
side = torch.tensor(sizes, dtype=torch.float32).view(1, -1)
out.append(torch.stack([cx.expand(-1, len(sizes)), cy.expand(-1, len(sizes)), side.expand(fh * fw, -1),
side.expand(fh * fw, -1)], -1).reshape(-1, 4))
_ANCHORS[key] = torch.cat(out).to(device)
return _ANCHORS[key]
def decode_detections(d_box, d_ldm, anc):
"""Network deltas + anchors -> boxes (K, 4) x1 y1 x2 y2 and landmarks (K, 10)."""
cx = anc[:, 0] + d_box[:, 0] * BOX_VAR[0] * anc[:, 2]
cy = anc[:, 1] + d_box[:, 1] * BOX_VAR[0] * anc[:, 3]
w = anc[:, 2] * torch.exp((d_box[:, 2] * BOX_VAR[1]).clamp(max=6))
h = anc[:, 3] * torch.exp((d_box[:, 3] * BOX_VAR[1]).clamp(max=6))
boxes = torch.stack([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2], 1)
ldm = anc[:, None, :2] + d_ldm.view(-1, 5, 2) * BOX_VAR[0] * anc[:, None, 2:]
return boxes, ldm.reshape(-1, 10)
def encode_detections(boxes, ldm, anc):
"""Inverse of decode_detections: training targets for anchors matched to faces."""
cx, cy = (boxes[:, 0] + boxes[:, 2]) / 2, (boxes[:, 1] + boxes[:, 3]) / 2
w, h = (boxes[:, 2] - boxes[:, 0]).clamp(min=1), (boxes[:, 3] - boxes[:, 1]).clamp(min=1)
t_box = torch.stack([(cx - anc[:, 0]) / (BOX_VAR[0] * anc[:, 2]), (cy - anc[:, 1]) / (BOX_VAR[0] * anc[:, 3]),
torch.log(w / anc[:, 2]) / BOX_VAR[1], torch.log(h / anc[:, 3]) / BOX_VAR[1]], 1)
t_ldm = (ldm.view(-1, 5, 2) - anc[:, None, :2]) / (BOX_VAR[0] * anc[:, None, 2:])
return t_box, t_ldm.reshape(-1, 10)
def pick_face(boxes: np.ndarray, image_size, selection: str = "largest") -> int | None:
"""Which detection to use: the largest face, or (LFW) the most central large face."""
if len(boxes) == 0:
return None
area = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1])
if selection == "largest":
return int(area.argmax())
w, h = image_size
offset = ((boxes[:, 0] + boxes[:, 2]) / 2 - w / 2) ** 2 + ((boxes[:, 1] + boxes[:, 3]) / 2 - h / 2) ** 2
return int((area - 2.0 * offset).argmax())
def load_detector(model_dir: str | Path, device: str = "cpu") -> FaceDetector:
det = FaceDetector()
det.load_state_dict(load_file(_require(Path(model_dir) / DETECTOR_WEIGHTS)), strict=True)
return det.to(device).eval()
# The standard 5-point face template (eyes, nose tip, mouth corners) of 112x112 face crops,
# scaled to `crop_size`. Every face is rotated/scaled/shifted onto it, so eyes and mouth always
# land on the same pixels.
FACE_TEMPLATE_112 = np.array([[38.2946, 51.6963], [73.5318, 51.5014], [56.0252, 71.7366],
[41.5493, 92.3655], [70.7299, 92.2041]])
def similarity_transform(src: np.ndarray, dst: np.ndarray) -> np.ndarray:
"""2x3 matrix of the rotation + uniform scale + shift that best maps `src` points onto `dst`
(least squares, Umeyama 1991)."""
mu_s, mu_d = src.mean(0), dst.mean(0)
s, d = src - mu_s, dst - mu_d
U, S, Vt = np.linalg.svd(d.T @ s / len(src))
D = np.eye(2)
if np.linalg.det(U) * np.linalg.det(Vt) < 0:
D[1, 1] = -1
R = U @ D @ Vt
scale = (S * np.diag(D)).sum() / s.var(0).sum()
return np.hstack([scale * R, (mu_d - scale * R @ mu_s)[:, None]])
def warp_face(img: Image.Image, landmarks: np.ndarray, crop_size: int) -> Image.Image:
"""Rotate/scale the photo so its 5 landmarks sit on the face template -> crop_size x crop_size."""
M = similarity_transform(np.asarray(landmarks, dtype=np.float64), FACE_TEMPLATE_112 * crop_size / 112)
inverse = np.linalg.inv(np.vstack([M, [0, 0, 1]]))[:2].ravel() # PIL maps output -> input
return img.transform((crop_size, crop_size), Image.AFFINE, tuple(inverse), resample=Image.BILINEAR)
def pil_to_tensor(img: Image.Image) -> torch.Tensor:
return torch.from_numpy(np.asarray(img, dtype=np.float32).copy()).permute(2, 0, 1)
class FaceAligner:
"""Our FaceDetector (face_detector.safetensors) finds the face and its 5 landmarks; the face is
then warped onto the template. image -> aligned (3, crop, crop) face tensor with values 0..255."""
def __init__(self, model_dir: str | Path, device: str = "cpu", crop_size: int = 128,
selection: str = "largest", detector: FaceDetector | None = None):
self.crop_size = crop_size
self.selection = selection # "largest" for photos; "center" for LFW (labelled by the centered person)
self.detector = detector if detector is not None else load_detector(model_dir, device)
def align_batch(self, images: list[Image.Image]) -> tuple[list[Image.Image], list[bool]]:
"""Same-size PIL images -> (aligned PIL faces, whether a face was found). No face -> center crop."""
faces, found = [], []
for img, (boxes, _, ldm) in zip(images, self.detector.detect(images)):
k = pick_face(boxes, img.size, self.selection)
if k is None:
faces.append(center_crop_face(img, self.crop_size)); found.append(False)
else:
faces.append(warp_face(img, ldm[k], self.crop_size)); found.append(True)
return faces, found
def __call__(self, image) -> tuple[torch.Tensor, bool]:
"""Image -> (CPU face tensor (3, crop, crop) 0..255, whether a face was found)."""
faces, found = self.align_batch([_limit_size(to_pil(image))])
return pil_to_tensor(faces[0]), found[0]
class FaceEmbedder:
"""Aligner + the trained embedders: image -> (embedding, aligned face, face detected)."""
def __init__(self, net: FaceRecognizer, aligner: FaceAligner, device: torch.device):
self.net, self.align, self.device = net, aligner, device
@torch.inference_mode()
def embed_faces(self, faces: torch.Tensor) -> np.ndarray:
"""(N, 3, crop, crop) faces 0..255 -> (N, 1024) float32 L2-normalised embeddings."""
return self.net.embed(faces.to(self.device)).float().cpu().numpy()
def embed(self, image) -> tuple[np.ndarray, torch.Tensor, bool]:
face, detected = self.align(image)
return self.embed_faces(face.unsqueeze(0))[0], face, detected
# --------------------------------------------------------------------------- inference API
class Predictor:
"""Loads detector, embedder and identity head once; identifies, verifies and embeds faces."""
def __init__(self, model_dir: str | Path, device: str = "cpu"):
model_dir = Path(model_dir)
_require(model_dir / WEIGHTS)
self.device = torch.device(device)
self.net = FaceRecognizer.from_pretrained(model_dir, map_location="cpu", strict=True)
self.net.to(self.device).eval()
self.class_names = self.net.class_names
self.verify_threshold = self.net.verify_threshold
aligner = FaceAligner(model_dir, str(self.device), self.net.crop_size)
self.embedder = FaceEmbedder(self.net, aligner, self.device)
@torch.inference_mode()
def predict_embeddings(self, embeddings: np.ndarray) -> np.ndarray:
"""(N, 1024) embeddings -> (N, n_classes) softmax probabilities (used by evaluation)."""
x = torch.as_tensor(np.asarray(embeddings, dtype=np.float32), device=self.device)
return self.net.classify(x).softmax(dim=1).float().cpu().numpy()
def _probs_dict(self, embedding: np.ndarray) -> dict[str, float]:
probs = self.predict_embeddings(embedding[None])[0]
order = np.argsort(-probs)
return {self.class_names[i]: float(probs[i]) for i in order}
def predict(self, image) -> dict[str, float]:
"""Face photo -> {person: probability} for all known people, most likely first (sums to 1)."""
embedding, _, _ = self.embedder.embed(image)
return self._probs_dict(embedding)
def analyze(self, image) -> dict:
"""Everything the Space shows: probabilities, the aligned crop, whether a face was detected
and the embedding (numpy)."""
embedding, face, detected = self.embedder.embed(image)
return {"probs": self._probs_dict(embedding), "face": face_to_pil(face),
"face_detected": detected, "embedding": embedding}
def embed(self, image) -> list[float]:
"""Face photo -> 1024-d L2-normalised embedding (plain floats)."""
return self.embedder.embed(image)[0].astype(float).tolist()
def verify(self, image_a, image_b, threshold: float | None = None) -> dict:
"""Are two photos the same person? Cosine distance of their embeddings vs `threshold`."""
threshold = self.verify_threshold if threshold is None else float(threshold)
emb_a, _, found_a = self.embedder.embed(image_a)
emb_b, _, found_b = self.embedder.embed(image_b)
distance = cosine_distance(emb_a, emb_b)
return {"same_person": bool(distance < threshold), "distance": round(distance, 6),
"threshold": threshold, "faces_detected": [found_a, found_b]}
def load(model_dir: str | Path, device: str = "cpu") -> Predictor:
return Predictor(model_dir, device)