Spaces:
Sleeping
Sleeping
museum
#2
by anjith2006 - opened
- app.py +92 -54
- {historicalface → calibration}/clip.csv +0 -0
- {historicalface → calibration}/fusion.csv +0 -0
- {historicalface → calibration}/ires100-tune.csv +0 -0
- {historicalface → calibration}/ires100.csv +0 -0
- {historicalface → calibration}/lora.csv +0 -0
- museum/clip.csv +0 -3
- museum/ires100-tune.csv +0 -3
- museum/ires100.csv +0 -3
- museum/lora.csv +0 -3
app.py
CHANGED
|
@@ -10,7 +10,6 @@ from __future__ import annotations
|
|
| 10 |
import os
|
| 11 |
import time
|
| 12 |
from functools import lru_cache
|
| 13 |
-
from collections.abc import Callable
|
| 14 |
|
| 15 |
import numpy as np
|
| 16 |
import pandas as pd
|
|
@@ -24,7 +23,6 @@ from lib.models import get_model
|
|
| 24 |
from lib.align import get_preprocessor
|
| 25 |
|
| 26 |
from calibrate_score import (
|
| 27 |
-
fit_calibrator_from_csv,
|
| 28 |
fit_calibrator_from_scores,
|
| 29 |
apply_calibrator,
|
| 30 |
)
|
|
@@ -41,16 +39,14 @@ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
|
| 41 |
PROJECT_URL = "https://www.idiap.ch/paper/artface/"
|
| 42 |
ARXIV_URL = "https://arxiv.org/abs/2508.20626"
|
| 43 |
|
| 44 |
-
|
| 45 |
-
"Historical Faces": "historicalface",
|
| 46 |
-
"Museum": "museum",
|
| 47 |
-
}
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
| 54 |
|
| 55 |
# =====================================================
|
| 56 |
# Original Palette & Friendly Professional Styling
|
|
@@ -203,7 +199,7 @@ TITLE_HTML = f"""
|
|
| 203 |
"""
|
| 204 |
|
| 205 |
# =====================================================
|
| 206 |
-
# Backend
|
| 207 |
# =====================================================
|
| 208 |
|
| 209 |
aligner = get_preprocessor("align")
|
|
@@ -215,18 +211,22 @@ for name in MODEL_VARIANTS:
|
|
| 215 |
|
| 216 |
|
| 217 |
@lru_cache(maxsize=None)
|
| 218 |
-
def get_cached_dynamic_calibrator(selected_models_key, fuse_method
|
| 219 |
selected_models = list(selected_models_key)
|
| 220 |
-
calibration_files = get_calibration_files(cal_folder)
|
| 221 |
key_cols = ["probe_subject_id", "bio_ref_subject_id"]
|
| 222 |
merged = None
|
| 223 |
for name in selected_models:
|
| 224 |
-
df = pd.read_csv(
|
|
|
|
|
|
|
| 225 |
merged = df if merged is None else merged.merge(df, on=key_cols, how="inner")
|
| 226 |
|
| 227 |
score_cols = [f"score_{name}" for name in selected_models]
|
| 228 |
-
labels = (
|
|
|
|
|
|
|
| 229 |
|
|
|
|
| 230 |
scores_mat = merged[score_cols].values
|
| 231 |
if fuse_method == "median":
|
| 232 |
fused = np.median(scores_mat, axis=1)
|
|
@@ -246,57 +246,87 @@ def make_plot(cal, target_score):
|
|
| 246 |
target_llr = ((w * target_score) + b) / np.log(10)
|
| 247 |
|
| 248 |
fig, ax = plt.subplots(figsize=(9, 5), facecolor=BG)
|
| 249 |
-
|
| 250 |
-
ax.hist(
|
| 251 |
-
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
|
| 254 |
yticks = [-7, -5, -3, -1, 0, 1, 3, 5, 7]
|
| 255 |
-
ylabs = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
ax.set_yticks(yticks)
|
| 257 |
ax.set_yticklabels(ylabs, fontsize=9)
|
| 258 |
ax.set_ylim([-8, 8])
|
| 259 |
ax.set_ylabel("ENFSI Verbal Scale")
|
| 260 |
ax.set_xlabel("Frequency (Counts)")
|
| 261 |
-
ax.set_title(
|
| 262 |
-
|
|
|
|
|
|
|
| 263 |
ax.legend(frameon=False, loc="upper right")
|
| 264 |
plt.tight_layout()
|
| 265 |
return fig
|
| 266 |
|
| 267 |
|
| 268 |
-
def process(img1, img2, models, method
|
| 269 |
if not img1 or not img2:
|
| 270 |
return [None] * 4 + [pd.DataFrame()]
|
| 271 |
a1, a2 = aligner(img1), aligner(img2)
|
| 272 |
if not a1 or not a2:
|
| 273 |
return [None] * 2 + ["No face detected", None, pd.DataFrame()]
|
| 274 |
|
| 275 |
-
cal_folder = DATASET_DIRS[cal_dataset]
|
| 276 |
-
|
| 277 |
start = time.time()
|
| 278 |
scores = {}
|
| 279 |
for n in models:
|
| 280 |
m, prep = MODELS[n]
|
| 281 |
-
x1 = prep(a1).unsqueeze(0).to(DEVICE)
|
| 282 |
-
x2 = prep(a2).unsqueeze(0).to(DEVICE)
|
| 283 |
with torch.no_grad():
|
| 284 |
e1, e2 = m(x1)[0].cpu().numpy(), m(x2)[0].cpu().numpy()
|
| 285 |
-
scores[n] = float(
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
dur = time.time() - start
|
| 295 |
|
| 296 |
try:
|
| 297 |
-
cal = get_cached_dynamic_calibrator(tuple(sorted(models)), method
|
| 298 |
res = apply_calibrator(f_score, cal)
|
| 299 |
-
llr_val, interp = res[
|
| 300 |
plot = make_plot(cal, f_score)
|
| 301 |
except Exception as e:
|
| 302 |
llr_val, interp, plot = 0.0, f"Error: {str(e)}", None
|
|
@@ -309,7 +339,7 @@ def process(img1, img2, models, method, cal_dataset):
|
|
| 309 |
<div>
|
| 310 |
<div class="fused-title">Likelihood Ratio (Log₁₀)</div>
|
| 311 |
<div class="fused-meta">
|
| 312 |
-
Method: <b>{method}</b> · Models: {len(models)} ·
|
| 313 |
<span class="pill" style="background:{pill_bg}; color:{pill_tx};">Verdict: {interp}</span>
|
| 314 |
</div>
|
| 315 |
</div>
|
|
@@ -325,7 +355,7 @@ def process(img1, img2, models, method, cal_dataset):
|
|
| 325 |
|
| 326 |
|
| 327 |
# =====================================================
|
| 328 |
-
# UI
|
| 329 |
# =====================================================
|
| 330 |
|
| 331 |
with gr.Blocks(title="ArtFace") as demo:
|
|
@@ -333,7 +363,9 @@ with gr.Blocks(title="ArtFace") as demo:
|
|
| 333 |
gr.HTML(TITLE_HTML)
|
| 334 |
|
| 335 |
with gr.Group():
|
| 336 |
-
gr.HTML(
|
|
|
|
|
|
|
| 337 |
with gr.Row():
|
| 338 |
i1 = gr.Image(label="Image A", type="pil", height=300)
|
| 339 |
i2 = gr.Image(label="Image B", type="pil", height=300)
|
|
@@ -343,34 +375,40 @@ with gr.Blocks(title="ArtFace") as demo:
|
|
| 343 |
clear = gr.ClearButton([i1, i2], value="Clear")
|
| 344 |
|
| 345 |
with gr.Accordion("Analysis Settings", open=False):
|
| 346 |
-
sel = gr.CheckboxGroup(
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
value="
|
| 351 |
-
label="Calibration Dataset",
|
| 352 |
)
|
| 353 |
|
| 354 |
gr.HTML('<div style="height:1px; background:#E7E7EA; margin: 1.5rem 0;"></div>')
|
| 355 |
|
| 356 |
-
gr.HTML(
|
|
|
|
|
|
|
| 357 |
|
| 358 |
with gr.Row():
|
| 359 |
o1 = gr.Image(label="Aligned A", height=150, interactive=False)
|
| 360 |
o2 = gr.Image(label="Aligned B", height=150, interactive=False)
|
| 361 |
|
| 362 |
-
res_html = gr.HTML(
|
|
|
|
|
|
|
| 363 |
|
| 364 |
with gr.Row():
|
| 365 |
res_table = gr.Dataframe(label="Individual Scores", interactive=False)
|
| 366 |
res_plot = gr.Plot(label="Likelihood Distribution")
|
| 367 |
|
| 368 |
-
gr.HTML(
|
|
|
|
|
|
|
| 369 |
|
| 370 |
-
run.click(process, [i1, i2, sel, met
|
| 371 |
|
| 372 |
if __name__ == "__main__":
|
| 373 |
demo.launch(
|
| 374 |
theme=gr.themes.Soft(primary_hue="orange", neutral_hue="slate"),
|
| 375 |
-
css=CSS,
|
| 376 |
-
|
|
|
|
|
|
| 10 |
import os
|
| 11 |
import time
|
| 12 |
from functools import lru_cache
|
|
|
|
| 13 |
|
| 14 |
import numpy as np
|
| 15 |
import pandas as pd
|
|
|
|
| 23 |
from lib.align import get_preprocessor
|
| 24 |
|
| 25 |
from calibrate_score import (
|
|
|
|
| 26 |
fit_calibrator_from_scores,
|
| 27 |
apply_calibrator,
|
| 28 |
)
|
|
|
|
| 39 |
PROJECT_URL = "https://www.idiap.ch/paper/artface/"
|
| 40 |
ARXIV_URL = "https://arxiv.org/abs/2508.20626"
|
| 41 |
|
| 42 |
+
CALIBRATION_DIR = "calibration"
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
+
CALIBRATION_FILES = {
|
| 45 |
+
"clip": os.path.join(CALIBRATION_DIR, "clip.csv"),
|
| 46 |
+
"lora": os.path.join(CALIBRATION_DIR, "lora.csv"),
|
| 47 |
+
"ires100": os.path.join(CALIBRATION_DIR, "ires100.csv"),
|
| 48 |
+
"ires100-tune": os.path.join(CALIBRATION_DIR, "ires100-tune.csv"),
|
| 49 |
+
}
|
| 50 |
|
| 51 |
# =====================================================
|
| 52 |
# Original Palette & Friendly Professional Styling
|
|
|
|
| 199 |
"""
|
| 200 |
|
| 201 |
# =====================================================
|
| 202 |
+
# Backend (Same functionality)
|
| 203 |
# =====================================================
|
| 204 |
|
| 205 |
aligner = get_preprocessor("align")
|
|
|
|
| 211 |
|
| 212 |
|
| 213 |
@lru_cache(maxsize=None)
|
| 214 |
+
def get_cached_dynamic_calibrator(selected_models_key, fuse_method):
|
| 215 |
selected_models = list(selected_models_key)
|
|
|
|
| 216 |
key_cols = ["probe_subject_id", "bio_ref_subject_id"]
|
| 217 |
merged = None
|
| 218 |
for name in selected_models:
|
| 219 |
+
df = pd.read_csv(CALIBRATION_FILES[name])[key_cols + ["score"]].rename(
|
| 220 |
+
columns={"score": f"score_{name}"}
|
| 221 |
+
)
|
| 222 |
merged = df if merged is None else merged.merge(df, on=key_cols, how="inner")
|
| 223 |
|
| 224 |
score_cols = [f"score_{name}" for name in selected_models]
|
| 225 |
+
labels = (
|
| 226 |
+
(merged["probe_subject_id"] == merged["bio_ref_subject_id"]).astype(int).values
|
| 227 |
+
)
|
| 228 |
|
| 229 |
+
# Simple manual fusion for the cohort
|
| 230 |
scores_mat = merged[score_cols].values
|
| 231 |
if fuse_method == "median":
|
| 232 |
fused = np.median(scores_mat, axis=1)
|
|
|
|
| 246 |
target_llr = ((w * target_score) + b) / np.log(10)
|
| 247 |
|
| 248 |
fig, ax = plt.subplots(figsize=(9, 5), facecolor=BG)
|
| 249 |
+
# Frequency histogram (not density)
|
| 250 |
+
ax.hist(
|
| 251 |
+
llrs[cal["cohort_labels"] == 1],
|
| 252 |
+
bins=40,
|
| 253 |
+
alpha=0.6,
|
| 254 |
+
label="Genuines",
|
| 255 |
+
color="tab:blue",
|
| 256 |
+
orientation="horizontal",
|
| 257 |
+
density=True,
|
| 258 |
+
)
|
| 259 |
+
ax.hist(
|
| 260 |
+
llrs[cal["cohort_labels"] == 0],
|
| 261 |
+
bins=40,
|
| 262 |
+
alpha=0.6,
|
| 263 |
+
label="Impostors",
|
| 264 |
+
color="tab:orange",
|
| 265 |
+
orientation="horizontal",
|
| 266 |
+
density=True,
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
ax.axhline(target_llr, color=TEXT, lw=3, ls="--", label=f"Result: {target_llr:.2f}")
|
| 270 |
|
| 271 |
yticks = [-7, -5, -3, -1, 0, 1, 3, 5, 7]
|
| 272 |
+
ylabs = [
|
| 273 |
+
"Extreme $H_I$",
|
| 274 |
+
"V.Strong $H_I$",
|
| 275 |
+
"Strong $H_I$",
|
| 276 |
+
"Weak $H_I$",
|
| 277 |
+
"Neutral",
|
| 278 |
+
"Weak $H_G$",
|
| 279 |
+
"Strong $H_G$",
|
| 280 |
+
"V.Strong $H_G$",
|
| 281 |
+
"Extreme $H_G$",
|
| 282 |
+
]
|
| 283 |
ax.set_yticks(yticks)
|
| 284 |
ax.set_yticklabels(ylabs, fontsize=9)
|
| 285 |
ax.set_ylim([-8, 8])
|
| 286 |
ax.set_ylabel("ENFSI Verbal Scale")
|
| 287 |
ax.set_xlabel("Frequency (Counts)")
|
| 288 |
+
ax.set_title(
|
| 289 |
+
"Calibrated Likelihood Distribution (Cohort Counts)", fontweight="bold"
|
| 290 |
+
)
|
| 291 |
+
ax.grid(axis="y", alpha=0.2)
|
| 292 |
ax.legend(frameon=False, loc="upper right")
|
| 293 |
plt.tight_layout()
|
| 294 |
return fig
|
| 295 |
|
| 296 |
|
| 297 |
+
def process(img1, img2, models, method):
|
| 298 |
if not img1 or not img2:
|
| 299 |
return [None] * 4 + [pd.DataFrame()]
|
| 300 |
a1, a2 = aligner(img1), aligner(img2)
|
| 301 |
if not a1 or not a2:
|
| 302 |
return [None] * 2 + ["No face detected", None, pd.DataFrame()]
|
| 303 |
|
|
|
|
|
|
|
| 304 |
start = time.time()
|
| 305 |
scores = {}
|
| 306 |
for n in models:
|
| 307 |
m, prep = MODELS[n]
|
| 308 |
+
x1, x2 = prep(a1).unsqueeze(0).to(DEVICE), prep(a2).unsqueeze(0).to(DEVICE)
|
|
|
|
| 309 |
with torch.no_grad():
|
| 310 |
e1, e2 = m(x1)[0].cpu().numpy(), m(x2)[0].cpu().numpy()
|
| 311 |
+
scores[n] = float(
|
| 312 |
+
np.dot(e1, e2) / (np.linalg.norm(e1) * np.linalg.norm(e2) + 1e-12)
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
f_score = (
|
| 316 |
+
np.mean(list(scores.values()))
|
| 317 |
+
if method == "mean"
|
| 318 |
+
else (
|
| 319 |
+
np.median(list(scores.values()))
|
| 320 |
+
if method == "median"
|
| 321 |
+
else np.max(list(scores.values()))
|
| 322 |
+
)
|
| 323 |
+
)
|
| 324 |
dur = time.time() - start
|
| 325 |
|
| 326 |
try:
|
| 327 |
+
cal = get_cached_dynamic_calibrator(tuple(sorted(models)), method)
|
| 328 |
res = apply_calibrator(f_score, cal)
|
| 329 |
+
llr_val, interp = res["llr_10"], res["interpretation"]
|
| 330 |
plot = make_plot(cal, f_score)
|
| 331 |
except Exception as e:
|
| 332 |
llr_val, interp, plot = 0.0, f"Error: {str(e)}", None
|
|
|
|
| 339 |
<div>
|
| 340 |
<div class="fused-title">Likelihood Ratio (Log₁₀)</div>
|
| 341 |
<div class="fused-meta">
|
| 342 |
+
Method: <b>{method}</b> · Models: {len(models)} · ⏱ {dur:.2f}s<br>
|
| 343 |
<span class="pill" style="background:{pill_bg}; color:{pill_tx};">Verdict: {interp}</span>
|
| 344 |
</div>
|
| 345 |
</div>
|
|
|
|
| 355 |
|
| 356 |
|
| 357 |
# =====================================================
|
| 358 |
+
# UI (Original Column Style)
|
| 359 |
# =====================================================
|
| 360 |
|
| 361 |
with gr.Blocks(title="ArtFace") as demo:
|
|
|
|
| 363 |
gr.HTML(TITLE_HTML)
|
| 364 |
|
| 365 |
with gr.Group():
|
| 366 |
+
gr.HTML(
|
| 367 |
+
'<div class="section-h">Inputs</div><div class="hint">Upload Reference and Probe images for alignment and identification.</div>'
|
| 368 |
+
)
|
| 369 |
with gr.Row():
|
| 370 |
i1 = gr.Image(label="Image A", type="pil", height=300)
|
| 371 |
i2 = gr.Image(label="Image B", type="pil", height=300)
|
|
|
|
| 375 |
clear = gr.ClearButton([i1, i2], value="Clear")
|
| 376 |
|
| 377 |
with gr.Accordion("Analysis Settings", open=False):
|
| 378 |
+
sel = gr.CheckboxGroup(
|
| 379 |
+
MODEL_VARIANTS, value=["lora"], label="Active Models"
|
| 380 |
+
)
|
| 381 |
+
met = gr.Radio(
|
| 382 |
+
["mean", "median", "max"], value="mean", label="Fusion Method"
|
|
|
|
| 383 |
)
|
| 384 |
|
| 385 |
gr.HTML('<div style="height:1px; background:#E7E7EA; margin: 1.5rem 0;"></div>')
|
| 386 |
|
| 387 |
+
gr.HTML(
|
| 388 |
+
'<div class="section-h">Results</div><div class="hint">Calibrated LLR based on ENFSI standards.</div>'
|
| 389 |
+
)
|
| 390 |
|
| 391 |
with gr.Row():
|
| 392 |
o1 = gr.Image(label="Aligned A", height=150, interactive=False)
|
| 393 |
o2 = gr.Image(label="Aligned B", height=150, interactive=False)
|
| 394 |
|
| 395 |
+
res_html = gr.HTML(
|
| 396 |
+
'<div style="padding:1rem; text-align:center; color:#556070;">Execute analysis to see likelihood score.</div>'
|
| 397 |
+
)
|
| 398 |
|
| 399 |
with gr.Row():
|
| 400 |
res_table = gr.Dataframe(label="Individual Scores", interactive=False)
|
| 401 |
res_plot = gr.Plot(label="Likelihood Distribution")
|
| 402 |
|
| 403 |
+
gr.HTML(
|
| 404 |
+
'<div class="footer">Research Demo · Idiap Research Institute · 2026</div>'
|
| 405 |
+
)
|
| 406 |
|
| 407 |
+
run.click(process, [i1, i2, sel, met], [o1, o2, res_html, res_plot, res_table])
|
| 408 |
|
| 409 |
if __name__ == "__main__":
|
| 410 |
demo.launch(
|
| 411 |
theme=gr.themes.Soft(primary_hue="orange", neutral_hue="slate"),
|
| 412 |
+
css=CSS,
|
| 413 |
+
share=True,
|
| 414 |
+
)
|
{historicalface → calibration}/clip.csv
RENAMED
|
File without changes
|
{historicalface → calibration}/fusion.csv
RENAMED
|
File without changes
|
{historicalface → calibration}/ires100-tune.csv
RENAMED
|
File without changes
|
{historicalface → calibration}/ires100.csv
RENAMED
|
File without changes
|
{historicalface → calibration}/lora.csv
RENAMED
|
File without changes
|
museum/clip.csv
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:cf94347de725e9a224eb1a6a85d552a2aa588b32c46123033d6b5af5f24c044f
|
| 3 |
-
size 441370
|
|
|
|
|
|
|
|
|
|
|
|
museum/ires100-tune.csv
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:3bf596b9457652fcf24cc165f6c23b0a185f8270ce025bde68ad6fce1e7dfe6d
|
| 3 |
-
size 446220
|
|
|
|
|
|
|
|
|
|
|
|
museum/ires100.csv
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:08a9ad6f73162e595665fd83cb772d612f7d5e482ebef69afe64f890537039bb
|
| 3 |
-
size 444890
|
|
|
|
|
|
|
|
|
|
|
|
museum/lora.csv
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:3ad8555c44492c3182903c3670646b6c087a3d762707c419a88898de62a85755
|
| 3 |
-
size 443032
|
|
|
|
|
|
|
|
|
|
|
|