File size: 10,088 Bytes
8e5456b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
#!/usr/bin/env python3
"""FGD and MAEJ (Hwang et al., NSLP-G / BMVC) for every dumped Multi-VSL condition.

Definitions, from the paper (`0.NSLP-G/1102.pdf`, eq. 9 and the surrounding text):

  FGD  -- Frechet distance between Gaussians fitted to LATENT FEATURES of real vs
          generated pose sequences:
              ||mu_r - mu_g||^2 + Tr(Sig_r + Sig_g - 2 (Sig_r Sig_g)^(1/2))
          "FGD measures the diversity of produced sign poses". It needs a feature
          extractor; the paper uses a Transformer autoencoder trained on sign poses,
          which is what --train-ae builds here (on the GT TRAIN split only, so it
          never sees generated poses).

  MAEJ -- "independently measures the distance between the produced sign pose and
          real sign pose (NO ASPECT OF TEMPORAL DISTANCE)". So it is a per-joint mean
          absolute error on coordinates with no DTW: the prediction is resampled to
          the reference length and compared frame-to-frame. The paper reports
          MAEJ* = MAEJ x 100.

Both are computed on the shared 50-joint layout (8 body + 42 hands), and every
condition is matched to the ground truth BY CLIP NAME, because the two projects
select their 300-clip subsets with different RNGs.

FGD is distributional, so it is computed over whatever clips a condition has; MAEJ is
per-clip and averaged. Neither uses DTW -- that is the point: they probe different
failure modes than the DTW-MJE table.
"""
import argparse
import glob
import json
import os

import numpy as np
import torch
import torch.nn as nn
from scipy import linalg


# ---------------------------------------------------------------- FGD core
def frechet(mu1, s1, mu2, s2, eps=1e-6):
    """Standard Frechet distance (same formula as 0.NSLP-G/modules/fid.py)."""
    diff = mu1 - mu2
    covmean, _ = linalg.sqrtm(s1.dot(s2), disp=False)
    if not np.isfinite(covmean).all():
        off = np.eye(s1.shape[0]) * eps
        covmean = linalg.sqrtm((s1 + off).dot(s2 + off))
    if np.iscomplexobj(covmean):
        covmean = covmean.real
    return float(diff.dot(diff) + np.trace(s1) + np.trace(s2) - 2 * np.trace(covmean))


def mean_cov(z):
    z = np.asarray(z, np.float64)
    return z.mean(0), np.cov(z, rowvar=False)


# ------------------------------------------------------- Transformer AE (features)
class PosEnc(nn.Module):
    def __init__(self, d, n=1024):
        super().__init__()
        pe = torch.zeros(n, d)
        pos = torch.arange(n).unsqueeze(1).float()
        div = torch.exp(torch.arange(0, d, 2).float() * (-np.log(10000.0) / d))
        pe[:, 0::2] = torch.sin(pos * div)
        pe[:, 1::2] = torch.cos(pos * div)
        self.register_buffer('pe', pe.unsqueeze(0))

    def forward(self, x):
        return x + self.pe[:, :x.shape[1]]


class TFAE(nn.Module):
    """Transformer autoencoder; the clip feature is the time-mean of encoder states."""

    def __init__(self, dim_in, d=256, heads=4, layers=3, ff=1024):
        super().__init__()
        self.inp = nn.Linear(dim_in, d)
        self.pe = PosEnc(d)
        el = nn.TransformerEncoderLayer(d, heads, ff, batch_first=True, dropout=0.1)
        self.enc = nn.TransformerEncoder(el, layers)
        dl = nn.TransformerEncoderLayer(d, heads, ff, batch_first=True, dropout=0.1)
        self.dec = nn.TransformerEncoder(dl, layers)
        self.out = nn.Linear(d, dim_in)

    def encode(self, x, mask=None):
        h = self.enc(self.pe(self.inp(x)), src_key_padding_mask=mask)
        if mask is None:
            return h.mean(1)
        w = (~mask).float().unsqueeze(-1)
        return (h * w).sum(1) / w.sum(1).clamp(min=1)

    def forward(self, x, mask=None):
        h = self.enc(self.pe(self.inp(x)), src_key_padding_mask=mask)
        return self.out(self.dec(h, src_key_padding_mask=mask))


def pad_batch(seqs, device, maxlen=256):
    L = min(max(len(s) for s in seqs), maxlen)
    D = seqs[0].shape[-1]
    x = np.zeros((len(seqs), L, D), np.float32)
    m = np.ones((len(seqs), L), bool)
    for i, s in enumerate(seqs):
        n = min(len(s), L)
        x[i, :n] = s[:n]
        m[i, :n] = False
    return torch.from_numpy(x).to(device), torch.from_numpy(m).to(device)


def flat(p):
    return np.asarray(p, np.float32).reshape(len(p), -1)


def train_ae(train_seqs, device, dim_in, epochs=30, bs=64, lr=3e-4):
    ae = TFAE(dim_in).to(device)
    opt = torch.optim.AdamW(ae.parameters(), lr=lr, weight_decay=1e-4)
    idx = np.arange(len(train_seqs))
    for ep in range(1, epochs + 1):
        np.random.shuffle(idx)
        tot = n = 0
        ae.train()
        for i in range(0, len(idx) - bs + 1, bs):
            seqs = [train_seqs[j] for j in idx[i:i + bs]]
            x, m = pad_batch(seqs, device)
            rec = ae(x, m)
            w = (~m).float().unsqueeze(-1)
            loss = (((rec - x) ** 2) * w).sum() / w.sum().clamp(min=1) / x.shape[-1]
            opt.zero_grad(); loss.backward(); opt.step()
            tot += loss.item(); n += 1
        if ep % 5 == 0 or ep == 1:
            print(f'  AE epoch {ep:3d}  recon {tot/max(n,1):.5f}')
    ae.eval()
    return ae


@torch.no_grad()
def features(ae, seqs, device, bs=64):
    out = []
    for i in range(0, len(seqs), bs):
        x, m = pad_batch(seqs[i:i + bs], device)
        out.append(ae.encode(x, m).cpu().numpy())
    return np.concatenate(out, 0)


# ------------------------------------------------------------------- MAEJ
def resample(p, T):
    if len(p) == T:
        return p.astype(np.float32)
    src, dst = np.linspace(0, 1, len(p)), np.linspace(0, 1, T)
    f = p.reshape(len(p), -1)
    o = np.stack([np.interp(dst, src, f[:, k]) for k in range(f.shape[1])], 1)
    return o.reshape(T, *p.shape[1:]).astype(np.float32)


def maej(pred, gt):
    """Mean absolute joint error, no temporal alignment (pred resampled to len(gt))."""
    return float(np.abs(resample(pred, len(gt)) - gt).mean())


# ------------------------------------------------------------------- driver
def load_dump(p):
    d = np.load(p, allow_pickle=True)
    return {n: q for n, q in zip(d['names'], d['poses'])}


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--dump-dirs', nargs='+',
                    default=['dumps_fgd_t2m',
                             '../0.NSLP-G/Word-level/NSLP-G/dumps_fgd'])
    ap.add_argument('--gt-npz', default='dumps_fgd_t2m/gt.npz')
    ap.add_argument('--data-dir', default='./dataset/MVSL')
    ap.add_argument('--epochs', type=int, default=30)
    ap.add_argument('--device', default='cuda')
    ap.add_argument('--out-json', default='output_vsl/fgd_maej.json')
    args = ap.parse_args()

    device = torch.device(args.device)
    gt = load_dump(args.gt_npz)
    print(f'ground truth: {len(gt)} clips')

    # --- feature extractor: trained on GT TRAIN poses only ---
    from dataset import dataset_vsl
    from dump_mvsl_poses import KEEP_50
    tr = dataset_vsl.VSLStore(args.data_dir, 'train')
    NK = tr.layout.n_kpts
    train_seqs = []
    for i in range(len(tr.index)):
        m, _ = tr.get(i)
        train_seqs.append(flat((m * tr.std + tr.mean).reshape(-1, NK, 2)[:, KEEP_50]))
    print(f'AE training on {len(train_seqs)} GT train clips, dim {train_seqs[0].shape[-1]}')
    ae = train_ae(train_seqs, device, train_seqs[0].shape[-1], epochs=args.epochs)

    # --- collect conditions ---
    conds = {}
    for d in args.dump_dirs:
        for p in sorted(glob.glob(os.path.join(d, '*.npz'))):
            tag = os.path.splitext(os.path.basename(p))[0]
            if tag == 'gt':
                continue
            src = 'T2M-GPT' if 'dumps_fgd_t2m' in d else 'NSLP-G'
            conds[f'{src}:{tag}'] = load_dump(p)

    # FGD is biased by sample size, so every condition MUST be scored on the same
    # clips: NSLP-G dumped 300 while this project dumps the whole split. Restrict to
    # the intersection over all conditions (and the GT), or the numbers are not
    # comparable to each other at all.
    common_all = set(gt)
    for m in conds.values():
        common_all &= set(m)
    gnames = sorted(common_all)
    print(f'\nscoring every condition on the SAME {len(gnames)} clips '
          f'(intersection over {len(conds)} conditions + GT)')
    for tag, m in sorted(conds.items()):
        print(f'    {tag:<34} dumped {len(m)}')
    if len(gnames) < 20:
        raise SystemExit('too few clips shared across conditions')

    # real-vs-real reference: split GT in half to expose the FGD noise floor
    half = len(gnames) // 2
    fa = features(ae, [flat(gt[n]) for n in gnames[:half]], device)
    fb = features(ae, [flat(gt[n]) for n in gnames[half:]], device)
    ref_fgd = frechet(*mean_cov(fa), *mean_cov(fb))

    gt_all = features(ae, [flat(gt[n]) for n in gnames], device)
    mu_r, sig_r = mean_cov(gt_all)

    rows = []
    for tag, m in sorted(conds.items()):
        common = gnames                      # identical clip set for every condition
        fz = features(ae, [flat(m[n]) for n in common], device)
        fgd = frechet(mu_r, sig_r, *mean_cov(fz))
        mj = float(np.mean([maej(np.asarray(m[n], np.float32),
                                 np.asarray(gt[n], np.float32)) for n in common]))
        lr = float(np.mean([len(m[n]) / len(gt[n]) for n in common]))
        rows.append({'condition': tag, 'n': len(common), 'FGD': fgd,
                     'MAEJ': mj, 'MAEJ_x100': mj * 100, 'len_ratio': lr})

    rows.sort(key=lambda r: r['MAEJ'])
    print(f"\n{'condition':<34}{'n':>5}{'FGD':>10}{'MAEJ*':>9}{'len_r':>8}")
    print(f"{'real vs real (FGD noise floor)':<34}{half:>5}{ref_fgd:>10.3f}{0.0:>9.3f}{1.0:>8.3f}")
    for r in rows:
        print(f"{r['condition']:<34}{r['n']:>5}{r['FGD']:>10.3f}"
              f"{r['MAEJ_x100']:>9.3f}{r['len_ratio']:>8.3f}")

    os.makedirs(os.path.dirname(args.out_json) or '.', exist_ok=True)
    with open(args.out_json, 'w') as f:
        json.dump({'fgd_noise_floor': ref_fgd, 'n_gt': len(gnames), 'rows': rows},
                  f, indent=2)
    print(f'\nwrote {args.out_json}')


if __name__ == '__main__':
    main()