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()
|