"""
shape_metrics.py —— 生成的形状"画得像不像"：几把尺子（第 6–9 讲共用）
=====================================================================

【这个文件要回答的问题】
    分类器有"准确率"，语言模型有"困惑度"，可是 GAN 这样的生成模型连 p(x) 都算不出来（第 6 讲 §7）。
    它画出来的图好不好，怎么打分？"好"至少包含两件事：
        质量 quality   ：每一张看起来像不像真的形状？
        多样性 diversity：所有样本合起来，有没有覆盖真实数据的各种情况（四种形状、各种位置和大小）？
    只看一张图判断不了第二件事：一个只会画"正中间的圆"的模型，每张都很像，却漏掉了绝大部分数据——
    这叫模式崩塌（mode collapse，第 6 讲 §3.6）。

    形状世界是我们自己画的，所以可以做真实图像数据集上做不到的检查。这里提供四把尺子：

    1. 形状裁判 ShapeJudge：在真实形状上训练好的小 CNN 分类器（第 5 讲的 SmallCNN 结构）。
       看生成的图被判成哪一类、判得有多确定。四类的比例偏离 1/4 很多 → 有些形状画不出来。
    2. Fréchet 距离（FD）：把真图和生成图都送进裁判，取它分类之前的 32 维特征，各拟合一个高斯，
       算两个高斯之间的 Fréchet 距离。这就是图像生成领域最常用的 FID（Fréchet Inception Distance）
       的"缩小版"——FID 用的是在 ImageNet 上训练的 Inception 网络的特征。越小越好；它同时惩罚质量差和多样性差。
    3. 形状拟合 fit_shapes：形状世界的渲染器是可微的（第 5 讲 shapes_world.render），
       所以对每张生成的图，可以用梯度下降去找"最像它的完美形状"（类别、中心、半径、角度）。
       拟合残差小 = 这张图确实是一个干净的形状；拟合出来的参数还能告诉我们位置和大小的分布（多样性）。
    4. 最近邻距离 nn_distance：每张生成的图和训练集里最像的那张差多少。如果远小于"真实的新图到训练集"的距离，
       模型可能是在背训练集（记忆，memorization），而不是学会了分布。

【本文件提供】
    ShapeJudge / load_judge          裁判网络（第一次调用时训练并缓存到 checkpoints/shape_judge.pt，约 15 秒）
    frechet_distance(f1, f2)         两组特征之间的 Fréchet 距离
    fit_shapes(images)               可微渲染 + 梯度下降的形状拟合；render_fit 把拟合结果画出来
    edge_and_noise(...)              边缘有多宽（模糊）、平坦区域有多少像素噪声（纹理）——形状拟合残差看不出的两件事
    nn_distance(gen, train)          到训练集的最近邻距离（每像素均方根）
    evaluate(images, ...)            一次算齐上面所有指标，返回字典
    REAL_STATS / real_reference(...) 真实图像的参考值（同样的指标用在一批新的真图上，作为"满分"）
    ascii_grid(images, ...)          在终端里并排画几张图

【边界】
    - 输入图像约定为 (N, 1, 28, 28)、值大致在 [0, 1]；超出范围的值在裁判和拟合之前会被截断到 [0, 1]。
    - FD 要估计 32 维特征的均值和协方差，样本太少时（几十张）估计本身就很不准，而且有偏（样本少 → FD 偏大）。
      所以比较两个模型时要用同样多的样本；这里默认 500 张。
    - 形状拟合假设图里只有一个形状、亮度约为 1、背景约为 0。拟合残差的"噪声底"约 0.0015
      （真实图有标准差 0.05 的噪声，截断到 [0, 1] 之后的均方误差）。

【GenAI 联系】
    评价生成模型本身就是一个难题。FID（Heusel et al., 2017）至今仍是文生图论文的主要指标之一（第 12、14 讲），
    但它也有被"刷分"的问题——这就是为什么这里还准备了能直接解释的形状拟合和最近邻检查。
"""

import math
import os
import sys

import torch
import torch.nn as nn
import torch.nn.functional as F

HERE = os.path.dirname(os.path.abspath(__file__))
_L5 = os.path.join(HERE, "..", "lecture05")
if _L5 not in sys.path:
    sys.path.insert(0, _L5)
from shapes_world import CLASSES, ascii_image, make_shapes, render  # noqa: E402

JUDGE_PATH = os.path.join(HERE, "checkpoints", "shape_judge.pt")
TRAIN_SEED = 1          # 第 6–9 讲的生成模型都在 make_shapes(N, seed=1) 上训练
REF_SEED = 77           # "参考用的真实新图"——生成模型没见过，用来给出各项指标的满分


# =====================================================================
# 1. 裁判网络
# =====================================================================

class ShapeJudge(nn.Module):
    """
    和第 5 讲的 SmallCNN 一样的结构：三层 3×3 卷积（16、32、32 通道）+ 两次最大池化 + 全局平均池化 + 线性分类层。

    【为什么用它的特征算 FD】分类器在分类之前的那一层特征，已经把"像素"变成了"这个东西像什么"的描述，
        比直接比较像素更接近人对图像的判断。FID 用 Inception 网络也是同样的道理。
    【注意】全局平均池化让特征几乎不含位置信息，所以 FD 对"形状都挤在一个位置"不敏感——
        这正是要另外用形状拟合检查位置分布的原因。
    """

    def __init__(self, width=16, n_classes=4):
        super().__init__()
        self.body = nn.Sequential(
            nn.Conv2d(1, width, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
            nn.Conv2d(width, 2 * width, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
            nn.Conv2d(2 * width, 2 * width, 3, padding=1), nn.ReLU())
        self.head = nn.Linear(2 * width, n_classes)

    def features(self, x):
        """(N, 1, 28, 28) → (N, 32)：全局平均池化后的特征。"""
        return self.body(x).mean(dim=(2, 3))

    def forward(self, x):
        return self.head(self.features(x))


def load_judge(log=print, retrain=False):
    """
    读取缓存的裁判；没有就训练一个（8192 张真图、1500 步，单线程约 15 秒）并缓存。

    【返回】eval 模式的 ShapeJudge
    【边界】缓存文件和代码版本不匹配（比如改了结构）时会报错；删掉 checkpoints/shape_judge.pt 或传 retrain=True 即可。
    """
    judge = ShapeJudge()
    if os.path.exists(JUDGE_PATH) and not retrain:
        judge.load_state_dict(torch.load(JUDGE_PATH, weights_only=True))
        return judge.eval()
    log("  （第一次运行：训练形状裁判，约 15 秒，之后读取缓存 checkpoints/shape_judge.pt）")
    x, y = make_shapes(8192, seed=TRAIN_SEED)
    g = torch.Generator().manual_seed(0)
    torch.manual_seed(0)
    judge = ShapeJudge()
    opt = torch.optim.Adam(judge.parameters(), lr=3e-3)
    for _ in range(1500):
        idx = torch.randint(len(x), (64,), generator=g)
        loss = F.cross_entropy(judge(x[idx]), y[idx])
        opt.zero_grad()
        loss.backward()
        opt.step()
    judge.eval()
    xt, yt = make_shapes(2000, seed=99)
    with torch.no_grad():
        acc = (judge(xt).argmax(-1) == yt).float().mean().item()
    log(f"  裁判在 2000 张新的真图上的准确率：{acc:.3f}")
    os.makedirs(os.path.dirname(JUDGE_PATH), exist_ok=True)
    torch.save(judge.state_dict(), JUDGE_PATH)
    return judge


# =====================================================================
# 2. Fréchet 距离
# =====================================================================

def _sqrtm_psd(m):
    """对称半正定矩阵的平方根：特征分解 m = V diag(λ) Vᵀ → V diag(√λ) Vᵀ（负的小特征值是舍入误差，截成 0）。"""
    lam, vec = torch.linalg.eigh(m)
    return (vec * lam.clamp_min(0).sqrt()) @ vec.T


def frechet_distance(f1, f2):
    """
    两组特征（各自看成一个多维高斯）之间的 Fréchet 距离：
        FD = ‖μ₁ − μ₂‖² + Tr(Σ₁ + Σ₂ − 2 (Σ₁^½ Σ₂ Σ₁^½)^½)

    【直觉】第一项比较"平均长什么样"，第二项比较"变化的范围和方向"。只会画一种图的模型，Σ 很小，第二项就很大。
    【参数】f1 (N1, d)、f2 (N2, d)
    【返回】float（≥ 0；两组样本来自同一分布时也不是 0，而是一个随样本数减小的正数，所以一定要和"真图 vs 真图"比）
    【实现】全部用 float64；Tr((Σ₁^½ Σ₂ Σ₁^½)^½) 等于 Tr((Σ₁Σ₂)^½)，但前者是对称矩阵，数值上稳定。
    """
    f1, f2 = f1.detach().double(), f2.detach().double()
    mu1, mu2 = f1.mean(0), f2.mean(0)
    s1, s2 = torch.cov(f1.T), torch.cov(f2.T)
    r1 = _sqrtm_psd(s1)
    covmean_tr = torch.linalg.eigvalsh(r1 @ s2 @ r1).clamp_min(0).sqrt().sum()
    return float(((mu1 - mu2) ** 2).sum() + torch.trace(s1) + torch.trace(s2) - 2 * covmean_tr)


# =====================================================================
# 3. 形状拟合：可微渲染 + 梯度下降
# =====================================================================

# 每种形状的面积是 "系数 × r²"（和 shapes_world.render 里的几何定义一致），用来从图像的总亮度猜一个初始半径
_AREA = {"circle": math.pi, "square": 1.7 ** 2, "triangle": 3 * math.sqrt(3) * 0.55 ** 2, "cross": 2 * 1.4 - 0.7 ** 2}
# 旋转对称的周期：正方形、十字转 90° 和原来一样，三角形转 120° 一样，圆转多少都一样
_PERIOD = {"circle": 2 * math.pi, "square": math.pi / 2, "triangle": 2 * math.pi / 3, "cross": math.pi / 2}
R_MIN = 4.0      # 拟合时半径的下限（像素）：真实形状的半径在 4.5–8 之间。没有下限的话，一张几乎空白的图会被
                 # "半径 0.3 像素的形状"拟合得很好（残差很小），被误判成"像形状"——03 号脚本第一版就踩了这个坑；
                 # 下限取 3 时，又发现"最像形状"的生成图全是半径 3 左右的小点（面积小，均方误差自然小）。
                 # 取 4（略小于真实最小值 4.5）：比真实数据还小的"形状"按画错了算。


def fit_shapes(images, steps=50, lr=0.1, n_angles=4):
    """
    对每张图，找"最像它的完美形状"。

    【思路】分析-综合（analysis by synthesis）：对每种形状，从图像的重心（中心）和总亮度（半径）出发，
        再试 n_angles 个初始角度，用 Adam 同时优化 (cx, cy, log r, angle) 让渲染图和这张图的均方误差最小；
        四种形状 × 若干初始角度里取误差最小的那个。
        为什么要多个初始角度：误差对角度不是凸的（比如三角形转 60° 正好"尖对边"，梯度会把它卡在那里）。
    【参数】images (N, 1, 28, 28)；steps：每次拟合的梯度步数；lr：Adam 学习率；n_angles：每种形状试几个初始角度
    【返回】(residual (N,), cls (N,) long, params (N, 4) = [cx, cy, r, angle])
        residual 是最佳拟合的每像素均方误差：真实图约 0.0015（噪声底），完全不像形状的随机噪声约 0.24。
    【边界】
        - 全黑 / 几乎空白的图：总亮度按 1e-3 截断，避免除以 0；半径有下限 R_MIN = 3 像素，
          所以空白图的最佳拟合也是一个 4 像素的形状，残差约 0.02，远大于"像形状"的门槛 0.005。
        - 初始半径限制在 [R_MIN, 14] 像素；优化时用 log r，渲染时再截断到 ≥ R_MIN。
        - 在 256 张图上约 2 秒（单线程）。
    """
    x = images[:, 0].detach().clamp(0, 1)
    B, H, _ = x.shape
    coords = torch.arange(H, dtype=torch.float32) + 0.5
    yy, xx = torch.meshgrid(coords, coords, indexing="ij")
    mass = x.sum((1, 2)).clamp_min(1e-3)
    cx0, cy0 = (x * xx).sum((1, 2)) / mass, (x * yy).sum((1, 2)) / mass
    best = torch.full((B,), float("inf"))
    best_k = torch.zeros(B, dtype=torch.long)
    best_p = torch.zeros(B, 4)
    for k, name in enumerate(CLASSES):
        na = 1 if name == "circle" else n_angles
        log_r0 = (mass / _AREA[name]).sqrt().clamp(R_MIN, 14).log()
        # 每张图复制 na 份，各自从不同的初始角度出发；【PyTorch 小知识】repeat_interleave 把每个元素连续复制 na 次
        cx = cx0.repeat_interleave(na).clone().requires_grad_()
        cy = cy0.repeat_interleave(na).clone().requires_grad_()
        lr_ = log_r0.repeat_interleave(na).clone().requires_grad_()
        an = (torch.arange(na) * _PERIOD[name] / na).repeat(B).clone().requires_grad_()
        tgt = x.repeat_interleave(na, 0)
        opt = torch.optim.Adam([cx, cy, lr_, an], lr=lr)
        with torch.enable_grad():
            for _ in range(steps):
                err = ((render(name, cx, cy, lr_.exp().clamp_min(R_MIN), an, H) - tgt) ** 2).mean((1, 2))
                opt.zero_grad()
                err.sum().backward()               # 各张图的误差互不相关，求和后一次反向传播就能同时优化所有拟合
                opt.step()
        with torch.no_grad():
            err = ((render(name, cx, cy, lr_.exp().clamp_min(R_MIN), an, H) - tgt) ** 2).mean((1, 2)).view(B, na)
            m, j = err.min(1)
            idx = torch.arange(B) * na + j
            p = torch.stack([cx[idx], cy[idx], lr_[idx].exp().clamp_min(R_MIN), an[idx] % _PERIOD[name]], 1)
            better = m < best
            best = torch.where(better, m, best)
            best_k[better] = k
            best_p[better] = p[better]
    return best, best_k, best_p


def render_fit(k, p, size=28, softness=None):
    """把 fit_shapes 的结果（类别 k、参数 p）重新画出来：(N, 1, size, size)。softness：(N,) 的边缘宽度，None 表示默认的 0.6。"""
    out = torch.zeros(len(k), size, size)
    for c, name in enumerate(CLASSES):
        sel = k == c
        if sel.any():
            if softness is None:
                out[sel] = render(name, p[sel, 0], p[sel, 1], p[sel, 2], p[sel, 3], size)
            else:
                for sv in softness[sel].unique():                   # render 的 softness 是一个数：按取值分组画
                    ss = sel & (softness == sv)
                    out[ss] = render(name, p[ss, 0], p[ss, 1], p[ss, 2], p[ss, 3], size, softness=float(sv))
    return out.unsqueeze(1)


SOFTNESS_GRID = (0.3, 0.45, 0.6, 0.8, 1.0, 1.3, 1.7, 2.2, 3.0)


def edge_and_noise(images, k, p):
    """
    两个"纹理"指标，补充形状拟合残差看不出来的东西：
        边缘宽度：固定形状的类别、位置、大小、角度，只改渲染时的 softness（真实数据用 0.6），在 SOFTNESS_GRID 里
                  选残差最小的那个。图越模糊，边缘越宽。
        像素噪声：用最佳边缘宽度画出拟合的形状，只看"平坦"的像素（渲染值 < 0.01 或 > 0.99，即远离边缘的背景和形状内部），
                  算图像和拟合之差的标准差。真实图有标准差 0.05 的像素噪声（截断到 [0, 1] 后约 0.03）；
                  "太干净、太平滑"的生成图接近 0。
    【为什么不直接看背景】模糊的图在边缘附近有一圈晕染，如果把它算进"背景"，会被误当成噪声。
    【返回】(边缘宽度 (N,), 像素噪声 (N,))
    """
    x = images.clamp(0, 1)
    best_err = torch.full((len(x),), float("inf"))
    best_s = torch.zeros(len(x))
    for sv in SOFTNESS_GRID:
        r = render_fit(k, p, x.shape[-1], softness=torch.full((len(x),), sv))
        err = ((r - x) ** 2).flatten(1).mean(1)
        better = err < best_err
        best_err = torch.where(better, err, best_err)
        best_s[better] = sv
    r = render_fit(k, p, x.shape[-1], softness=best_s)
    flat = ((r < 0.01) | (r > 0.99)).float()
    n = flat.flatten(1).sum(1).clamp_min(2)
    d = (x - r) * flat
    mean = d.flatten(1).sum(1) / n
    var = (((x - r - mean.view(-1, 1, 1, 1)) ** 2) * flat).flatten(1).sum(1) / (n - 1)
    return best_s, var.sqrt()


# =====================================================================
# 4. 最近邻距离
# =====================================================================

def nn_distance(gen, train, batch=500):
    """
    每张生成图到训练集里最近一张的距离（每像素均方根，RMS）。

    【参数】gen (N, 1, 28, 28)，train (M, 1, 28, 28)
    【返回】(N,) 的张量
    【实现】‖a − b‖² = ‖a‖² + ‖b‖² − 2 a·b，用矩阵乘法一次算出一批 × 全部训练图的距离；分批是为了控制内存。
    """
    t = train.flatten(1)
    t2 = (t ** 2).sum(1)
    out = []
    for i in range(0, len(gen), batch):
        g = gen[i:i + batch].flatten(1).clamp(0, 1)
        d2 = (g ** 2).sum(1, keepdim=True) + t2[None] - 2 * g @ t.T
        out.append((d2.min(1).values.clamp_min(0) / t.shape[1]).sqrt())
    return torch.cat(out)


# =====================================================================
# 5. 一次算齐
# =====================================================================

@torch.no_grad()
def evaluate(images, judge, ref_feats, train=None, fit=True):
    """
    【参数】images (N, 1, 28, 28)；judge：load_judge() 的结果；ref_feats：真实参考图的裁判特征（real_reference 给出）；
        train：训练集（给了才算最近邻距离）；fit：是否做形状拟合（256 张约 2 秒）
    【返回】字典：
        class_freq  四类的比例（裁判的判断）       conf      裁判的平均最大概率（越接近 1 越"确定"）
        fit_freq    四类的比例（形状拟合的判断）   agree     裁判和形状拟合判成同一类的比例
                    （真图上约 0.99；生成的图越"不标准"，两把尺子越容易意见不一致——这时任何一个类别比例都要谨慎看待）
        blank       几乎空白的图（最亮的像素 < 0.5）的比例——裁判对这种图也会给出很高的确信度，所以要单独数
        fd          和真实参考图的 Fréchet 距离    fit_res   形状拟合残差的中位数    good      残差 < 0.005 的比例
        cx_std / cy_std / r_mean / r_std  拟合出来的位置和大小的统计（看多样性）
        edge        拟合出的边缘宽度（真实图 0.6；越大越模糊）   noise   平坦区域的像素噪声（真实图约 0.03；接近 0 = 太平滑）
        nn          到训练集最近邻距离的中位数
    """
    x = images.clamp(0, 1)
    probs = judge(x).softmax(-1)
    out = {"class_freq": torch.bincount(probs.argmax(-1), minlength=4).float() / len(x),
           "conf": probs.max(-1).values.mean().item(),
           "blank": (x.flatten(1).max(1).values < 0.5).float().mean().item(),
           "fd": frechet_distance(judge.features(x), ref_feats)}
    if fit:
        res, k, p = fit_shapes(x)
        edge, noise = edge_and_noise(x, k, p)
        out.update(edge=edge.mean().item(), noise=noise.mean().item(),
                   fit_freq=torch.bincount(k, minlength=4).float() / len(x),
                   agree=(k == probs.argmax(-1)).float().mean().item(),
                   fit_res=res.median().item(), good=(res < 0.005).float().mean().item(),
                   cx_std=p[:, 0].std().item(), cy_std=p[:, 1].std().item(),
                   r_mean=p[:, 2].mean().item(), r_std=p[:, 2].std().item())
    if train is not None:
        out["nn"] = nn_distance(x, train).median().item()
    return out


@torch.no_grad()
def real_reference(judge, n=500):
    """
    真实参考：返回 (ref_feats, real_eval)。
        ref_feats ：n 张"参考用"真实新图（seed=REF_SEED）的裁判特征，所有生成模型都和它比 FD；
        real_eval ：另一批 n 张真实新图（seed=REF_SEED+1）当作"生成结果"的各项指标——这是任何模型能达到的"满分"
                    （FD 不是 0，而是两批独立真图之间的抽样误差）。
    """
    ref, _ = make_shapes(n, seed=REF_SEED)
    ref_feats = judge.features(ref)
    other, _ = make_shapes(n, seed=REF_SEED + 1)
    train, _ = make_shapes(8192, seed=TRAIN_SEED)
    return ref_feats, evaluate(other, judge, ref_feats, train=train)


def format_eval(e):
    """把 evaluate 的结果排成表格的一行（类别比例按 CLASSES 的顺序：圆 / 方 / 三角 / 十字）。"""
    row = ["/".join(f"{v:.2f}" for v in e["class_freq"].tolist())]
    if "fit_res" in e:
        row += ["/".join(f"{v:.2f}" for v in e["fit_freq"].tolist()), f"{e['agree']:.2f}"]
    row.append(f"{e['fd']:.2f}")
    if "fit_res" in e:
        row += [f"{e['fit_res']:.4f}", f"{e['good']:.2f}", f"{e['edge']:.2f}", f"{e['noise']:.3f}",
                f"{e['cx_std']:.1f}/{e['cy_std']:.1f}", f"{e['r_mean']:.1f}±{e['r_std']:.1f}"]
    if "nn" in e:
        row.append(f"{e['nn']:.3f}")
    return row


EVAL_HEADERS = ["四类比例·裁判 圆/方/三角/十", "四类比例·拟合", "两者一致", "FD↓", "拟合残差↓", "像形状的比例",
                "边缘宽度", "像素噪声", "位置标准差 x/y", "半径", "最近邻距离"]
# 不做形状拟合（fit=False）时，format_eval 只有"四类比例·裁判"和 FD 两列（再加上可能的最近邻距离）


def ascii_grid(images, ncol=6, row_step=2, indent="    "):
    """并排打印若干张图（每张 28 列宽，隔 row_step 行打印一行，让图在终端里不至于太高）。返回行的列表。"""
    pics = [ascii_image(img.clamp(0, 1)) for img in images]
    lines = []
    for start in range(0, len(pics), ncol):
        group = pics[start:start + ncol]
        for r in range(0, len(group[0]), row_step):
            lines.append(indent + "  ".join(p[r] for p in group))
        lines.append("")
    return lines


if __name__ == "__main__":
    # 自检：几种"坏的生成器"各得几分（讲义第 6 讲 §0.4 引用了这里的数字）
    import torch.nn.functional as F
    from console import print_table
    torch.set_num_threads(1)
    judge = load_judge()
    ref_feats, real_eval = real_reference(judge)
    n = 500
    centered = render("circle", torch.full((n,), 14.0), torch.full((n,), 14.0), torch.full((n,), 6.0),
                      torch.zeros(n)).unsqueeze(1)
    circles, _ = make_shapes(n, seed=6, classes=["circle"])
    blurred = F.avg_pool2d(make_shapes(n, seed=5)[0], 3, 1, 1)
    rows = [["真实新图（满分）"] + format_eval(real_eval)]
    for name, xs in (("只画正中间同一个圆", centered), ("只画圆（位置、大小随机）", circles), ("真图模糊一下（3×3 平均）", blurred)):
        rows.append([name] + format_eval(evaluate(xs, judge, ref_feats)))
    print_table(["来源"] + [h for h in EVAL_HEADERS if h != "最近邻距离"], rows)
