Spaces:
Running
Running
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 +54 -92
- {calibration → historicalface}/clip.csv +0 -0
- {calibration → historicalface}/fusion.csv +0 -0
- {calibration → historicalface}/ires100-tune.csv +0 -0
- {calibration → historicalface}/ires100.csv +0 -0
- {calibration → historicalface}/lora.csv +0 -0
- museum/clip.csv +3 -0
- museum/ires100-tune.csv +3 -0
- museum/ires100.csv +3 -0
- museum/lora.csv +3 -0
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 |
-
|
| 43 |
-
|
| 44 |
-
|
| 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
|
| 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(
|
| 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 |
-
|
| 250 |
-
ax.hist(
|
| 251 |
-
|
| 252 |
-
|
| 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 |
-
|
| 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
|
|
|
|
| 309 |
with torch.no_grad():
|
| 310 |
e1, e2 = m(x1)[0].cpu().numpy(), m(x2)[0].cpu().numpy()
|
| 311 |
-
scores[n] = float(
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
np.
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 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[
|
| 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
|
| 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 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
|
|
|
| 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 |
-
|
| 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
|