File size: 7,808 Bytes
e0c1479 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 | """LFW verification benchmark for TinyFaceNet (standard 10-fold protocol, 6000 pairs).
Images: funneled LFW (sklearn download, pairs.txt parsed directly, images read one at a time). Faces are re-detected + 5-pt aligned with buffalo_l -> 112x112,
so the input matches training. Metric: 10-fold cross-validated accuracy with threshold picked on the other 9 folds (the LFW standard),
plus best-threshold accuracy, AUC, and TAR@FAR=1e-2/1e-3.
Stranger test (--strangers, needs a .pt ckpt with centroids + dataset/faces_v2.npz): treats all 12k LFW faces as people outside the family and reports,
per open-set threshold, how many get accepted as a family member vs how many held-out family faces are still recognised.
usage: python eval_lfw.py [ckpt=dataset/model_v1.pt | *.onnx] [--teacher] [--strangers] (--teacher = also score buffalo_l itself as reference)
Cache: analysis/lfw_aligned.npz (aligned crops, built once, ~10 min CPU). Output: analysis/lfw_<tag>.json"""
import sys, os, json, time, numpy as np, cv2, torch, torch.nn.functional as F
import onnxruntime as ort
_IS=ort.InferenceSession
class _CappedSession(_IS): # cap ORT threads BEFORE insightface imports; default = all cores spin-waiting on 5 tiny models -> thrash
def __init__(s,path,sess_options=None,**kw):
so=ort.SessionOptions(); so.intra_op_num_threads=int(os.environ.get("ORT_THREADS","4")); so.inter_op_num_threads=1
super().__init__(path,sess_options=so,**kw)
ort.InferenceSession=_CappedSession
torch.set_num_threads(int(os.environ.get("THREADS","10")))
args=[a for a in sys.argv[1:] if not a.startswith("--")]; CK=args[0] if args else "dataset/model_v1.pt"; TAG=os.path.splitext(os.path.basename(CK))[0]
CACHE="analysis/lfw_aligned.npz"
# ---------------- 1. aligned crops (cached) ----------------
if not os.path.exists(CACHE):
from sklearn.datasets import get_data_home
from insightface.app import FaceAnalysis
from insightface.utils.face_align import norm_crop
home=os.path.join(get_data_home(),"lfw_home")
if not os.path.exists(os.path.join(home,"pairs.txt")): # first time: let sklearn fetch the tar + pairs.txt (does not load arrays)
from sklearn.datasets._lfw import _check_fetch_lfw; _check_fetch_lfw(data_home=get_data_home(),funneled=True,download_if_missing=True)
IMG=os.path.join(home,"lfw_funneled"); pairs=[]
for l in open(os.path.join(home,"pairs.txt")).read().splitlines()[1:]:
s=l.split(" ")
if len(s)==3: pairs.append(((s[0],int(s[1])),(s[0],int(s[2])),1))
elif len(s)==4: pairs.append(((s[0],int(s[1])),(s[2],int(s[3])),0))
N=len(pairs); Y=np.array([p[2] for p in pairs],np.int8); print("pairs",N,"pos",int(Y.sum()),flush=True)
def load(name,idx): return cv2.imread(os.path.join(IMG,name,f"{name}_{idx:04d}.jpg")) # BGR 250x250
P=None
app=FaceAnalysis(name="buffalo_l",providers=["CPUExecutionProvider"],allowed_modules=["detection","recognition"]); app.prepare(ctx_id=-1,det_size=(320,320))
crops=np.zeros((N,2,112,112,3),np.uint8); ok=np.ones((N,2),bool); temb=np.zeros((N,2,512),np.float32); t0=time.time()
for i in range(N):
for k in range(2):
img=load(*pairs[i][k]); faces=app.get(img)
if not faces: # fallback: center crop (LFW faces are centered)
ok[i,k]=False; crops[i,k]=cv2.cvtColor(cv2.resize(img[70:180,70:180],(112,112)),cv2.COLOR_BGR2RGB); continue
c=np.array([125,125]); f=min(faces,key=lambda f:np.linalg.norm((f.bbox[:2]+f.bbox[2:])/2-c)) # most central face
crops[i,k]=cv2.cvtColor(norm_crop(img,f.kps,112),cv2.COLOR_BGR2RGB); temb[i,k]=f.normed_embedding
if i%200==0: print(f" aligned {i}/{N} {time.time()-t0:.0f}s det_fail={int((~ok[:i+1]).sum())}",flush=True)
np.savez_compressed(CACHE,crops=crops,y=Y,ok=ok,teacher=temb); print("cached",CACHE,"det_fail",int((~ok).sum()))
d=np.load(CACHE); X=d["crops"]; Y=d["y"].astype(bool); N=len(Y)
# ---------------- 2. embed ----------------
def prep(x): return ((x.transpose(0,3,1,2).astype(np.float32)/255.)-0.5)/0.5
def embed_all(fn):
flat=X.reshape(-1,112,112,3); out=[]
for i in range(0,len(flat),128): out.append(fn(prep(flat[i:i+128])))
E=np.concatenate(out); E/=np.linalg.norm(E,axis=1,keepdims=True); return E.reshape(N,2,-1)
t0=time.time()
if CK.endswith(".onnx"):
import onnxruntime as ort; s=ort.InferenceSession(CK,providers=["CPUExecutionProvider"]); n=s.get_inputs()[0].name
E=embed_all(lambda x:s.run(None,{n:x})[0])
else:
from model import TinyFaceNet
ck=torch.load(CK,weights_only=False); m=TinyFaceNet(); m.load_state_dict(ck["model"],strict=False); m.eval()
def run(x):
with torch.no_grad(): return m(torch.from_numpy(x)).numpy()
E=embed_all(run)
lat=(time.time()-t0)/(2*N)*1000; print(f"embedded {2*N} faces in {time.time()-t0:.0f}s ({lat:.1f} ms/face)")
# ---------------- 3. metrics ----------------
def kfold_acc(sim,y,k=10):
folds=np.array_split(np.arange(len(y)),k); ths=np.arange(-1,1,0.002); accs=[]
for f in folds:
tr=np.setdiff1d(np.arange(len(y)),f); a=[((sim[tr]>=t)==y[tr]).mean() for t in ths]; t=ths[int(np.argmax(a))]
accs.append(((sim[f]>=t)==y[f]).mean())
return float(np.mean(accs)),float(np.std(accs))
def tar_at_far(sim,y,far):
neg=np.sort(sim[~y])[::-1]; t=neg[int(far*len(neg))]; return float((sim[y]>=t).mean()),float(t)
def report(E,name):
sim=(E[:,0]*E[:,1]).sum(1); acc,sd=kfold_acc(sim,Y)
ths=np.arange(-1,1,0.002); best=max(((sim>=t)==Y).mean() for t in ths)
from sklearn.metrics import roc_auc_score
r=dict(acc_10fold=round(acc,4),std=round(sd,4),acc_best_thr=round(float(best),4),auc=round(float(roc_auc_score(Y,sim)),4),
tar_far1e2=round(tar_at_far(sim,Y,1e-2)[0],4),tar_far1e3=round(tar_at_far(sim,Y,1e-3)[0],4),
mean_sim_pos=round(float(sim[Y].mean()),3),mean_sim_neg=round(float(sim[~Y].mean()),3))
print(f"{name:12s} LFW 10-fold acc={acc:.4f}+-{sd:.4f} best={best:.4f} AUC={r['auc']:.4f} TAR@FAR1e-2={r['tar_far1e2']:.3f} TAR@FAR1e-3={r['tar_far1e3']:.3f} pos/neg sim={r['mean_sim_pos']}/{r['mean_sim_neg']}")
return r
res={"model":CK,"n_pairs":int(N),"det_fail":int((~d["ok"]).sum()),"lat_ms":round(lat,2),"student":report(E,TAG)}
if "--strangers" in sys.argv and not CK.endswith(".onnx") and "centroids" in ck:
names=list(map(str,ck["names"])); C=ck["centroids"].numpy(); fam_idx=[i for i in range(len(names)) if names[i]!="other"]; Cf=C[fam_idx]
f=np.load("dataset/faces_v2.npz"); lab=np.array(list(map(str,f["labels"]))); DT=f["date"]
fam=np.array([(x[:6] in ("202511","202605","202606")) for x in DT])&(lab!="other")
with torch.no_grad(): EF=F.normalize(m(torch.from_numpy(prep(f["imgs"][fam]))),dim=1).numpy()
yf=np.array([names.index(l) for l in lab[fam]]); EL=E.reshape(-1,E.shape[-1])
sL=(EL@Cf.T).max(1); sF=EF@Cf.T; predF=np.array(fam_idx)[sF.argmax(1)]; bestF=sF.max(1)
print(f"strangers n={len(EL)} max-sim to family mean={sL.mean():.3f} median={np.median(sL):.3f}; family test faces n={len(EF)}")
st={}
for thr in [0.45,0.50,0.55,0.60,0.65,0.70,0.75,0.80]:
fa=float((sL>=thr).mean()); rec=float(((bestF>=thr)&(predF==yf)).mean()); st[str(thr)]=dict(stranger_accept=round(fa,4),family_recall=round(rec,4))
print(f" THR={thr:.2f} stranger accepted as family {fa*100:5.1f}% family recognised {rec*100:5.1f}%")
res["stranger_test"]=dict(n_strangers=int(len(EL)),n_family=int(len(EF)),stranger_max_sim_mean=round(float(sL.mean()),4),by_thr=st)
if "--teacher" in sys.argv: res["teacher_buffalo_l"]=report(d["teacher"]/np.maximum(np.linalg.norm(d["teacher"],axis=2,keepdims=True),1e-8),"buffalo_l")
json.dump(res,open(f"analysis/lfw_{TAG}.json","w"),indent=1); print("wrote",f"analysis/lfw_{TAG}.json")
|