File size: 4,730 Bytes
902df3d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import os, numpy as np, torch, torch.nn as nn, torch.nn.functional as F, time, json, collections, sys
from model import TinyFaceNet
torch.manual_seed(0); np.random.seed(0); torch.set_num_threads(int(os.environ.get("THREADS","10")))  # leave cores for the desktop
import os; d=np.load(os.environ.get("DATA","dataset/faces.npz")); X=d["imgs"]; T=d["embs"]; L=d["labels"]; S=d["src"]; DT=d["date"]
names=sorted(set(L)); idx={n:i for i,n in enumerate(names)}; Y=np.array([idx[l] for l in L])
# date-based split: hold out Nov-2025 and May/Jun-2026
test=np.array([(x[:6] in ("202511","202605","202606")) for x in DT]); train=~test
print("classes",names); print("train",train.sum(),"test",test.sum())
print("train per class",dict(collections.Counter(L[train]))); print("test per class",dict(collections.Counter(L[test])))
EPOCHS=int(sys.argv[1]) if len(sys.argv)>1 else 40; BS=128
def aug(x):  # x: B,112,112,3 uint8 numpy -> float tensor B,3,112,112
    x=torch.from_numpy(x).permute(0,3,1,2).float()/255.
    B=x.shape[0]
    flip=torch.rand(B)<0.5; x[flip]=x[flip].flip(-1)
    # random shift/scale via affine
    th=torch.zeros(B,2,3); sc=1+0.15*(torch.rand(B)*2-1); th[:,0,0]=sc; th[:,1,1]=sc; th[:,:,2]=0.12*(torch.rand(B,2)*2-1)
    x=F.grid_sample(x,F.affine_grid(th,x.shape,align_corners=False),padding_mode="border",align_corners=False)
    # color jitter
    x=x*(1+0.4*(torch.rand(B,1,1,1)-0.5))+0.2*(torch.rand(B,1,1,1)-0.5)
    # low-light sim: gamma darken (p=0.4), contrast crush (p=0.3), sensor noise (p=0.4)
    g=torch.rand(B)<0.4
    if g.any(): x[g]=x[g].clamp(1e-3,1)**(1/(0.3+0.7*torch.rand(int(g.sum()),1,1,1)))*(0.5+0.5*torch.rand(int(g.sum()),1,1,1))
    cc=torch.rand(B)<0.3
    if cc.any(): mu=x[cc].mean((1,2,3),keepdim=True); x[cc]=(x[cc]-mu)*(0.4+0.6*torch.rand(int(cc.sum()),1,1,1))+mu
    nz=torch.rand(B)<0.4
    if nz.any(): x[nz]=x[nz]+torch.randn_like(x[nz])*(0.08*torch.rand(int(nz.sum()),1,1,1))
    gray=torch.rand(B)<0.15; x[gray]=x[gray].mean(1,keepdim=True).expand(-1,3,-1,-1)
    blur=torch.rand(B)<0.2
    if blur.any(): x[blur]=F.avg_pool2d(x[blur],3,1,1)
    # cutout
    for i in torch.nonzero(torch.rand(B)<0.3).flatten():
        cy,cx=np.random.randint(0,112,2); x[i,:,max(0,cy-16):cy+16,max(0,cx-16):cx+16]=0
    return (x.clamp(0,1)-0.5)/0.5
def prep(x): return (torch.from_numpy(x).permute(0,3,1,2).float()/255.-0.5)/0.5
m=TinyFaceNet(n_cls=len(names)); opt=torch.optim.AdamW(m.parameters(),2e-3,weight_decay=5e-4)
tr=np.nonzero(train)[0]; te=np.nonzero(test)[0]; Tt=torch.from_numpy(T)
# ---- cap "other" in train: prefer jpg crops, fill with video frames (spread out) ----
OTHER_CAP=600
oth=tr[L[tr]=="other"]; fam=tr[L[tr]!="other"]
oj=oth[np.char.startswith(S[oth],"jpg:")]; ov=oth[~np.char.startswith(S[oth],"jpg:")]
need=max(0,OTHER_CAP-len(oj)); ov=ov[np.linspace(0,len(ov)-1,min(need,len(ov))).astype(int)] if need else ov[:0]
tr=np.concatenate([fam,oj[:OTHER_CAP],ov])
print("train after other-cap",len(tr),dict(collections.Counter(L[tr])))
# ---- class-balanced sampling (sqrt inverse freq) ----
cnt=collections.Counter(Y[tr]); w=np.array([1/np.sqrt(cnt[y]) for y in Y[tr]]); w/=w.sum()
steps=EPOCHS*(len(tr)//BS+1); sched=torch.optim.lr_scheduler.OneCycleLR(opt,2e-3,total_steps=steps,pct_start=0.1)
def embed(ix):
    m.eval(); out=[]
    with torch.no_grad():
        for i in range(0,len(ix),256): out.append(F.normalize(m(prep(X[ix[i:i+256]])),dim=1))
    return torch.cat(out)
def evaluate():
    Etr=embed(tr); Ete=embed(te); ytr=torch.from_numpy(Y[tr]); yte=torch.from_numpy(Y[te])
    C=torch.stack([F.normalize(Etr[ytr==c].mean(0),dim=0) if (ytr==c).any() else torch.zeros(Etr.shape[1]) for c in range(len(names))])
    sim=Ete@C.T; pred=sim.argmax(1); acc=(pred==yte).float().mean().item()
    per={str(names[c]):round((pred[yte==c]==c).float().mean().item(),3) for c in range(len(names)) if (yte==c).any()}
    fam=yte!=idx["other"]; facc=(pred[fam]==yte[fam]).float().mean().item() if fam.any() else 0.
    return facc,per,C
best=0
for ep in range(EPOCHS):
    m.train(); ep_ix=np.random.choice(tr,size=len(tr),replace=True,p=w); t0=time.time(); tl=0
    for i in range(0,len(ep_ix),BS):
        b=ep_ix[i:i+BS]; x=aug(X[b]); y=torch.from_numpy(Y[b]); t=Tt[b]
        e=m(x); lc=F.cross_entropy(m.cls(e),y,label_smoothing=0.1); ld=1-F.cosine_similarity(m.distill(e),t).mean()
        loss=lc+2.0*ld; opt.zero_grad(); loss.backward(); opt.step(); sched.step(); tl+=loss.item()*len(b)
    acc,per,C=evaluate()
    print(f"ep{ep} loss={tl/len(tr):.3f} fam_acc={acc:.4f} {per} {time.time()-t0:.0f}s",flush=True)
    if acc>=best: best=acc; torch.save({"model":m.state_dict(),"names":names,"centroids":C},os.environ.get("OUT","dataset/best.pt"))
print("BEST",best)