| |
| """Evaluate a face-recognition backbone on the LFW 6000 verification pairs.""" |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import csv |
| import json |
| import os |
| from dataclasses import asdict, dataclass |
| from pathlib import Path |
| from typing import Iterable |
|
|
| import matplotlib |
|
|
| matplotlib.use("Agg") |
|
|
| import matplotlib.pyplot as plt |
| import numpy as np |
| import torch |
| from facenet_pytorch import MTCNN, fixed_image_standardization |
| from PIL import Image |
| from sklearn.metrics import auc, confusion_matrix, roc_curve |
| from torch import nn |
| from torch.utils.data import DataLoader, Dataset |
| from torchvision import transforms |
| from tqdm import tqdm |
|
|
| try: |
| from face_backbones import build_backbone, canonical_backbone_name |
| except ModuleNotFoundError: |
| from .face_backbones import build_backbone, canonical_backbone_name |
|
|
|
|
| PROJECT_ROOT = Path(__file__).resolve().parents[1] |
| DEFAULT_LFW_ROOT = PROJECT_ROOT / "data" / "raw" / "lfw-deepfunneled" |
| DEFAULT_PAIRS = PROJECT_ROOT / "design" / "lfw_test_pair.txt" |
| DEFAULT_OUTPUT = PROJECT_ROOT / "results" |
| DEFAULT_MODEL_DIR = PROJECT_ROOT / "models" |
|
|
|
|
| @dataclass |
| class Pair: |
| image_a: str |
| image_b: str |
| label: int |
|
|
|
|
| @dataclass |
| class FoldResult: |
| fold: int |
| threshold: float |
| accuracy: float |
| true_positive: int |
| false_positive: int |
| true_negative: int |
| false_negative: int |
|
|
|
|
| class LFWImageDataset(Dataset): |
| def __init__( |
| self, |
| root: Path, |
| rel_paths: list[str], |
| preprocess: str, |
| image_size: int, |
| mtcnn_margin: int, |
| device: torch.device, |
| ): |
| self.root = root |
| self.rel_paths = rel_paths |
| self.preprocess = preprocess |
| self.image_size = image_size |
| self.device = device |
| self.resize = transforms.Resize((image_size, image_size), antialias=True) |
| self.to_tensor = transforms.PILToTensor() |
| self.mtcnn = None |
| if preprocess == "mtcnn": |
| self.mtcnn = MTCNN(image_size=image_size, margin=mtcnn_margin, post_process=True, device=device) |
|
|
| def __len__(self) -> int: |
| return len(self.rel_paths) |
|
|
| def __getitem__(self, index: int) -> tuple[str, torch.Tensor, bool]: |
| rel_path = self.rel_paths[index] |
| image_path = self.root / rel_path |
| with Image.open(image_path) as img: |
| img = img.convert("RGB") |
| if self.preprocess == "resize": |
| tensor = self.to_tensor(self.resize(img)).float() |
| tensor = fixed_image_standardization(tensor) |
| return rel_path, tensor, True |
|
|
| assert self.mtcnn is not None |
| face = self.mtcnn(img) |
| if face is None: |
| tensor = self.to_tensor(self.resize(img)).float() |
| tensor = fixed_image_standardization(tensor) |
| return rel_path, tensor, False |
| return rel_path, face.cpu(), True |
|
|
|
|
| def parse_pairs(path: Path) -> list[Pair]: |
| pairs: list[Pair] = [] |
| with path.open("r", encoding="utf-8") as f: |
| for line_no, line in enumerate(f, start=1): |
| parts = line.strip().split() |
| if not parts: |
| continue |
| if len(parts) != 3: |
| raise ValueError(f"Invalid pair line {line_no}: {line!r}") |
| pairs.append(Pair(parts[0], parts[1], int(parts[2]))) |
| if len(pairs) != 6000: |
| raise ValueError(f"Expected 6000 LFW pairs, got {len(pairs)}") |
| labels = np.array([p.label for p in pairs]) |
| if labels.sum() != 3000 or len(labels) - labels.sum() != 3000: |
| raise ValueError("Expected 3000 positive and 3000 negative pairs") |
| return pairs |
|
|
|
|
| def unique_image_paths(pairs: Iterable[Pair]) -> list[str]: |
| paths = sorted({p.image_a for p in pairs} | {p.image_b for p in pairs}) |
| return paths |
|
|
|
|
| def collate_images(batch: list[tuple[str, torch.Tensor, bool]]) -> tuple[list[str], torch.Tensor, list[bool]]: |
| paths, tensors, detected = zip(*batch) |
| return list(paths), torch.stack(list(tensors), dim=0), list(detected) |
|
|
|
|
| def build_model( |
| model_name: str, |
| backbone_name: str, |
| device: torch.device, |
| checkpoint: Path | None = None, |
| ) -> tuple[nn.Module, str]: |
| if checkpoint: |
| payload = torch.load(checkpoint, map_location="cpu") |
| checkpoint_backbone = canonical_backbone_name(payload.get("backbone", payload.get("model_arch", backbone_name))) |
| model, _, canonical = build_backbone(checkpoint_backbone, pretrained_model=None) |
| state = payload.get("backbone_state_dict", payload.get("model_state_dict", payload)) |
| missing, unexpected = model.load_state_dict(state, strict=False) |
| allowed_unexpected = [k for k in unexpected if k.startswith("logits.")] |
| if missing or len(allowed_unexpected) != len(unexpected): |
| raise RuntimeError(f"Checkpoint load issue. missing={missing}, unexpected={unexpected}") |
| else: |
| model, _, canonical = build_backbone(backbone_name, pretrained_model=model_name) |
| return model.eval().to(device), canonical |
|
|
|
|
| def extract_embeddings( |
| model: nn.Module, |
| dataset: LFWImageDataset, |
| batch_size: int, |
| num_workers: int, |
| device: torch.device, |
| tta_flip: bool, |
| ) -> tuple[dict[str, np.ndarray], dict[str, bool]]: |
| loader = DataLoader( |
| dataset, |
| batch_size=batch_size, |
| shuffle=False, |
| num_workers=num_workers, |
| collate_fn=collate_images, |
| pin_memory=(device.type == "cuda"), |
| ) |
| embeddings: dict[str, np.ndarray] = {} |
| detected: dict[str, bool] = {} |
| with torch.inference_mode(): |
| for rel_paths, batch, batch_detected in tqdm(loader, desc="Extract embeddings"): |
| batch = batch.to(device, non_blocking=True) |
| if tta_flip: |
| flipped = torch.flip(batch, dims=[3]) |
| out_all = model(torch.cat([batch, flipped], dim=0)) |
| out = (out_all[: batch.size(0)] + out_all[batch.size(0) :]) / 2.0 |
| else: |
| out = model(batch) |
| out = torch.nn.functional.normalize(out, p=2, dim=1) |
| out_np = out.detach().cpu().numpy().astype(np.float32) |
| for rel_path, emb, ok in zip(rel_paths, out_np, batch_detected): |
| embeddings[rel_path] = emb |
| detected[rel_path] = bool(ok) |
| return embeddings, detected |
|
|
|
|
| def pair_scores(pairs: list[Pair], embeddings: dict[str, np.ndarray]) -> tuple[np.ndarray, np.ndarray]: |
| scores = np.empty(len(pairs), dtype=np.float32) |
| labels = np.empty(len(pairs), dtype=np.int64) |
| for i, pair in enumerate(pairs): |
| emb_a = embeddings[pair.image_a] |
| emb_b = embeddings[pair.image_b] |
| scores[i] = float(np.dot(emb_a, emb_b)) |
| labels[i] = pair.label |
| return scores, labels |
|
|
|
|
| def best_threshold(scores: np.ndarray, labels: np.ndarray) -> tuple[float, float]: |
| order = np.argsort(scores) |
| sorted_scores = scores[order] |
| candidates = np.empty(len(sorted_scores) + 1, dtype=np.float32) |
| candidates[0] = sorted_scores[0] - 1e-6 |
| candidates[-1] = sorted_scores[-1] + 1e-6 |
| if len(sorted_scores) > 1: |
| candidates[1:-1] = (sorted_scores[:-1] + sorted_scores[1:]) / 2.0 |
|
|
| best_acc = -1.0 |
| best_thr = float(candidates[0]) |
| for threshold in candidates: |
| pred = (scores >= threshold).astype(np.int64) |
| acc = float((pred == labels).mean()) |
| if acc > best_acc: |
| best_acc = acc |
| best_thr = float(threshold) |
| return best_thr, best_acc |
|
|
|
|
| def fold_indices(num_pairs: int = 6000, folds: int = 10) -> list[np.ndarray]: |
| if num_pairs != 6000 or folds != 10: |
| raise ValueError("This helper is for the official 6000-pair, 10-fold LFW protocol") |
| fold_ids = [] |
| for fold in range(folds): |
| pos = np.arange(fold * 300, (fold + 1) * 300) |
| neg = np.arange(3000 + fold * 300, 3000 + (fold + 1) * 300) |
| fold_ids.append(np.concatenate([pos, neg])) |
| return fold_ids |
|
|
|
|
| def evaluate_10_fold(scores: np.ndarray, labels: np.ndarray) -> tuple[list[FoldResult], np.ndarray]: |
| ids = fold_indices(len(scores), 10) |
| all_indices = np.arange(len(scores)) |
| predictions = np.zeros_like(labels) |
| results: list[FoldResult] = [] |
| for fold, test_idx in enumerate(ids, start=1): |
| train_idx = np.setdiff1d(all_indices, test_idx) |
| threshold, _ = best_threshold(scores[train_idx], labels[train_idx]) |
| pred = (scores[test_idx] >= threshold).astype(np.int64) |
| predictions[test_idx] = pred |
| tn, fp, fn, tp = confusion_matrix(labels[test_idx], pred, labels=[0, 1]).ravel() |
| results.append( |
| FoldResult( |
| fold=fold, |
| threshold=threshold, |
| accuracy=float((pred == labels[test_idx]).mean()), |
| true_positive=int(tp), |
| false_positive=int(fp), |
| true_negative=int(tn), |
| false_negative=int(fn), |
| ) |
| ) |
| return results, predictions |
|
|
|
|
| def save_scores_csv(path: Path, pairs: list[Pair], scores: np.ndarray, labels: np.ndarray) -> None: |
| with path.open("w", encoding="utf-8", newline="") as f: |
| writer = csv.writer(f) |
| writer.writerow(["image_a", "image_b", "label", "cosine_score"]) |
| for pair, score, label in zip(pairs, scores, labels): |
| writer.writerow([pair.image_a, pair.image_b, int(label), f"{float(score):.8f}"]) |
|
|
|
|
| def save_fold_csv(path: Path, fold_results: list[FoldResult]) -> None: |
| with path.open("w", encoding="utf-8", newline="") as f: |
| writer = csv.DictWriter(f, fieldnames=list(asdict(fold_results[0]).keys())) |
| writer.writeheader() |
| for result in fold_results: |
| writer.writerow(asdict(result)) |
|
|
|
|
| def plot_roc(path: Path, labels: np.ndarray, scores: np.ndarray) -> float: |
| fpr, tpr, _ = roc_curve(labels, scores) |
| roc_auc = float(auc(fpr, tpr)) |
| fig, ax = plt.subplots(figsize=(6, 5)) |
| ax.plot(fpr, tpr, label=f"AUC = {roc_auc:.4f}", linewidth=2) |
| ax.plot([0, 1], [0, 1], linestyle="--", color="gray", linewidth=1) |
| ax.set_xlabel("False Positive Rate") |
| ax.set_ylabel("True Positive Rate") |
| ax.set_title("LFW ROC Curve") |
| ax.legend(loc="lower right") |
| ax.grid(alpha=0.25) |
| fig.tight_layout() |
| fig.savefig(path, dpi=200) |
| plt.close(fig) |
| return roc_auc |
|
|
|
|
| def plot_confusion(path: Path, labels: np.ndarray, predictions: np.ndarray) -> list[list[int]]: |
| cm = confusion_matrix(labels, predictions, labels=[0, 1]) |
| fig, ax = plt.subplots(figsize=(5, 4)) |
| im = ax.imshow(cm, cmap="Blues") |
| ax.set_xticks([0, 1], labels=["Different", "Same"]) |
| ax.set_yticks([0, 1], labels=["Different", "Same"]) |
| ax.set_xlabel("Predicted") |
| ax.set_ylabel("Ground Truth") |
| ax.set_title("LFW Confusion Matrix") |
| for i in range(2): |
| for j in range(2): |
| ax.text(j, i, str(int(cm[i, j])), ha="center", va="center", color="black") |
| fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04) |
| fig.tight_layout() |
| fig.savefig(path, dpi=200) |
| plt.close(fig) |
| return cm.astype(int).tolist() |
|
|
|
|
| def plot_score_histogram(path: Path, labels: np.ndarray, scores: np.ndarray, threshold: float) -> None: |
| fig, ax = plt.subplots(figsize=(7, 4)) |
| ax.hist(scores[labels == 1], bins=60, alpha=0.75, label="Same person") |
| ax.hist(scores[labels == 0], bins=60, alpha=0.75, label="Different people") |
| ax.axvline(threshold, color="black", linestyle="--", linewidth=1.5, label=f"Global threshold {threshold:.4f}") |
| ax.set_xlabel("Cosine Similarity") |
| ax.set_ylabel("Pair Count") |
| ax.set_title("LFW Pair Score Distribution") |
| ax.legend() |
| fig.tight_layout() |
| fig.savefig(path, dpi=200) |
| plt.close(fig) |
|
|
|
|
| def verify_files_exist(lfw_root: Path, pairs: list[Pair]) -> None: |
| missing = [] |
| for rel_path in unique_image_paths(pairs): |
| if not (lfw_root / rel_path).is_file(): |
| missing.append(rel_path) |
| if len(missing) >= 10: |
| break |
| if missing: |
| raise FileNotFoundError(f"Missing LFW images under {lfw_root}: {missing}") |
|
|
|
|
| def save_model_artifact(model_name: str, model_dir: Path, checkpoint: Path | None = None) -> Path: |
| if checkpoint is not None: |
| return checkpoint.resolve() |
| checkpoint_name = { |
| "casia-webface": "20180408-102900-casia-webface.pt", |
| "vggface2": "20180402-114759-vggface2.pt", |
| }[model_name] |
| source = Path(os.environ.get("TORCH_HOME", str(model_dir / "torch"))) / "checkpoints" / checkpoint_name |
| target = model_dir / f"facenet_{model_name.replace('-', '_')}.pth" |
| if source.exists(): |
| import shutil |
|
|
| shutil.copy2(source, target) |
| return target |
|
|
|
|
| def write_metrics( |
| path: Path, |
| args: argparse.Namespace, |
| labels: np.ndarray, |
| scores: np.ndarray, |
| predictions: np.ndarray, |
| fold_results: list[FoldResult], |
| global_threshold: float, |
| global_accuracy: float, |
| roc_auc: float, |
| confusion: list[list[int]], |
| detected: dict[str, bool], |
| model_artifact: Path, |
| backbone_name: str, |
| ) -> None: |
| fold_accuracies = [r.accuracy for r in fold_results] |
| metrics = { |
| "model": "local_checkpoint" if args.checkpoint else args.model, |
| "pretrained_model_arg": None if args.checkpoint else args.model, |
| "backbone": backbone_name, |
| "checkpoint": str(args.checkpoint) if args.checkpoint else None, |
| "preprocess": args.preprocess, |
| "mtcnn_margin": int(args.mtcnn_margin), |
| "tta_flip": bool(args.tta_flip), |
| "image_size": int(args.image_size), |
| "device": args.device, |
| "lfw_root": str(args.lfw_root), |
| "pairs_file": str(args.pairs_file), |
| "num_pairs": int(len(labels)), |
| "num_positive_pairs": int(labels.sum()), |
| "num_negative_pairs": int(len(labels) - labels.sum()), |
| "num_unique_images": int(len(detected)), |
| "mtcnn_or_resize_success_rate": float(np.mean(list(detected.values()))), |
| "ten_fold_accuracy_mean": float(np.mean(fold_accuracies)), |
| "ten_fold_accuracy_std": float(np.std(fold_accuracies)), |
| "ten_fold_accuracies": fold_accuracies, |
| "global_best_threshold": float(global_threshold), |
| "global_best_accuracy": float(global_accuracy), |
| "roc_auc": float(roc_auc), |
| "confusion_matrix_10_fold": confusion, |
| "model_artifact": str(model_artifact), |
| } |
| with path.open("w", encoding="utf-8") as f: |
| json.dump(metrics, f, indent=2, ensure_ascii=False) |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument("--lfw-root", type=Path, default=DEFAULT_LFW_ROOT) |
| parser.add_argument("--pairs-file", type=Path, default=DEFAULT_PAIRS) |
| parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT) |
| parser.add_argument("--model-dir", type=Path, default=DEFAULT_MODEL_DIR) |
| parser.add_argument("--model", choices=["casia-webface", "vggface2"], default="casia-webface") |
| parser.add_argument( |
| "--backbone", |
| choices=["inception_resnet_v1", "ir_resnet18", "ir_resnet34"], |
| default="inception_resnet_v1", |
| help="used only without --checkpoint; checkpoints carry their own backbone", |
| ) |
| parser.add_argument("--checkpoint", type=Path, default=None, help="local scratch-trained backbone checkpoint") |
| parser.add_argument("--preprocess", choices=["resize", "mtcnn"], default="resize") |
| parser.add_argument("--image-size", type=int, default=160) |
| parser.add_argument("--mtcnn-margin", type=int, default=0) |
| parser.add_argument("--batch-size", type=int, default=512) |
| parser.add_argument("--num-workers", type=int, default=4) |
| parser.add_argument("--tta-flip", action="store_true", help="average embeddings from original and horizontal flip") |
| parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu") |
| return parser.parse_args() |
|
|
|
|
| def main() -> None: |
| args = parse_args() |
| project_torch_home = args.model_dir / "torch" |
| os.environ.setdefault("TORCH_HOME", str(project_torch_home)) |
| args.output_dir.mkdir(parents=True, exist_ok=True) |
| args.model_dir.mkdir(parents=True, exist_ok=True) |
|
|
| pairs = parse_pairs(args.pairs_file) |
| verify_files_exist(args.lfw_root, pairs) |
|
|
| device = torch.device(args.device) |
| model, backbone_name = build_model(args.model, args.backbone, device, args.checkpoint) |
| rel_paths = unique_image_paths(pairs) |
| dataset = LFWImageDataset(args.lfw_root, rel_paths, args.preprocess, args.image_size, args.mtcnn_margin, device) |
| embeddings, detected = extract_embeddings(model, dataset, args.batch_size, args.num_workers, device, args.tta_flip) |
| scores, labels = pair_scores(pairs, embeddings) |
|
|
| fold_results, predictions = evaluate_10_fold(scores, labels) |
| global_threshold, global_accuracy = best_threshold(scores, labels) |
| roc_auc = plot_roc(args.output_dir / "roc_curve.png", labels, scores) |
| confusion = plot_confusion(args.output_dir / "confusion_matrix.png", labels, predictions) |
| plot_score_histogram(args.output_dir / "score_histogram.png", labels, scores, global_threshold) |
|
|
| save_scores_csv(args.output_dir / "pair_scores.csv", pairs, scores, labels) |
| save_fold_csv(args.output_dir / "fold_metrics.csv", fold_results) |
| np.savez_compressed( |
| args.output_dir / "lfw_embeddings.npz", |
| paths=np.array(rel_paths), |
| embeddings=np.stack([embeddings[p] for p in rel_paths]), |
| ) |
| model_artifact = save_model_artifact(args.model, args.model_dir, args.checkpoint) |
| write_metrics( |
| args.output_dir / "metrics.json", |
| args, |
| labels, |
| scores, |
| predictions, |
| fold_results, |
| global_threshold, |
| global_accuracy, |
| roc_auc, |
| confusion, |
| detected, |
| model_artifact, |
| backbone_name, |
| ) |
|
|
| mean_acc = np.mean([r.accuracy for r in fold_results]) |
| std_acc = np.std([r.accuracy for r in fold_results]) |
| print(f"LFW 10-fold accuracy: {mean_acc:.4%} ± {std_acc:.4%}") |
| print(f"ROC AUC: {roc_auc:.6f}") |
| print(f"Global best accuracy: {global_accuracy:.4%} @ threshold {global_threshold:.6f}") |
| print(f"Results written to: {args.output_dir}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|