mchlsam anjith2006 commited on
Commit
cd3e549
·
1 Parent(s): a5d528c

Add historical and museum calibration (#3)

Browse files

- Upload 10 files (1e36fec5ecdb02e59d72ac5735fd69450d537c43)
- chore remove unsued folder/files (b6c691f49684e270df414de4ebf0e171e8b30cef)


Co-authored-by: Anjith George <anjith2006@users.noreply.huggingface.co>

app.py CHANGED
@@ -10,6 +10,7 @@ from __future__ import annotations
10
  import os
11
  import time
12
  from functools import lru_cache
 
13
 
14
  import numpy as np
15
  import pandas as pd
@@ -23,6 +24,7 @@ from lib.models import get_model
23
  from lib.align import get_preprocessor
24
 
25
  from calibrate_score import (
 
26
  fit_calibrator_from_scores,
27
  apply_calibrator,
28
  )
@@ -39,15 +41,17 @@ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
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
53
  # =====================================================
@@ -199,7 +203,7 @@ TITLE_HTML = f"""
199
  """
200
 
201
  # =====================================================
202
- # Backend (Same functionality)
203
  # =====================================================
204
 
205
  aligner = get_preprocessor("align")
@@ -211,22 +215,18 @@ for name in MODEL_VARIANTS:
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,87 +246,57 @@ 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
- # 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,7 +309,7 @@ def process(img1, img2, models, method):
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,7 +325,7 @@ def process(img1, img2, models, method):
355
 
356
 
357
  # =====================================================
358
- # UI (Original Column Style)
359
  # =====================================================
360
 
361
  with gr.Blocks(title="ArtFace") as demo:
@@ -363,9 +333,7 @@ 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,40 +343,34 @@ with gr.Blocks(title="ArtFace") as demo:
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
- )
 
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
  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
  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
57
  # =====================================================
 
203
  """
204
 
205
  # =====================================================
206
+ # Backend
207
  # =====================================================
208
 
209
  aligner = get_preprocessor("align")
 
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
  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
  <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
 
326
 
327
  # =====================================================
328
+ # UI
329
  # =====================================================
330
 
331
  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
  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", "ires100"], 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
+ )
 
{calibration → historicalface}/clip.csv RENAMED
File without changes
{calibration → historicalface}/fusion.csv RENAMED
File without changes
{calibration → historicalface}/ires100-tune.csv RENAMED
File without changes
{calibration → historicalface}/ires100.csv RENAMED
File without changes
{calibration → historicalface}/lora.csv RENAMED
File without changes
museum/clip.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cf94347de725e9a224eb1a6a85d552a2aa588b32c46123033d6b5af5f24c044f
3
+ size 441370
museum/ires100-tune.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3bf596b9457652fcf24cc165f6c23b0a185f8270ce025bde68ad6fce1e7dfe6d
3
+ size 446220
museum/ires100.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:08a9ad6f73162e595665fd83cb772d612f7d5e482ebef69afe64f890537039bb
3
+ size 444890
museum/lora.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3ad8555c44492c3182903c3670646b6c087a3d762707c419a88898de62a85755
3
+ size 443032