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
- DATASET_DIRS = {
45
- "Historical Faces": "historicalface",
46
- "Museum": "museum",
47
- }
48
 
49
- def get_calibration_files(folder):
50
- return {
51
- model: os.path.join(folder, f"{model}.csv")
52
- for model in MODEL_VARIANTS
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, cal_folder):
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(calibration_files[name])[key_cols + ["score"]].rename(columns={"score": f"score_{name}"})
 
 
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 = (merged["probe_subject_id"] == merged["bio_ref_subject_id"]).astype(int).values
 
 
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
- ax.hist(llrs[cal["cohort_labels"] == 1], bins=40, alpha=0.6, label="Genuines", color="tab:blue", orientation='horizontal', density=True)
250
- ax.hist(llrs[cal["cohort_labels"] == 0], bins=40, alpha=0.6, label="Impostors", color="tab:orange", orientation='horizontal', density=True)
251
-
252
- ax.axhline(target_llr, color=TEXT, lw=3, ls='--', label=f"Result: {target_llr:.2f}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
 
254
  yticks = [-7, -5, -3, -1, 0, 1, 3, 5, 7]
255
- 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$"]
 
 
 
 
 
 
 
 
 
 
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("Calibrated Likelihood Distribution (Cohort Counts)", fontweight="bold")
262
- ax.grid(axis='y', alpha=0.2)
 
 
263
  ax.legend(frameon=False, loc="upper right")
264
  plt.tight_layout()
265
  return fig
266
 
267
 
268
- def process(img1, img2, models, method, cal_dataset):
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(np.dot(e1, e2) / (np.linalg.norm(e1) * np.linalg.norm(e2) + 1e-12))
286
-
287
- if method == "mean":
288
- f_score = np.mean(list(scores.values()))
289
- elif method == "median":
290
- f_score = np.median(list(scores.values()))
291
- else:
292
- f_score = np.max(list(scores.values()))
293
-
 
 
 
 
294
  dur = time.time() - start
295
 
296
  try:
297
- cal = get_cached_dynamic_calibrator(tuple(sorted(models)), method, cal_folder)
298
  res = apply_calibrator(f_score, cal)
299
- llr_val, interp = res['llr_10'], res['interpretation']
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)} · Dataset: <b>{cal_dataset}</b> · ⏱ {dur:.2f}s<br>
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('<div class="section-h">Inputs</div><div class="hint">Upload Reference and Probe images for alignment and identification.</div>')
 
 
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(MODEL_VARIANTS, value=["lora", "ires100-tune"], label="Active Models")
347
- met = gr.Radio(["mean", "median", "max"], value="mean", label="Fusion Method")
348
- cal_dir = gr.Dropdown(
349
- choices=list(DATASET_DIRS.keys()),
350
- value="Historical Faces",
351
- label="Calibration Dataset",
352
  )
353
 
354
  gr.HTML('<div style="height:1px; background:#E7E7EA; margin: 1.5rem 0;"></div>')
355
 
356
- gr.HTML('<div class="section-h">Results</div><div class="hint">Calibrated LLR based on ENFSI standards.</div>')
 
 
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('<div style="padding:1rem; text-align:center; color:#556070;">Execute analysis to see likelihood score.</div>')
 
 
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('<div class="footer">Research Demo · Idiap Research Institute · 2026</div>')
 
 
369
 
370
- run.click(process, [i1, i2, sel, met, cal_dir], [o1, o2, res_html, res_plot, res_table])
371
 
372
  if __name__ == "__main__":
373
  demo.launch(
374
  theme=gr.themes.Soft(primary_hue="orange", neutral_hue="slate"),
375
- css=CSS, share=True,
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