weathon commited on
Commit
35ec68e
·
verified ·
1 Parent(s): d050fd9

Clean DecoyFace implementation and checkpoints

Browse files
README.md ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ library_name: pytorch
3
+ tags:
4
+ - face-recognition
5
+ - privacy
6
+ ---
7
+
8
+ # DecoyFace
9
+
10
+ Implementation of DecoyFace split face recognition privacy protection:
11
+ client-side Decoy-Guided Coherent Mixing (DGCM) and the server-side Authorized Canonicalization Module (ACM),
12
+ together with the scripts that build the Reconstruction-Sensitive Subspace Decomposition (RSSD) statistics and train the server backbone.
13
+
14
+ The client encoder `F_c` is AdaFace IR-50 (MS1MV2) stem + stage 1 (output 64x56x56);
15
+ the server backbone `F_s` is the rest of the IR-50.
16
+
17
+ ## Files
18
+
19
+ | File | Content |
20
+ | --- | --- |
21
+ | `decoyface.py` | `DecoyProtector`: `protect(z_A, z_B)` (client, DGCM) and `server_recover(z_t)` (server, ACM) |
22
+ | `models.py` | IR-50, its split into `ClientEncoder` (F_c) / `ServerBackbone` (F_s), and the proxy inversion U-Net used by RSSD |
23
+ | `common.py` | DecoyFace hyper-parameters `CFG`, fixed channel permutation, checkpoint paths |
24
+ | `data.py` | MS1M identity-folder dataset |
25
+ | `build_rssd.py` | Trains the proxy inversion U-Net, estimates the reconstruction-sensitive subspace -> `checkpoints/rssd.pt` |
26
+ | `build_bank.py` | Builds the auxiliary complementary bank and `mu_rec` -> `checkpoints/bank.pt` |
27
+ | `train_defender.py` | Trains F_s + ArcFace head on canonicalized protected features -> `checkpoints/defender.pt` |
28
+
29
+ ## Checkpoints
30
+
31
+ | File | Content |
32
+ | --- | --- |
33
+ | `checkpoints/adaface_ir50_ms1mv2.backbone.pth` | AdaFace IR-50 MS1MV2 backbone state dict (from https://github.com/mk-minchul/AdaFace) |
34
+ | `checkpoints/rssd.pt` | `P_rec`, `P_N` (64x64), `U_rec`, `evals`, `sigma` |
35
+ | `checkpoints/bank.pt` | `aux_bank` (5000x64x56x56, fp16), `mu_rec` (64x56x56), `perm` (64) |
36
+
37
+ `proxy_unet.pt` and `defender.pt` are not included.
38
+
39
+ ## Usage
40
+
41
+ ```python
42
+ import torch
43
+ from common import ADAFACE_CKPT, CKPT, CFG
44
+ from models import load_ir50, ClientEncoder
45
+ from decoyface import DecoyProtector
46
+
47
+ Fc = ClientEncoder(load_ir50(ADAFACE_CKPT, "cuda")).cuda().eval()
48
+ rssd = torch.load(CKPT / "rssd.pt", map_location="cuda")
49
+ bank = torch.load(CKPT / "bank.pt", map_location="cuda")
50
+ protector = DecoyProtector(rssd["P_rec"], rssd["P_N"], bank["mu_rec"],
51
+ bank["aux_bank"].float(), bank["perm"], CFG, "cuda")
52
+
53
+ # x, decoy: (B,3,112,112) RGB in [0,1], ArcFace-aligned; decoy is a different identity
54
+ with torch.no_grad():
55
+ z_t = protector.protect(Fc(x), Fc(decoy)) # transmitted by the client
56
+ z_can = protector.server_recover(z_t) # fed to F_s on the server
57
+ ```
58
+
59
+ ## Rebuilding
60
+
61
+ MS1M root: identity folders named by integer, each holding 112x112 aligned `.jpg` faces.
62
+ Identities used: proxy U-Net first 5000, auxiliary bank 50000-60000, defender first 30000 (`--num_ids`).
63
+
64
+ ```bash
65
+ python build_rssd.py --ms1m_root /path/to/ms1m
66
+ python build_bank.py --ms1m_root /path/to/ms1m
67
+ python train_defender.py --ms1m_root /path/to/ms1m
68
+ ```
69
+
70
+ Scripts log to Weights & Biases (project `decoyface-repro`).
build_bank.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build the auxiliary complementary bank D_N = {P_N F_c(y_j)} and the fixed
2
+ reconstruction-sensitive anchor mu_rec = mean_j P_rec F_c(y_j) (paper Eq. 5, 17).
3
+
4
+ Auxiliary samples are drawn from a dedicated MS1M identity range disjoint from
5
+ the proxy / defender training identities, mirroring the paper's use of a
6
+ separate auxiliary set.
7
+
8
+ Outputs checkpoints/bank.pt with aux_bank (M,C,56,56, fp16), mu_rec (C,56,56)
9
+ and the fixed channel-shuffle permutation perm (C,).
10
+ """
11
+ import os
12
+ import glob
13
+ import argparse
14
+
15
+ import torch
16
+
17
+ from models import load_ir50, ClientEncoder
18
+ from decoyface import proj
19
+ from data import load_rgb01
20
+ from common import ADAFACE_CKPT, CKPT, CFG, DEVICE, fixed_perm
21
+
22
+ AUX_ID_LO, AUX_ID_HI = 50000, 60000 # dedicated auxiliary identity range
23
+
24
+
25
+ def main():
26
+ ap = argparse.ArgumentParser()
27
+ ap.add_argument("--ms1m_root", required=True)
28
+ args = ap.parse_args()
29
+
30
+ dev = DEVICE
31
+ ir50 = load_ir50(ADAFACE_CKPT, dev)
32
+ Fc = ClientEncoder(ir50).to(dev).eval()
33
+ rssd = torch.load(CKPT / "rssd.pt", map_location=dev)
34
+ P_rec, P_N = rssd["P_rec"].to(dev), rssd["P_N"].to(dev)
35
+
36
+ ids = sorted(os.listdir(args.ms1m_root), key=int)[AUX_ID_LO:AUX_ID_HI]
37
+ paths = []
38
+ for ident in ids:
39
+ paths.extend(sorted(glob.glob(f"{args.ms1m_root}/{ident}/*.jpg")))
40
+ assert len(paths) >= CFG["M"], f"aux pool {len(paths)} < M {CFG['M']}"
41
+ g = torch.Generator().manual_seed(7)
42
+ pick = torch.randperm(len(paths), generator=g)[:CFG["M"]]
43
+ paths = [paths[i] for i in pick.tolist()]
44
+
45
+ bank = torch.empty(CFG["M"], CFG["C"], 56, 56, dtype=torch.float16)
46
+ mu_rec = torch.zeros(CFG["C"], 56, 56, device=dev)
47
+ bs = 128
48
+ with torch.no_grad():
49
+ for i in range(0, len(paths), bs):
50
+ imgs = torch.stack([load_rgb01(p) for p in paths[i:i + bs]]).to(dev)
51
+ z = Fc(imgs)
52
+ bank[i:i + imgs.shape[0]] = proj(P_N, z).half().cpu()
53
+ mu_rec += proj(P_rec, z).sum(0)
54
+ mu_rec /= len(paths)
55
+
56
+ torch.save({"aux_bank": bank, "mu_rec": mu_rec.cpu(),
57
+ "perm": fixed_perm(CFG["C"])}, CKPT / "bank.pt")
58
+ print(f"bank {tuple(bank.shape)} mu_rec {tuple(mu_rec.shape)} "
59
+ f"from {len(paths)} aux samples", flush=True)
60
+
61
+
62
+ if __name__ == "__main__":
63
+ main()
build_rssd.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RSSD: train the proxy inversion network Phi, then estimate the
2
+ reconstruction-sensitive subspace from the gradient covariance (paper Sec. IV-B).
3
+
4
+ Outputs checkpoints/rssd.pt with P_rec, P_N (C x C), U_rec, eigenvalues, and the
5
+ trained proxy checkpoint checkpoints/proxy_unet.pt.
6
+ """
7
+ import argparse
8
+
9
+ import torch
10
+ import torch.nn.functional as F
11
+ from torch.utils.data import DataLoader
12
+ import wandb
13
+
14
+ from models import load_ir50, ClientEncoder, UNetInverter
15
+ from data import MS1MDataset
16
+ from common import ADAFACE_CKPT, CKPT, CFG, DEVICE
17
+
18
+ PROXY_IDS = 5000 # identity folders used to train the proxy
19
+ PROXY_ITERS = 30000 # paper: 30k iterations
20
+ BATCH = 128
21
+ COV_SAMPLES = 20000 # samples used to estimate the gradient covariance
22
+
23
+
24
+ def infinite(loader):
25
+ while True:
26
+ for batch in loader:
27
+ yield batch
28
+
29
+
30
+ def main():
31
+ ap = argparse.ArgumentParser()
32
+ ap.add_argument("--ms1m_root", required=True)
33
+ args = ap.parse_args()
34
+
35
+ CKPT.mkdir(exist_ok=True)
36
+ dev = DEVICE
37
+ wandb.init(project="decoyface-repro", name="rssd", mode="online", config=CFG)
38
+
39
+ ir50 = load_ir50(ADAFACE_CKPT, dev)
40
+ Fc = ClientEncoder(ir50).to(dev).eval()
41
+ for p in Fc.parameters():
42
+ p.requires_grad_(False)
43
+
44
+ ds = MS1MDataset(args.ms1m_root, PROXY_IDS)
45
+ loader = DataLoader(ds, batch_size=BATCH, shuffle=True, num_workers=8,
46
+ drop_last=True, pin_memory=True, persistent_workers=True)
47
+ it = infinite(loader)
48
+
49
+ proxy = UNetInverter().to(dev)
50
+ opt = torch.optim.Adam(proxy.parameters(), lr=1e-3)
51
+
52
+ proxy_path = CKPT / "proxy_unet.pt"
53
+ resume_path = CKPT / "proxy_unet_resume.pt"
54
+ start = 0
55
+ if resume_path.exists():
56
+ ck = torch.load(resume_path, map_location=dev)
57
+ proxy.load_state_dict(ck["model"])
58
+ opt.load_state_dict(ck["opt"])
59
+ start = ck["step"]
60
+ print(f"resuming proxy from step {start}", flush=True)
61
+ for step in range(start, PROXY_ITERS):
62
+ x, _ = next(it)
63
+ x = x.to(dev, non_blocking=True)
64
+ with torch.no_grad():
65
+ z = Fc(x)
66
+ rec = proxy(z)
67
+ loss = F.l1_loss(rec, x)
68
+ opt.zero_grad(set_to_none=True)
69
+ loss.backward()
70
+ opt.step()
71
+ if step % 50 == 0:
72
+ wandb.log({"proxy/l1": loss.item()}, step=step)
73
+ if step % 2000 == 0:
74
+ print(f"proxy step {step} l1 {loss.item():.4f}", flush=True)
75
+ torch.save({"step": step, "model": proxy.state_dict(),
76
+ "opt": opt.state_dict()}, resume_path)
77
+ torch.save(proxy.state_dict(), proxy_path)
78
+
79
+ # ---- gradient covariance over the channel-averaged reconstruction grad ----
80
+ proxy.eval()
81
+ gbar = []
82
+ seen = 0
83
+ for x, _ in DataLoader(ds, batch_size=64, shuffle=True, num_workers=8):
84
+ x = x.to(dev)
85
+ z = Fc(x).detach().clone().requires_grad_(True)
86
+ loss = F.l1_loss(proxy(z), x)
87
+ g = torch.autograd.grad(loss, z)[0] # (B,C,H,W)
88
+ gbar.append(g.mean(dim=(2, 3)).detach().cpu()) # (B,C)
89
+ seen += x.shape[0]
90
+ if seen >= COV_SAMPLES:
91
+ break
92
+ gbar = torch.cat(gbar, 0) # (N,C)
93
+ mu = gbar.mean(0, keepdim=True)
94
+ gc = gbar - mu
95
+ sigma = (gc.T @ gc) / (gbar.shape[0] - 1) # (C,C)
96
+ evals, evecs = torch.linalg.eigh(sigma) # ascending
97
+ order = torch.argsort(evals, descending=True)
98
+ evals, evecs = evals[order], evecs[:, order]
99
+ U_rec = evecs[:, :CFG["r"]] # (C,r)
100
+ P_rec = U_rec @ U_rec.T
101
+ P_N = torch.eye(CFG["C"]) - P_rec
102
+
103
+ torch.save({"P_rec": P_rec, "P_N": P_N, "U_rec": U_rec,
104
+ "evals": evals, "sigma": sigma}, CKPT / "rssd.pt")
105
+ ev = evals / evals.sum()
106
+ print("top-8 eigenvalue energy:", ev[:8].tolist(), flush=True)
107
+ print(f"RSS energy captured by top-{CFG['r']}: "
108
+ f"{ev[:CFG['r']].sum().item():.4f}", flush=True)
109
+ wandb.log({"rssd/rss_energy": ev[:CFG["r"]].sum().item()})
110
+ wandb.finish()
111
+
112
+
113
+ if __name__ == "__main__":
114
+ main()
checkpoints/adaface_ir50_ms1mv2.backbone.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:321a032dd99ce7ffeab078c92436e0371e8d752d66994c48825ff296cac58490
3
+ size 174645926
checkpoints/bank.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ba04ddb79804abb619daaae9eec4ca5c51155347d266422bd367505336851c6f
3
+ size 2007845254
checkpoints/rssd.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9d8629eaaa70ff55684262ce4e1f6c3b2be982d5c6becb36a3021ae260ecd8e2
3
+ size 68024
common.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared config and paths for DecoyFace."""
2
+ from pathlib import Path
3
+
4
+ import torch
5
+
6
+ CKPT = Path(__file__).resolve().parent / "checkpoints"
7
+ ADAFACE_CKPT = CKPT / "adaface_ir50_ms1mv2.backbone.pth"
8
+
9
+ # DecoyFace hyper-parameters (paper Sec. V-B "Implementation Details").
10
+ CFG = {
11
+ "C": 64,
12
+ "r": 32, # RSS rank
13
+ "G": 16, # groups
14
+ "K": 3, # preserved true-sample groups
15
+ "M": 5000, # auxiliary complementary bank size
16
+ "sc_lo": 8, "sc_hi": 12, # coherent subset size s_c ~ U[8,12]
17
+ "L_lo": 2, "L_hi": 4, # shared source count L ~ U[2,4]
18
+ "t": 3, # random scale exponent k ~ U[-3,3]
19
+ "q": 0.95, # quantile
20
+ "tau_s": 0.8, # server target magnitude
21
+ "eps": 1e-6,
22
+ }
23
+
24
+ DEVICE = "cuda:0"
25
+
26
+
27
+ def fixed_perm(C=64, seed=1234):
28
+ g = torch.Generator().manual_seed(seed)
29
+ return torch.randperm(C, generator=g)
data.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MS1M dataset: identity folders of 112x112 ArcFace-aligned faces."""
2
+ import os
3
+ import glob
4
+
5
+ import cv2
6
+ import torch
7
+ from torch.utils.data import Dataset
8
+
9
+
10
+ def load_rgb01(path):
11
+ img = cv2.imread(path) # BGR uint8, 112x112
12
+ img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
13
+ t = torch.from_numpy(img).float().permute(2, 0, 1) / 255.0
14
+ return t
15
+
16
+
17
+ class MS1MDataset(Dataset):
18
+ """Aligned MS1M faces. Uses `num_ids` identity folders (sorted
19
+ numerically) starting at `start` so different scripts see the same
20
+ deterministic split."""
21
+
22
+ def __init__(self, root, num_ids, start=0):
23
+ ids = sorted(os.listdir(root), key=int)[start:start + num_ids]
24
+ self.samples = []
25
+ for label, ident in enumerate(ids):
26
+ for f in sorted(glob.glob(f"{root}/{ident}/*.jpg")):
27
+ self.samples.append((f, label))
28
+ self.num_classes = len(ids)
29
+
30
+ def __len__(self):
31
+ return len(self.samples)
32
+
33
+ def __getitem__(self, i):
34
+ path, label = self.samples[i]
35
+ return load_rgb01(path), label
decoyface.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DecoyFace client-side protection (DGCM) and server-side recovery (ACM).
2
+
3
+ All projections act on the channel dimension of a C x H x W feature at every
4
+ spatial location. C = 64 (F_c output), r = 32, G = 16 groups of size 4.
5
+
6
+ The RSSD statistics (P_rec, P_N, mu_rec), the fixed channel-shuffle permutation
7
+ and the auxiliary complementary bank D_N are built once (see build_rssd.py /
8
+ build_bank.py) and loaded into DecoyProtector.
9
+ """
10
+ import torch
11
+ import torch.nn.functional as F
12
+
13
+
14
+ def proj(P, z):
15
+ """Apply channel-space projection P (C,C) to feature z (B,C,H,W)."""
16
+ b, c, h, w = z.shape
17
+ out = P @ z.reshape(b, c, h * w)
18
+ return out.reshape(b, c, h, w)
19
+
20
+
21
+ def group_shuffle(z, perm, G):
22
+ """CSG: permute channels by `perm`, then split into G contiguous groups.
23
+ z (B,C,H,W) -> (B,G,c_g,H,W)."""
24
+ b, c, h, w = z.shape
25
+ z = z[:, perm]
26
+ return z.reshape(b, G, c // G, h, w)
27
+
28
+
29
+ def group_unshuffle(zg, inv_perm):
30
+ """Inverse CSG: (B,G,c_g,H,W) -> (B,C,H,W) in the original channel order."""
31
+ b, G, cg, h, w = zg.shape
32
+ z = zg.reshape(b, G * cg, h, w)
33
+ return z[:, inv_perm]
34
+
35
+
36
+ def group_instance_norm(zg, eps=1e-5):
37
+ """InstanceNorm applied to every group's channels over the spatial dims."""
38
+ b, G, cg, h, w = zg.shape
39
+ x = zg.reshape(b * G, cg, h, w)
40
+ x = F.instance_norm(x, eps=eps)
41
+ return x.reshape(b, G, cg, h, w)
42
+
43
+
44
+ class DecoyProtector:
45
+ def __init__(self, P_rec, P_N, mu_rec, aux_bank, perm, cfg, device):
46
+ self.device = device
47
+ self.P_rec = P_rec.to(device)
48
+ self.P_N = P_N.to(device)
49
+ self.mu_rec = mu_rec.to(device) # (C,H,W)
50
+ self.perm = perm.to(device) # (C,)
51
+ self.inv_perm = torch.argsort(perm).to(device)
52
+ self.cfg = cfg
53
+ self.G = cfg["G"]
54
+ self.K = cfg["K"]
55
+ self.M = aux_bank.shape[0]
56
+ # Pre-group and pre-normalize the auxiliary complementary bank once.
57
+ # aux_bank holds n_j = P_N F_c(y_j); grouped+IN gives the decoy groups.
58
+ ag = group_shuffle(aux_bank.to(device), self.perm, self.G)
59
+ self.aux_grouped_norm = group_instance_norm(ag) # (M,G,c_g,H,W)
60
+
61
+ def _sample_routing(self, B):
62
+ """Per-sample mask m (B,G) and decoy source indices src (B,G)."""
63
+ G, K, M, cfg = self.G, self.K, self.M, self.cfg
64
+ mask = torch.zeros(B, G, dtype=torch.bool)
65
+ src = torch.zeros(B, G, dtype=torch.long)
66
+ for b in range(B):
67
+ keep = torch.randperm(G)[:K]
68
+ mask[b, keep] = True
69
+ g0 = torch.nonzero(~mask[b], as_tuple=False).flatten()
70
+ sc = int(torch.randint(cfg["sc_lo"], cfg["sc_hi"] + 1, (1,)))
71
+ sc = min(sc, g0.numel())
72
+ L = int(torch.randint(cfg["L_lo"], cfg["L_hi"] + 1, (1,)))
73
+ L = min(L, sc) if sc > 0 else L
74
+ perm_g0 = g0[torch.randperm(g0.numel())]
75
+ coherent = perm_g0[:sc]
76
+ independent = perm_g0[sc:]
77
+ shared_pool = torch.randperm(M)[:L]
78
+ for g in coherent.tolist():
79
+ src[b, g] = shared_pool[torch.randint(L, (1,))]
80
+ for g in independent.tolist():
81
+ src[b, g] = torch.randint(M, (1,))
82
+ return mask.to(self.device), src.to(self.device)
83
+
84
+ def protect(self, z_A, z_B):
85
+ """Client-side DGCM. z_A, z_B: (B,C,H,W). Returns transmitted z_t."""
86
+ B = z_A.shape[0]
87
+ r_B = proj(self.P_rec, z_B)
88
+ n_A = proj(self.P_N, z_A)
89
+
90
+ uA = group_instance_norm(group_shuffle(n_A, self.perm, self.G))
91
+ mask, src = self._sample_routing(B)
92
+ ar = torch.arange(self.G, device=self.device)
93
+ decoy = self.aux_grouped_norm[src, ar] # (B,G,c_g,H,W)
94
+ m = mask[:, :, None, None, None]
95
+ mixed = torch.where(m, uA, decoy)
96
+ n_tilde = group_unshuffle(mixed, self.inv_perm)
97
+
98
+ z_tilde = n_tilde + r_B
99
+ k = torch.randint(-self.cfg["t"], self.cfg["t"] + 1, (B, 1, 1, 1),
100
+ device=self.device)
101
+ z_t = (2.0 ** k.float()) * z_tilde
102
+ return z_t
103
+
104
+ def server_recover(self, z_t):
105
+ """Server-side ACM. z_t: (B,C,H,W). Returns canonicalized z_can."""
106
+ z_s = proj(self.P_N, z_t)
107
+ zg = group_shuffle(z_s, self.perm, self.G) # (B,G,c_g,H,W)
108
+ b, G, cg, h, w = zg.shape
109
+ flat = zg.reshape(b, G, cg * h * w).abs()
110
+ q = torch.quantile(flat, self.cfg["q"], dim=2) # (B,G)
111
+ scale = self.cfg["tau_s"] / (q + self.cfg["eps"])
112
+ zg = zg * scale[:, :, None, None, None]
113
+ n_cal = group_unshuffle(zg, self.inv_perm)
114
+ return self.mu_rec[None] + n_cal
models.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Backbone (IR-50) split into F_c / F_s, and the U-Net feature-inversion network.
2
+
3
+ The IR-50 definition follows face.evoLVe / AdaFace exactly so the pretrained
4
+ AdaFace MS1Mv2 checkpoint loads without surgery. The client encoder F_c is the
5
+ stem plus the first residual stage, whose output is 64x56x56 -- the intermediate
6
+ feature used throughout DecoyFace (paper Sec. V-B). F_s is the remainder.
7
+ """
8
+ from collections import namedtuple
9
+
10
+ import torch
11
+ import torch.nn as nn
12
+
13
+
14
+ # ----------------------------- IR-50 backbone ------------------------------ #
15
+ class Flatten(nn.Module):
16
+ def forward(self, x):
17
+ return x.view(x.size(0), -1)
18
+
19
+
20
+ class BasicBlockIR(nn.Module):
21
+ def __init__(self, in_channel, depth, stride):
22
+ super().__init__()
23
+ if in_channel == depth:
24
+ self.shortcut_layer = nn.MaxPool2d(1, stride)
25
+ else:
26
+ self.shortcut_layer = nn.Sequential(
27
+ nn.Conv2d(in_channel, depth, (1, 1), stride, bias=False),
28
+ nn.BatchNorm2d(depth))
29
+ self.res_layer = nn.Sequential(
30
+ nn.BatchNorm2d(in_channel),
31
+ nn.Conv2d(in_channel, depth, (3, 3), (1, 1), 1, bias=False),
32
+ nn.BatchNorm2d(depth),
33
+ nn.PReLU(depth),
34
+ nn.Conv2d(depth, depth, (3, 3), stride, 1, bias=False),
35
+ nn.BatchNorm2d(depth))
36
+
37
+ def forward(self, x):
38
+ return self.res_layer(x) + self.shortcut_layer(x)
39
+
40
+
41
+ Bottleneck = namedtuple('Block', ['in_channel', 'depth', 'stride'])
42
+
43
+
44
+ def get_block(in_channel, depth, num_units, stride=2):
45
+ return [Bottleneck(in_channel, depth, stride)] + \
46
+ [Bottleneck(depth, depth, 1) for _ in range(num_units - 1)]
47
+
48
+
49
+ def get_blocks_ir50():
50
+ return [
51
+ get_block(64, 64, 3), # stage 1 -> 64 x 56 x 56
52
+ get_block(64, 128, 4), # stage 2 -> 128 x 28 x 28
53
+ get_block(128, 256, 14), # stage 3 -> 256 x 14 x 14
54
+ get_block(256, 512, 3), # stage 4 -> 512 x 7 x 7
55
+ ]
56
+
57
+
58
+ class IR50(nn.Module):
59
+ """Full IR-50 recognizer (input 112x112 -> 512-d embedding)."""
60
+
61
+ def __init__(self):
62
+ super().__init__()
63
+ self.input_layer = nn.Sequential(
64
+ nn.Conv2d(3, 64, (3, 3), 1, 1, bias=False),
65
+ nn.BatchNorm2d(64), nn.PReLU(64))
66
+ modules = []
67
+ for block in get_blocks_ir50():
68
+ for b in block:
69
+ modules.append(BasicBlockIR(b.in_channel, b.depth, b.stride))
70
+ self.body = nn.Sequential(*modules)
71
+ self.output_layer = nn.Sequential(
72
+ nn.BatchNorm2d(512), nn.Dropout(0.4), Flatten(),
73
+ nn.Linear(512 * 7 * 7, 512), nn.BatchNorm1d(512, affine=False))
74
+
75
+ def forward(self, x):
76
+ x = self.input_layer(x)
77
+ x = self.body(x)
78
+ return self.output_layer(x)
79
+
80
+
81
+ # The first residual stage (stage 1) spans body[0:3]; splitting there yields the
82
+ # 64x56x56 intermediate feature the paper transmits.
83
+ SPLIT_INDEX = 3
84
+
85
+
86
+ def adaface_preprocess(x_rgb01):
87
+ """RGB image in [0,1] -> AdaFace input (BGR, normalized to [-1,1])."""
88
+ x_bgr = x_rgb01.flip(1) # RGB -> BGR on the channel dim
89
+ return (x_bgr - 0.5) / 0.5
90
+
91
+
92
+ class ClientEncoder(nn.Module):
93
+ """F_c: AdaFace stem + stage 1. Frozen. Consumes RGB [0,1], emits 64x56x56."""
94
+
95
+ def __init__(self, ir50: IR50):
96
+ super().__init__()
97
+ self.input_layer = ir50.input_layer
98
+ self.stage1 = ir50.body[:SPLIT_INDEX]
99
+
100
+ def forward(self, x_rgb01):
101
+ x = adaface_preprocess(x_rgb01)
102
+ x = self.input_layer(x)
103
+ return self.stage1(x)
104
+
105
+
106
+ class ServerBackbone(nn.Module):
107
+ """F_s: AdaFace stage 2-4 + output layer. 64x56x56 -> 512-d embedding."""
108
+
109
+ def __init__(self, ir50: IR50):
110
+ super().__init__()
111
+ self.rest = ir50.body[SPLIT_INDEX:]
112
+ self.output_layer = ir50.output_layer
113
+
114
+ def forward(self, feat):
115
+ return self.output_layer(self.rest(feat))
116
+
117
+
118
+ def load_ir50(ckpt_path, device):
119
+ net = IR50()
120
+ sd = torch.load(ckpt_path, map_location="cpu")
121
+ if isinstance(sd, dict) and "state_dict" in sd:
122
+ sd = sd["state_dict"]
123
+ missing, unexpected = net.load_state_dict(sd, strict=True)
124
+ net.eval().to(device)
125
+ return net
126
+
127
+
128
+ # ------------------------------- U-Net ------------------------------------- #
129
+ class ConvBlock(nn.Module):
130
+ def __init__(self, cin, cout, norm="batch"):
131
+ super().__init__()
132
+ if norm == "batch":
133
+ norm1 = nn.BatchNorm2d(cout)
134
+ norm2 = nn.BatchNorm2d(cout)
135
+ elif norm == "instance":
136
+ norm1 = nn.InstanceNorm2d(cout)
137
+ norm2 = nn.InstanceNorm2d(cout)
138
+ else:
139
+ raise ValueError(norm)
140
+ self.net = nn.Sequential(
141
+ nn.Conv2d(cin, cout, 3, 1, 1, bias=False),
142
+ norm1, nn.LeakyReLU(0.2, inplace=True),
143
+ nn.Conv2d(cout, cout, 3, 1, 1, bias=False),
144
+ norm2, nn.LeakyReLU(0.2, inplace=True))
145
+
146
+ def forward(self, x):
147
+ return self.net(x)
148
+
149
+
150
+ class UNetInverter(nn.Module):
151
+ """Feature-inversion U-Net: 64x56x56 feature -> 3x112x112 RGB image in [0,1].
152
+
153
+ Encoder-decoder with skip connections over the feature map, plus a final
154
+ upsampling stage to reach the 112x112 image resolution.
155
+ """
156
+
157
+ def __init__(self, in_ch=64, norm="batch"):
158
+ super().__init__()
159
+ self.enc0 = ConvBlock(in_ch, 64, norm) # 56
160
+ self.down1 = nn.MaxPool2d(2) # 28
161
+ self.enc1 = ConvBlock(64, 128, norm)
162
+ self.down2 = nn.MaxPool2d(2) # 14
163
+ self.enc2 = ConvBlock(128, 256, norm)
164
+ self.bott = ConvBlock(256, 256, norm)
165
+
166
+ self.up2 = nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False)
167
+ self.dec2 = ConvBlock(256 + 128, 128, norm) # 28
168
+ self.up1 = nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False)
169
+ self.dec1 = ConvBlock(128 + 64, 64, norm) # 56
170
+ self.up0 = nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False)
171
+ self.dec0 = ConvBlock(64, 32, norm) # 112
172
+ self.head = nn.Conv2d(32, 3, 3, 1, 1)
173
+
174
+ def forward(self, feat):
175
+ e0 = self.enc0(feat)
176
+ e1 = self.enc1(self.down1(e0))
177
+ e2 = self.enc2(self.down2(e1))
178
+ b = self.bott(e2)
179
+ d2 = self.dec2(torch.cat([self.up2(b), e1], 1))
180
+ d1 = self.dec1(torch.cat([self.up1(d2), e0], 1))
181
+ d0 = self.dec0(self.up0(d1))
182
+ return torch.sigmoid(self.head(d0))
train_defender.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Defender training (paper Sec. IV-E): F_c frozen; train F_s + ArcFace head on
2
+ the server-side canonicalized feature z_can with V=2 protected views and the
3
+ multi-view consistency loss. L = L_id + lambda_cons * L_cons.
4
+
5
+ Outputs checkpoints/defender.pt with the trained F_s and ArcFace head.
6
+ """
7
+ import copy
8
+ import argparse
9
+
10
+ import torch
11
+ import torch.nn as nn
12
+ import torch.nn.functional as F
13
+ from torch.utils.data import DataLoader
14
+ import wandb
15
+
16
+ from models import load_ir50, ClientEncoder, ServerBackbone
17
+ from data import MS1MDataset
18
+ from decoyface import DecoyProtector
19
+ from common import ADAFACE_CKPT, CKPT, CFG, DEVICE
20
+
21
+ V = 2
22
+ LAMBDA_CONS = 0.1
23
+ BATCH = 256
24
+ EPOCHS = 15
25
+ LR = 0.1
26
+ DECAY_EPOCHS = [4, 8, 12]
27
+
28
+
29
+ class ArcFace(nn.Module):
30
+ def __init__(self, in_dim, n_cls, s=64.0, m=0.5):
31
+ super().__init__()
32
+ self.W = nn.Parameter(torch.empty(n_cls, in_dim))
33
+ nn.init.xavier_normal_(self.W)
34
+ self.s, self.m = s, m
35
+
36
+ def forward(self, emb, y):
37
+ emb = F.normalize(emb)
38
+ w = F.normalize(self.W)
39
+ cos = emb @ w.T
40
+ theta = torch.acos(cos.clamp(-1 + 1e-6, 1 - 1e-6))
41
+ target = torch.cos(theta + self.m)
42
+ onehot = F.one_hot(y, cos.shape[1]).float()
43
+ logits = self.s * (onehot * target + (1 - onehot) * cos)
44
+ return F.cross_entropy(logits, y)
45
+
46
+
47
+ def main():
48
+ ap = argparse.ArgumentParser()
49
+ ap.add_argument("--ms1m_root", required=True)
50
+ ap.add_argument("--num_ids", type=int, default=30000)
51
+ ap.add_argument("--epochs", type=int, default=EPOCHS)
52
+ args = ap.parse_args()
53
+ dev = DEVICE
54
+
55
+ ir50 = load_ir50(ADAFACE_CKPT, dev)
56
+ Fc = ClientEncoder(ir50).to(dev).eval()
57
+ for p in Fc.parameters():
58
+ p.requires_grad_(False)
59
+ Fs = ServerBackbone(copy.deepcopy(ir50)).to(dev).train()
60
+ rssd = torch.load(CKPT / "rssd.pt", map_location=dev)
61
+ bank = torch.load(CKPT / "bank.pt", map_location=dev)
62
+ protector = DecoyProtector(rssd["P_rec"], rssd["P_N"], bank["mu_rec"],
63
+ bank["aux_bank"].float(), bank["perm"], CFG, dev)
64
+
65
+ ds = MS1MDataset(args.ms1m_root, args.num_ids)
66
+ head = ArcFace(512, ds.num_classes).to(dev)
67
+ loader = DataLoader(ds, batch_size=BATCH, shuffle=True, num_workers=12,
68
+ drop_last=True, pin_memory=True, persistent_workers=True)
69
+
70
+ params = list(Fs.parameters()) + list(head.parameters())
71
+ opt = torch.optim.SGD(params, lr=LR, momentum=0.9, weight_decay=5e-4)
72
+ wandb.init(project="decoyface-repro", name="defender", mode="online",
73
+ config={**CFG, "num_ids": args.num_ids, "epochs": args.epochs})
74
+
75
+ step = 0
76
+ for epoch in range(args.epochs):
77
+ lr = LR * (0.1 ** sum(epoch >= d for d in DECAY_EPOCHS))
78
+ for g in opt.param_groups:
79
+ g["lr"] = lr
80
+ for x, y in loader:
81
+ x, y = x.to(dev, non_blocking=True), y.to(dev, non_blocking=True)
82
+ with torch.no_grad():
83
+ z = Fc(x)
84
+ embs = []
85
+ id_loss = 0.0
86
+ for _ in range(V):
87
+ z_B = z.roll(torch.randint(1, x.shape[0], (1,)).item(), 0)
88
+ z_can = protector.server_recover(protector.protect(z, z_B))
89
+ e = Fs(z_can)
90
+ id_loss = id_loss + head(e, y)
91
+ embs.append(F.normalize(e))
92
+ id_loss = id_loss / V
93
+ cons = ((embs[0] - embs[1]) ** 2).sum(1).mean()
94
+ loss = id_loss + LAMBDA_CONS * cons
95
+ opt.zero_grad(set_to_none=True)
96
+ loss.backward()
97
+ opt.step()
98
+ if step % 100 == 0:
99
+ wandb.log({"def/loss": loss.item(), "def/id": id_loss.item(),
100
+ "def/cons": cons.item(), "def/lr": lr}, step=step)
101
+ if step % 1000 == 0:
102
+ print(f"ep{epoch} step{step} loss{loss.item():.3f} "
103
+ f"id{id_loss.item():.3f} cons{cons.item():.4f}", flush=True)
104
+ step += 1
105
+ torch.save({"Fs": Fs.state_dict(), "head": head.state_dict()},
106
+ CKPT / "defender.pt")
107
+ wandb.finish()
108
+
109
+
110
+ if __name__ == "__main__":
111
+ main()