# SPDX-FileCopyrightText: Copyright © 2025 Idiap Research Institute # SPDX-FileContributor: Francois Poh # SPDX-FileContributor: Anjith George # SPDX-FileContributor: Samuel Michel # # SPDX-License-Identifier: GPL-3.0-or-later from __future__ import annotations import os import time from functools import lru_cache from collections.abc import Callable import numpy as np import pandas as pd import torch import gradio as gr import matplotlib.pyplot as plt import onnxruntime as ort from lib.models import get_model from lib.align import get_preprocessor from calibrate_score import ( fit_calibrator_from_csv, fit_calibrator_from_scores, apply_calibrator, ) ort.set_default_logger_severity(3) # ===================================================== # Configuration # ===================================================== MODEL_VARIANTS = ["clip", "lora", "ires100", "ires100-tune"] DEVICE = "cuda" if torch.cuda.is_available() else "cpu" PROJECT_URL = "https://www.idiap.ch/paper/artface/" ARXIV_URL = "https://arxiv.org/abs/2508.20626" DATASET_DIRS = { "Historical Faces": "historicalface", "Museum": "museum", } def get_calibration_files(folder): return { model: os.path.join(folder, f"{model}.csv") for model in MODEL_VARIANTS } # ===================================================== # Original Palette & Friendly Professional Styling # ===================================================== PRIMARY = "#F97316" # Original Orange BG = "#FBFBFC" CARD = "#FFFFFF" BORDER = "#E7E7EA" TEXT = "#0B1220" MUTED = "#556070" CSS = f""" @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&display=swap'); body, .gradio-container {{ background: {BG} !important; font-family: 'Inter', sans-serif !important; color: {TEXT}; }} #wrap {{ max-width: 940px; margin: 0 auto; }} .gr-block, .gr-box, .gr-panel {{ background: {CARD} !important; border: 1px solid {BORDER} !important; border-radius: 16px !important; box-shadow: none !important; }} .gr-button-primary {{ background: {TEXT} !important; border: none !important; color: white !important; font-weight: 800 !important; border-radius: 12px !important; }} .kicker {{ display:flex; align-items:center; justify-content:space-between; gap: 1rem; padding: 1.5rem 0 .5rem 0; }} .title {{ font-size: 1.8rem; font-weight: 900; letter-spacing: -0.02em; color: {TEXT}; }} .subtitle {{ color: {MUTED}; font-weight: 600; font-size: .95rem; }} .chip {{ border: 1px solid {BORDER}; border-radius: 999px; padding: .25rem .7rem; font-weight: 800; font-size: .75rem; color: {MUTED}; background: #fff; }} .section-h {{ font-size: 1rem; font-weight: 900; margin-bottom: .2rem; }} .hint {{ color: {MUTED}; font-weight: 600; font-size: .9rem; margin-bottom: 1rem; }} .fused-box {{ display:flex; align-items:center; justify-content:space-between; padding: 1.25rem; border-radius: 16px; border: 1px solid {BORDER}; background: #fff; margin: 1rem 0; }} .fused-title {{ font-weight: 900; font-size: 1.1rem; margin-bottom: .2rem; }} .fused-meta {{ color: {MUTED}; font-weight: 700; font-size: .85rem; line-height: 1.4; }} .llr-val {{ font-size: 2.2rem; font-weight: 950; color: {PRIMARY}; letter-spacing: -0.03em; }} .pill {{ display: inline-block; padding: .2rem .6rem; border-radius: 6px; font-weight: 800; font-size: .75rem; margin-top: .5rem; }} .footer {{ text-align:center; color: {MUTED}; font-weight: 600; padding: 2rem 0; font-size: .85rem; }} """ TITLE_HTML = f"""
ArtFace Demo
Forensic face identification for historical portraits
GPU: {"ON" if torch.cuda.is_available() else "OFF"}
Calibration: ENFSI
Project Page arXiv Paper
""" # ===================================================== # Backend # ===================================================== aligner = get_preprocessor("align") MODELS = {} for name in MODEL_VARIANTS: model, prep = get_model(name).torch() model.eval().to(DEVICE) MODELS[name] = (model, prep) @lru_cache(maxsize=None) def get_cached_dynamic_calibrator(selected_models_key, fuse_method, cal_folder): selected_models = list(selected_models_key) calibration_files = get_calibration_files(cal_folder) key_cols = ["probe_subject_id", "bio_ref_subject_id"] merged = None for name in selected_models: df = pd.read_csv(calibration_files[name])[key_cols + ["score"]].rename(columns={"score": f"score_{name}"}) merged = df if merged is None else merged.merge(df, on=key_cols, how="inner") score_cols = [f"score_{name}" for name in selected_models] labels = (merged["probe_subject_id"] == merged["bio_ref_subject_id"]).astype(int).values scores_mat = merged[score_cols].values if fuse_method == "median": fused = np.median(scores_mat, axis=1) elif fuse_method == "max": fused = np.max(scores_mat, axis=1) else: fused = np.mean(scores_mat, axis=1) calibrator = fit_calibrator_from_scores(fused, labels, P_tar=0.5) calibrator.update({"cohort_scores": fused, "cohort_labels": labels}) return calibrator def make_plot(cal, target_score): w, b = float(cal["w"]), float(cal["b"]) llrs = ((w * cal["cohort_scores"]) + b) / np.log(10) target_llr = ((w * target_score) + b) / np.log(10) fig, ax = plt.subplots(figsize=(9, 5), facecolor=BG) ax.hist(llrs[cal["cohort_labels"] == 1], bins=40, alpha=0.6, label="Genuines", color="tab:blue", orientation='horizontal', density=True) ax.hist(llrs[cal["cohort_labels"] == 0], bins=40, alpha=0.6, label="Impostors", color="tab:orange", orientation='horizontal', density=True) ax.axhline(target_llr, color=TEXT, lw=3, ls='--', label=f"Result: {target_llr:.2f}") yticks = [-7, -5, -3, -1, 0, 1, 3, 5, 7] ylabs = ["Extreme $H_I$", "V.Strong $H_I$", "Strong $H_I$", "Weak $H_I$", "Neutral", "Weak $H_G$", "Strong $H_G$", "V.Strong $H_G$", "Extreme $H_G$"] ax.set_yticks(yticks) ax.set_yticklabels(ylabs, fontsize=9) ax.set_ylim([-8, 8]) ax.set_ylabel("ENFSI Verbal Scale") ax.set_xlabel("Frequency (Counts)") ax.set_title("Calibrated Likelihood Distribution (Cohort Counts)", fontweight="bold") ax.grid(axis='y', alpha=0.2) ax.legend(frameon=False, loc="upper right") plt.tight_layout() return fig def process(img1, img2, models, method, cal_dataset): if not img1 or not img2: return [None] * 4 + [pd.DataFrame()] a1, a2 = aligner(img1), aligner(img2) if not a1 or not a2: return [None] * 2 + ["No face detected", None, pd.DataFrame()] cal_folder = DATASET_DIRS[cal_dataset] start = time.time() scores = {} for n in models: m, prep = MODELS[n] x1 = prep(a1).unsqueeze(0).to(DEVICE) x2 = prep(a2).unsqueeze(0).to(DEVICE) with torch.no_grad(): e1, e2 = m(x1)[0].cpu().numpy(), m(x2)[0].cpu().numpy() scores[n] = float(np.dot(e1, e2) / (np.linalg.norm(e1) * np.linalg.norm(e2) + 1e-12)) if method == "mean": f_score = np.mean(list(scores.values())) elif method == "median": f_score = np.median(list(scores.values())) else: f_score = np.max(list(scores.values())) dur = time.time() - start try: cal = get_cached_dynamic_calibrator(tuple(sorted(models)), method, cal_folder) res = apply_calibrator(f_score, cal) llr_val, interp = res['llr_10'], res['interpretation'] plot = make_plot(cal, f_score) except Exception as e: llr_val, interp, plot = 0.0, f"Error: {str(e)}", None pill_bg = "#DCFCE7" if llr_val > 1 else ("#FEF2F2" if llr_val < -1 else "#F1F5F9") pill_tx = "#166534" if llr_val > 1 else ("#991B1B" if llr_val < -1 else "#475569") html = f"""
Likelihood Ratio (Log₁₀)
Method: {method} · Models: {len(models)} · Dataset: {cal_dataset} · ⏱ {dur:.2f}s
Verdict: {interp}
{llr_val:.3f}
LLR Score
""" rows = [{"Model": n, "Similarity": round(s, 4)} for n, s in scores.items()] return a1, a2, html, plot, pd.DataFrame(rows) # ===================================================== # UI # ===================================================== with gr.Blocks(title="ArtFace") as demo: with gr.Column(elem_id="wrap"): gr.HTML(TITLE_HTML) with gr.Group(): gr.HTML('
Inputs
Upload Reference and Probe images for alignment and identification.
') with gr.Row(): i1 = gr.Image(label="Image A", type="pil", height=300) i2 = gr.Image(label="Image B", type="pil", height=300) with gr.Row(): run = gr.Button("Compare Identities", variant="primary") clear = gr.ClearButton([i1, i2], value="Clear") with gr.Accordion("Analysis Settings", open=False): sel = gr.CheckboxGroup(MODEL_VARIANTS, value=["lora", "ires100-tune"], label="Active Models") met = gr.Radio(["mean", "median", "max"], value="mean", label="Fusion Method") cal_dir = gr.Dropdown( choices=list(DATASET_DIRS.keys()), value="Historical Faces", label="Calibration Dataset", ) gr.HTML('
') gr.HTML('
Results
Calibrated LLR based on ENFSI standards.
') with gr.Row(): o1 = gr.Image(label="Aligned A", height=150, interactive=False) o2 = gr.Image(label="Aligned B", height=150, interactive=False) res_html = gr.HTML('
Execute analysis to see likelihood score.
') with gr.Row(): res_table = gr.Dataframe(label="Individual Scores", interactive=False) res_plot = gr.Plot(label="Likelihood Distribution") gr.HTML('') run.click(process, [i1, i2, sel, met, cal_dir], [o1, o2, res_html, res_plot, res_table]) if __name__ == "__main__": demo.launch( theme=gr.themes.Soft(primary_hue="orange", neutral_hue="slate"), css=CSS, share=True, )