File size: 11,628 Bytes
41dd7e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""The Clean-FID pipeline, pinned and checked.

Everything after the dataset adapter is fixed here for every dataset:
  * images arrive as uint8 (N, H, W, 3) in [0, 255] (one fixed image range);
  * a single resize to 299x299 with clean-fid's "clean" resizer (PIL bicubic, float output, antialiasing is
    PIL's default for its resampling filters); the TorchScript Inception is built with resize_inside=False
    and asserts 299x299 input, so a second resize cannot happen silently;
  * the pinned Inception-v3 weights (inception-2015-12-05.pt, sha256 checked), pool3 features (2048-D);
  * FID = clean-fid's frechet_distance formula; KID = clean-fid's kernel_distance (unbiased polynomial-kernel
    MMD^2, num_subsets / max_subset_size from the evaluation settings).

``pins()`` returns the record that every FID manifest carries; ``check_pins(expected)`` raises on any drift.
"""
from __future__ import annotations

import hashlib
import importlib.metadata as _md
import os
from pathlib import Path

import numpy as np

INCEPTION_DIR = Path(os.environ.get("CLEAN_FID_CACHE", Path.home() / ".cache" / "clean-fid"))
INCEPTION_FILE = "inception-2015-12-05.pt"
IMAGE_SIZE = 299

# ── the spatial Inception feature of csFID (Nash et al., ICML 2021) ─────────────────────
# The Inception-v3 activation "mixed_6/conv" -- the FIRST branch of the mixed_6 Inception block, BEFORE its
# concatenation with tower/tower_1/tower_2 (openai/guided-diffusion evaluator.py FID_SPATIAL_NAME) -- is a
# 192 x 17 x 17 tensor; its first 7 channels are kept and flattened NHWC to 17*17*7 = 2023 components per image,
# without spatial average pooling (that pooling is what makes the 2048-D pool3 cFID feature layout-tolerant).
# In this TorchScript port the tensor is the submodule layers.mixed_6.conv; the module names come from the TF op
# names. The stepwise traversal used to read it reproduces layers.forward exactly on CPU (verify_spatial_tap).
SPATIAL_LAYER = "mixed_6/conv"
SPATIAL_CHANNELS = 7
SPATIAL_GRID = 17
SPATIAL_DIM = SPATIAL_GRID * SPATIAL_GRID * SPATIAL_CHANNELS          # 2023
POOL3_DIM = 2048
_MIXED5_SHAPE = (768, SPATIAL_GRID, SPATIAL_GRID)                     # canonical input of the mixed_6 block


def _sha256(path: Path) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1 << 22), b""):
            h.update(chunk)
    return h.hexdigest()


def pins() -> dict:
    import PIL
    import torch
    p = INCEPTION_DIR / INCEPTION_FILE
    return {"backend": "clean-fid", "clean_fid_version": _md.version("clean-fid"), "mode": "clean",
            "resize": {"backend": "PIL", "filter": "bicubic", "size": IMAGE_SIZE, "once": True,
                       "implementation": "cleanfid.resize.build_resizer('clean') = make_resizer('PIL', False, 'bicubic', (299, 299))"},
            "inception": {"file": INCEPTION_FILE, "path": str(p), "sha256": _sha256(p) if p.is_file() else None,
                          "feature_layer": "pool3", "feature_dim": POOL3_DIM, "resize_inside": False},
            "spatial": {"feature_layer": SPATIAL_LAYER, "submodule": "layers.mixed_6.conv",
                        "tap": "first branch of the mixed_6 block, before concatenation with tower/tower_1/tower_2",
                        "channels_kept": SPATIAL_CHANNELS, "grid": [SPATIAL_GRID, SPATIAL_GRID], "feature_dim": SPATIAL_DIM,
                        "flatten": "NHWC", "spatial_pooling": "none",
                        "reference": "Nash et al., ICML 2021; openai/guided-diffusion evaluator.py FID_SPATIAL_NAME='mixed_6/conv:0'"},
            "fid": "cleanfid.fid.frechet_distance (scipy sqrtm, eps 1e-6 fallback)",
            "kid": "cleanfid.fid.kernel_distance (unbiased polynomial-kernel MMD^2)",
            "torch": torch.__version__, "pillow": PIL.__version__, "numpy": np.__version__}


def check_pins(expected: dict) -> dict:
    """``expected`` = the fid settings (clean_fid_version, inception.sha256, inception.file). Raises on drift."""
    got = pins()
    probs = []
    if str(expected.get("clean_fid_version")) != got["clean_fid_version"]:
        probs.append(f"clean-fid {got['clean_fid_version']} != pinned {expected.get('clean_fid_version')}")
    exp_inc = expected.get("inception") or {}
    if exp_inc.get("file") and exp_inc["file"] != got["inception"]["file"]:
        probs.append("inception file name differs")
    if got["inception"]["sha256"] is None:
        probs.append(f"Inception weights not found at {got['inception']['path']} (download once with cleanfid on a node with network)")
    elif exp_inc.get("sha256") and exp_inc["sha256"] != got["inception"]["sha256"]:
        probs.append(f"Inception weights sha256 {got['inception']['sha256'][:12]} != pinned {exp_inc['sha256'][:12]}")
    if probs:
        raise RuntimeError("FID pipeline pin violated: " + "; ".join(probs))
    return got


def build_extractor(device):
    """clean-fid 'clean' extractor: TorchScript TF-Inception, weights from INCEPTION_DIR, no internal resize."""
    from cleanfid.inception_torchscript import InceptionV3W
    INCEPTION_DIR.mkdir(parents=True, exist_ok=True)
    model = InceptionV3W(str(INCEPTION_DIR), download=not (INCEPTION_DIR / INCEPTION_FILE).is_file(),
                         resize_inside=False).to(device).eval()
    return model


def inception_features(imgs_u8: np.ndarray, extractor, device, batch: int = 200) -> np.ndarray:
    """uint8 (N, H, W, 3) -> float32 (N, 2048): a single clean resize per image, then the pinned network."""
    import torch
    from cleanfid.resize import build_resizer
    if imgs_u8.dtype != np.uint8 or imgs_u8.ndim != 4 or imgs_u8.shape[-1] != 3:
        raise ValueError(f"adapter output must be uint8 (N, H, W, 3); got {imgs_u8.dtype} {imgs_u8.shape}")
    resizer = build_resizer("clean")
    out = []
    with torch.no_grad():
        for i in range(0, len(imgs_u8), int(batch)):
            xs = [resizer(im) for im in imgs_u8[i:i + int(batch)]]           # float32 (299, 299, 3) in [0, 255]
            x = torch.from_numpy(np.stack(xs).transpose(0, 3, 1, 2).copy()).to(device)
            if tuple(x.shape[-2:]) != (IMAGE_SIZE, IMAGE_SIZE):
                raise AssertionError("resizer did not produce 299x299")
            out.append(extractor(x).cpu().numpy())
    return np.concatenate(out).astype(np.float32)


def fid_from_features(f1: np.ndarray, f2: np.ndarray) -> float:
    from cleanfid.fid import frechet_distance
    f1 = np.asarray(f1, np.float64); f2 = np.asarray(f2, np.float64)
    return float(frechet_distance(f1.mean(0), np.cov(f1, rowvar=False), f2.mean(0), np.cov(f2, rowvar=False)))


def kid_from_features(f1: np.ndarray, f2: np.ndarray, num_subsets: int = 100, max_subset_size: int = 1000) -> float:
    from cleanfid.fid import kernel_distance
    return float(kernel_distance(np.asarray(f1, np.float64), np.asarray(f2, np.float64),
                                 num_subsets=int(num_subsets), max_subset_size=int(max_subset_size)))


# ── csFID feature extraction ────────────────────────────────────────────────────────────────────────────────

def _pre_mixed6_stages(layers):
    """The layer sequence of layers.forward up to (not including) mixed_6 -- mirrors its TorchScript source."""
    return [layers.conv, layers.conv_1, layers.conv_2, layers.pool0, layers.conv_3, layers.conv_4, layers.pool1,
            layers.mixed, layers.mixed_1, layers.mixed_2, layers.mixed_3, layers.mixed_4, layers.mixed_5]


def _post_mixed6_stages(layers):
    return [layers.mixed_6, layers.mixed_7, layers.mixed_8, layers.mixed_9, layers.mixed_10, layers.pool2]


def spatial_tap(extractor, x_normalized):
    """x already resized to 299x299 and normalized as InceptionV3W does ((x - 128) / 128) -> the mixed_6/conv
    activation (N, 192, 17, 17)."""
    L = extractor.layers
    h = x_normalized
    for st in _pre_mixed6_stages(L):
        h = st.forward(h)
    return L.mixed_6.conv.forward(h)


def spatial_features(imgs_u8: np.ndarray, extractor, device, batch: int = 200) -> np.ndarray:
    """uint8 (N, H, W, 3) -> float32 (N, 2023): the canonical sFID feature. Same clean resize and normalization
    as inception_features; only the tapped tensor differs."""
    import torch
    from cleanfid.resize import build_resizer
    if imgs_u8.dtype != np.uint8 or imgs_u8.ndim != 4 or imgs_u8.shape[-1] != 3:
        raise ValueError(f"adapter output must be uint8 (N, H, W, 3); got {imgs_u8.dtype} {imgs_u8.shape}")
    resizer = build_resizer("clean")
    out = []
    with torch.no_grad():
        for i in range(0, len(imgs_u8), int(batch)):
            xs = [resizer(im) for im in imgs_u8[i:i + int(batch)]]
            x = torch.from_numpy(np.stack(xs).transpose(0, 3, 1, 2).copy()).to(device)
            if tuple(x.shape[-2:]) != (IMAGE_SIZE, IMAGE_SIZE):
                raise AssertionError("resizer did not produce 299x299")
            sp = spatial_tap(extractor, (x - 128) / 128)[:, :SPATIAL_CHANNELS]
            out.append(sp.permute(0, 2, 3, 1).reshape(sp.shape[0], -1).cpu().numpy())
    feats = np.concatenate(out).astype(np.float32)
    if feats.shape[1] != SPATIAL_DIM:
        raise AssertionError(f"spatial feature dim {feats.shape[1]} != {SPATIAL_DIM}")
    return feats


def verify_spatial_tap(extractor=None) -> dict:
    """Check on CPU that (a) the stepwise traversal reproduces layers.forward exactly (so the tap sits on the
    cFID path) and (b) the tapped tensor is the canonical 17x17 mixed_6/conv representation. Raises on failure;
    returns the record that MANIFEST_CSFID.json carries."""
    import torch
    ext = extractor if extractor is not None else build_extractor(torch.device("cpu"))
    L = ext.layers
    if not (hasattr(L, "mixed_5") and hasattr(L, "mixed_6") and hasattr(L.mixed_6, "conv")):
        raise RuntimeError("this Inception build does not expose layers.mixed_6.conv -- canonical sFID unavailable")
    x = (torch.rand(3, 3, IMAGE_SIZE, IMAGE_SIZE) * 255.0 - 128) / 128
    with torch.no_grad():
        h = x
        for st in _pre_mixed6_stages(L):
            h = st.forward(h)
        mixed5 = h
        sp = L.mixed_6.conv.forward(mixed5)
        for st in _post_mixed6_stages(L):
            h = st.forward(h)
        diff = float((h.view(3, POOL3_DIM) - L.forward(x).view(3, POOL3_DIM)).abs().max())
    flat_dim = int(sp[:, :SPATIAL_CHANNELS].permute(0, 2, 3, 1).reshape(3, -1).shape[1])
    rec = {"mixed_5_output_shape_CHW": list(sp.shape[1:]) and list(mixed5.shape[1:]),
           "mixed_6_conv_output_shape_CHW": list(sp.shape[1:]), "channels_total": int(sp.shape[1]),
           "channels_kept": SPATIAL_CHANNELS, "feature_dim": flat_dim, "flatten": "NHWC",
           "stepwise_vs_layers_forward_max_abs_diff_cpu": diff, "bitexact_cpu": diff == 0.0}
    probs = []
    if tuple(mixed5.shape[1:]) != _MIXED5_SHAPE:
        probs.append(f"mixed_5 output {tuple(mixed5.shape[1:])} != canonical {_MIXED5_SHAPE}")
    if tuple(sp.shape[2:]) != (SPATIAL_GRID, SPATIAL_GRID):
        probs.append(f"mixed_6/conv spatial {tuple(sp.shape[2:])} != {SPATIAL_GRID}x{SPATIAL_GRID}")
    if flat_dim != SPATIAL_DIM:
        probs.append(f"feature dim {flat_dim} != {SPATIAL_DIM}")
    if diff != 0.0:
        probs.append(f"stepwise traversal differs from layers.forward on CPU by {diff}")
    if probs:
        raise RuntimeError("spatial tap verification failed: " + "; ".join(probs))
    return rec