File size: 5,808 Bytes
1ea7ba6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""

eval_aifnet.py — standalone evaluation for AIFNet / SpiceFusionNet checkpoints.



Reports overall + per-source Top-1 and macro-F1 on a manifest split. With

--synthetic-shift, applies a controlled "consumer-camera" acquisition transform

(JPEG compression + warm color cast + gamma + downscale) to every test image —

a held-out synthetic domain for OOD generalization evidence when a real 3rd

source is unavailable. Compare a robust model (AIFNet) vs an ERM ablation:

the smaller the clean->shifted drop, the more acquisition-invariant the model.



    python eval_aifnet.py --arch aifnet --ckpt outputs/checkpoints/aifnet_overlap_aif/best.pth

    python eval_aifnet.py --arch aifnet --ckpt .../best.pth --synthetic-shift

"""
import argparse
import json
from collections import defaultdict
from pathlib import Path

import numpy as np
from PIL import Image
import torch
import albumentations as A
from albumentations.pytorch import ToTensorV2
from sklearn.metrics import balanced_accuracy_score, f1_score

import config
from src.features import extract_all
from src.dataset import load_manifest_splits, source_of
from eval_source_probe import load_model            # arch-aware loader


def synthetic_shift():
    """Controlled 'consumer-camera' acquisition shift (albumentations 2.x API)."""
    return A.Compose([
        A.ImageCompression(quality_range=(25, 45), p=1.0),
        A.ColorJitter(brightness=0.25, contrast=0.25, saturation=0.35, hue=0.06, p=1.0),
        A.RandomGamma(gamma_limit=(70, 130), p=1.0),
        A.Downscale(scale_range=(0.5, 0.75), p=0.7),
    ])


def normalize():
    s = int(config.IMG_SIZE * 256 / 224)
    return A.Compose([A.Resize(s, s), A.CenterCrop(config.IMG_SIZE, config.IMG_SIZE),
                      A.Normalize(mean=config.IMG_MEAN, std=config.IMG_STD), ToTensorV2()])


@torch.no_grad()
def evaluate(model, samples, device, batch, shift_tf, norm_tf, arch):
    model.eval()
    y_true, y_pred, srcs = [], [], []
    img_b, tex_b, col_b, meta_b = [], [], [], []

    def flush():
        if not img_b:
            return
        x = torch.stack(img_b).to(device)
        tex = torch.from_numpy(np.stack(tex_b)).float().to(device)
        col = torch.from_numpy(np.stack(col_b)).float().to(device)
        logits = model(x, tex, col) if arch == "aifnet" else model.forward_fusion(x, tex, col)[0]
        pred = logits.argmax(1).cpu().numpy()
        for p, (lab, s) in zip(pred, meta_b):
            y_pred.append(int(p)); y_true.append(lab); srcs.append(s)
        img_b.clear(); tex_b.clear(); col_b.clear(); meta_b.clear()

    for path, label in samples:
        try:
            img = np.array(Image.open(path).convert("RGB"))
        except Exception:
            continue
        if shift_tf is not None:
            img = shift_tf(image=img)["image"]        # apply acquisition shift to raw image
        tex_np, col_np = extract_all(img)             # features from the (shifted) image
        img_b.append(norm_tf(image=img)["image"])
        tex_b.append(tex_np); col_b.append(col_np)
        meta_b.append((int(label), source_of(path)))
        if len(img_b) >= batch:
            flush()
    flush()

    y_true, y_pred, srcs = np.array(y_true), np.array(y_pred), np.array(srcs)
    overall = float((y_true == y_pred).mean())
    f1m = float(f1_score(y_true, y_pred, average="macro"))
    per_src = {s: float((y_true[srcs == s] == y_pred[srcs == s]).mean())
               for s in sorted(set(srcs))}
    gap = (max(per_src.values()) - min(per_src.values())) if len(per_src) > 1 else 0.0
    return {"top1": overall, "macro_f1": f1m, "per_source_top1": per_src, "source_gap": gap,
            "n": int(len(y_true))}


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--ckpt", required=True)
    ap.add_argument("--arch", default="aifnet", choices=["aifnet", "spicefusion"])
    ap.add_argument("--manifest", default="outputs/manifest_overlap_only.json")
    ap.add_argument("--split", default="test", choices=["train", "val", "test"])
    ap.add_argument("--synthetic-shift", action="store_true")
    ap.add_argument("--batch", type=int, default=32)
    ap.add_argument("--out", default=None)
    ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
    args = ap.parse_args()

    device = torch.device(args.device)
    splits, classes = load_manifest_splits(args.manifest)
    samples = list(zip(*splits[args.split]))
    samples = [(p, y) for p, y in samples]
    model = load_model(args.ckpt, device, args.arch)
    norm_tf = normalize()

    print(f"eval {Path(args.ckpt).name} [{args.arch}] on {args.manifest} {args.split} "
          f"({len(samples)} samples)")
    clean = evaluate(model, samples, device, args.batch, None, norm_tf, args.arch)
    print(f"  CLEAN    top1={clean['top1']:.4f} macroF1={clean['macro_f1']:.4f} "
          f"gap={clean['source_gap']:.4f} per_src={ {k:round(v,3) for k,v in clean['per_source_top1'].items()} }")
    out = {"checkpoint": args.ckpt, "arch": args.arch, "clean": clean}

    if args.synthetic_shift:
        shifted = evaluate(model, samples, device, args.batch, synthetic_shift(), norm_tf, args.arch)
        drop = clean["top1"] - shifted["top1"]
        print(f"  SHIFTED  top1={shifted['top1']:.4f} macroF1={shifted['macro_f1']:.4f} "
              f"gap={shifted['source_gap']:.4f}  | clean->shift drop = {drop:.4f}")
        out["synthetic_shift"] = shifted
        out["shift_drop_top1"] = drop

    if args.out:
        Path(args.out).parent.mkdir(parents=True, exist_ok=True)
        json.dump(out, open(args.out, "w"), indent=2)
        print(f"  -> {args.out}")


if __name__ == "__main__":
    main()