Download eval_vsl.py from Tri1/t2m-gpt-vsl-code: direct link, hf CLI and curl.
- Browser
- Download file 9.3 kB
-
https://huggingface.co/Tri1/t2m-gpt-vsl-code/resolve/main/eval_vsl.py
- Command line
-
hf download hf://Tri1/t2m-gpt-vsl-code/eval_vsl.py
-
curl -L -o eval_vsl.py https://huggingface.co/Tri1/t2m-gpt-vsl-code/resolve/main/eval_vsl.py
9.3 kB
| #!/usr/bin/env python3 | |
| """Evaluate the trained VSL T2M-GPT on a held-out split. | |
| Reports, for the requested split: | |
| * tokenizer ceiling -- MPJPE of VQ-VAE reconstruction (encode->decode of GT). | |
| No generation can beat this, so every generation number should be read | |
| against it. | |
| * teacher-forced -- CE loss and next-token accuracy. | |
| * generation -- prefix MPJPE, DTW-MJE (alignment-free, so a | |
| length/timing mismatch is not double-counted), and length ratio. | |
| DTW-MJE is included because this project's earlier BARTpho gloss->pose baseline | |
| was reported in that metric (hand 0.237 / body 0.228 vs a 0.012 ceiling), and the | |
| two numbers are only comparable if computed the same way. | |
| """ | |
| import argparse | |
| import json | |
| import os | |
| import numpy as np | |
| import torch | |
| from tqdm import tqdm | |
| import models.t2m_trans as trans | |
| from dataset import dataset_vsl | |
| from models.text_encoder_vi import ViTextEncoder | |
| from train_t2m_trans_vsl import build_vqvae | |
| GROUPS = {"all": (0, 128), "body": (0, 18), "face": (18, 86), "hands": (86, 128)} # replaced at runtime from the data layout | |
| NK = 128 # replaced at runtime from the data layout | |
| def per_frame_dist(a, b): | |
| """a [T1,nk,2], b [T2,nk,2] -> pairwise per-keypoint distances [T1,T2,nk].""" | |
| return np.linalg.norm(a[:, None] - b[None, :], axis=-1) | |
| def dtw_mje(pred, gt, valid_gt): | |
| """Alignment-free mean joint error per keypoint group. | |
| pred [T1,128,2], gt [T2,128,2], valid_gt [T2,128]. | |
| DTW path is found on the all-keypoint cost, then each group is averaged along | |
| that single shared path (so groups stay comparable to each other). | |
| """ | |
| T1, T2 = len(pred), len(gt) | |
| d = per_frame_dist(pred, gt) # [T1,T2,128] | |
| w = valid_gt[None, :, :] # [1,T2,128] | |
| cost = (d * w).sum(-1) / np.maximum(w.sum(-1), 1e-6) # [T1,T2] | |
| # standard DTW with the usual 3 moves | |
| D = np.full((T1 + 1, T2 + 1), np.inf) | |
| D[0, 0] = 0.0 | |
| for i in range(1, T1 + 1): | |
| ci = cost[i - 1] | |
| for j in range(1, T2 + 1): | |
| D[i, j] = ci[j - 1] + min(D[i - 1, j], D[i, j - 1], D[i - 1, j - 1]) | |
| # backtrack | |
| path, i, j = [], T1, T2 | |
| while i > 0 and j > 0: | |
| path.append((i - 1, j - 1)) | |
| step = int(np.argmin([D[i - 1, j - 1], D[i - 1, j], D[i, j - 1]])) | |
| if step == 0: | |
| i, j = i - 1, j - 1 | |
| elif step == 1: | |
| i -= 1 | |
| else: | |
| j -= 1 | |
| pi = np.array([p[0] for p in path]) | |
| pj = np.array([p[1] for p in path]) | |
| out = {} | |
| for name, (a, b) in GROUPS.items(): | |
| dd = d[pi, pj, a:b] | |
| vv = valid_gt[pj, a:b] | |
| out[name] = float((dd * vv).sum() / max(vv.sum(), 1e-6)) | |
| return out | |
| def prefix_mje(pred, gt, valid_gt): | |
| T = min(len(pred), len(gt)) | |
| d = np.linalg.norm(pred[:T] - gt[:T], axis=-1) # [T,128] | |
| v = valid_gt[:T] | |
| out = {} | |
| for name, (a, b) in GROUPS.items(): | |
| out[name] = float((d[:, a:b] * v[:, a:b]).sum() / max(v[:, a:b].sum(), 1e-6)) | |
| return out | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument('--data-dir', default='./dataset/VSL') | |
| ap.add_argument('--token-dir', default='./dataset/VSL/tokens') | |
| ap.add_argument('--resume-pth', required=True, help='stage-1 VQ-VAE checkpoint') | |
| ap.add_argument('--resume-trans', required=True, help='stage-2 GPT checkpoint') | |
| ap.add_argument('--split', default='test') | |
| ap.add_argument('--n', type=int, default=300, help='clips to score (0 = all)') | |
| ap.add_argument('--device', default='cuda') | |
| ap.add_argument('--sampling', default='categorial', choices=['greedy', 'categorial'], | |
| help="categorial matches upstream evaluation_transformer_test") | |
| ap.add_argument('--shuffle-text', action='store_true', | |
| help='CONTROL: condition each clip on another clip\'s gloss. If this ' | |
| 'scores the same as the real pairing, the model is ignoring the text.') | |
| ap.add_argument('--text-override', default=None, | |
| help='JSON {clip_name: {"pred_gloss": ...}} -- condition on PREDICTED ' | |
| 'gloss instead of ground truth, for end-to-end text->pose eval') | |
| ap.add_argument('--out-json', default=None) | |
| args = ap.parse_args() | |
| override = {} | |
| if args.text_override: | |
| with open(args.text_override, encoding='utf-8') as f: | |
| raw = json.load(f) | |
| override = {k: (v['pred_gloss'] if isinstance(v, dict) else v) | |
| for k, v in raw.items()} | |
| print(f'text-override: {len(override)} clips from {args.text_override}') | |
| device = torch.device(args.device) | |
| net, targs, ck1 = build_vqvae(args.resume_pth, device) | |
| tck = torch.load(args.resume_trans, map_location='cpu') | |
| gargs = argparse.Namespace(**tck['args']) | |
| text_enc = ViTextEncoder(gargs.text_model, device=args.device) | |
| trans_encoder = trans.Text2Motion_Transformer( | |
| num_vq=gargs.nb_code, embed_dim=gargs.embed_dim_gpt, clip_dim=text_enc.dim, | |
| block_size=gargs.max_tokens + 1, num_layers=gargs.num_layers, | |
| n_head=gargs.n_head_gpt, drop_out_rate=gargs.drop_out_rate, fc_rate=gargs.ff_rate) | |
| trans_encoder.load_state_dict(tck['trans'], strict=True) | |
| trans_encoder.eval().to(device) | |
| print(f"stage-2 ckpt iter {tck.get('iter')} val_loss {tck.get('val_loss'):.4f} " | |
| f"val_acc {tck.get('val_acc'):.2f}") | |
| store = dataset_vsl.VSLStore(args.data_dir, args.split) | |
| global GROUPS, NK | |
| GROUPS = store.layout.metric_groups() | |
| NK = store.layout.n_kpts | |
| print(f'layout: {store.layout}') | |
| rng = np.random.RandomState(0) | |
| n = len(store.index) if not args.n else min(args.n, len(store.index)) | |
| items = sorted(rng.choice(len(store.index), size=n, replace=False).tolist()) | |
| mean, std = store.mean, store.std | |
| mean_t = torch.from_numpy(mean).to(device) | |
| std_t = torch.from_numpy(std).to(device) | |
| agg = {k: {g: [] for g in GROUPS} for k in ('ceiling', 'gen_prefix', 'gen_dtw')} | |
| ratios, empties, n_nooverride = [], 0, 0 | |
| with torch.no_grad(): | |
| for i in tqdm(items, desc=f'eval {args.split}'): | |
| c = store.index[i] | |
| motion, mask = store.get(i) | |
| gt_t = torch.from_numpy(motion).unsqueeze(0).to(device) | |
| valid = mask[:, ::2] # [T,128] | |
| gt_xy = (motion * std + mean).reshape(-1, NK, 2) | |
| # --- tokenizer ceiling: encode then decode the ground truth --- | |
| unit = 2 ** targs.down_t | |
| T = (len(motion) // unit) * unit | |
| codes = net.encode(gt_t[:, :T]) | |
| rec = net.decode_batch(codes)[0].cpu().numpy() * std + mean | |
| rec = rec.reshape(-1, NK, 2) | |
| for g, v in prefix_mje(rec, gt_xy, valid).items(): | |
| agg['ceiling'][g].append(v) | |
| # --- generation from gloss --- | |
| cond_text = c[gargs.text_field] | |
| if override: | |
| if c['name'] not in override: | |
| n_nooverride += 1 | |
| continue | |
| cond_text = override[c['name']] | |
| if args.shuffle_text: | |
| # deterministic mismatch: pair clip k with the gloss of another clip | |
| other = store.index[(i + len(store.index) // 2) % len(store.index)] | |
| cond_text = other[gargs.text_field] | |
| feat = text_enc([cond_text]) | |
| idx = trans_encoder.sample(feat, if_categorial=(args.sampling == 'categorial')) | |
| if idx is None or idx.numel() == 0: | |
| empties += 1 | |
| continue | |
| idx = idx.clamp(max=gargs.nb_code - 1) | |
| pred = net.decode_batch(idx)[0].cpu().numpy() * std + mean | |
| pred = pred.reshape(-1, NK, 2) | |
| ratios.append(len(pred) / len(gt_xy)) | |
| for g, v in prefix_mje(pred, gt_xy, valid).items(): | |
| agg['gen_prefix'][g].append(v) | |
| for g, v in dtw_mje(pred, gt_xy, valid).items(): | |
| agg['gen_dtw'][g].append(v) | |
| res = {'split': args.split, 'n_clips': n, 'n_empty': empties, | |
| 'n_missing_override': n_nooverride, | |
| 'text_source': 'predicted' if override else 'ground_truth', | |
| 'sampling': args.sampling, | |
| 'len_ratio_mean': float(np.mean(ratios)) if ratios else 0.0, | |
| 'stage1': {'iter': ck1.get('iter'), 'codes_used': ck1.get('codes_used')}, | |
| 'stage2': {'iter': tck.get('iter'), 'val_acc': tck.get('val_acc')}} | |
| for k, d in agg.items(): | |
| res[k] = {g: (float(np.mean(v)) if v else None) for g, v in d.items()} | |
| print(json.dumps(res, indent=2)) | |
| print("\n--- summary (frame-normalized units, lower is better) ---") | |
| print(f"{'metric':<22}{'all':>9}{'body':>9}{'face':>9}{'hands':>9}") | |
| for k in ('ceiling', 'gen_prefix', 'gen_dtw'): | |
| row = ''.join(f"{res[k][g]:>9.4f}" if res[k][g] is not None else f"{'-':>9}" | |
| for g in ('all', 'body', 'face', 'hands')) | |
| print(f"{k:<22}{row}") | |
| print(f"len_ratio (pred/gt): {res['len_ratio_mean']:.3f} empty gens: {empties}") | |
| if args.out_json: | |
| os.makedirs(os.path.dirname(args.out_json) or '.', exist_ok=True) | |
| with open(args.out_json, 'w') as f: | |
| json.dump(res, f, indent=2) | |
| print(f"\nwrote {args.out_json}") | |
| if __name__ == '__main__': | |
| main() | |