"""
=============================================================================
CS230 · 第 13 讲工具模块：合成的"形状世界"与"纹理世界"（彩色小图）
=============================================================================

对应讲义：lectures/lecture13_deep_cnn_architectures.md（§3 plain vs residual、§7 迁移学习、§8 数据增强）

-----------------------------------------------------------------------------
【为什么需要它】
-----------------------------------------------------------------------------
本讲要做三类实验：
    · 很深的网络能不能训练（§3）：需要一个"有点难"的多类图像任务，看**训练误差**随深度怎样变化；
    · 迁移学习（§7）：需要一个"大"的源任务（source task）来预训练，再加几个"小"的目标任务（target task）；
    · 数据增强（§8）：需要知道"哪些变换不改变标签"——所以标签必须由我们自己写下的规则决定。
真实的 ImageNet 我们下载不了，也训练不动。于是用几十行 numpy "画"一个世界：

    形状世界 shapes  ：一张 S×S 的彩色图里有一个形状（圆、方块、三角、十字……），类别 = 形状是哪一种。
                        干扰：位置、大小、±25° 的旋转、形状和背景的颜色、背景的渐变、像素噪声、一个小的干扰圆点。
    纹理世界 textures：整张图是一种纹理（条纹、棋盘格、斑点、云纹、细线），类别 = 纹理是哪一种。

形状分成互不重叠的三组：
    SOURCE_CLASSES  16 种：源任务（"大"数据集，用来预训练，相当于 ImageNet）
    TARGET_CLASSES   5 种：相关的目标任务（新的形状，但由同样的"零件"——直边、角、弧线——组成）
    ARROW_CLASSES    4 种：箭头朝左 / 右 / 上 / 下 —— **左右翻转会改变标签**，§8 用它说明"增强要保持标签不变"
纹理（TEXTURE_CLASSES 5 种）是"不太相关"的目标任务：图像的统计性质和形状世界差别很大。

-----------------------------------------------------------------------------
【形状是怎么画的】
-----------------------------------------------------------------------------
每个形状用一个函数 f(u, v) 描述：(u, v) 是以形状中心为原点、以形状半径 R 为单位的坐标（已经旋转过），
f > 0 表示在形状里面，f < 0 在外面，而且 |f| 大致等于"到边界的距离"（以 R 为单位）。
这种写法叫 **有符号距离函数**（signed distance function, SDF）的近似。好处是组合很简单：
    两个形状的并集 = max(f1, f2)；交集 = min(f1, f2)；从 f1 里挖掉 f2 = min(f1, −f2)。
渲染时，像素的"覆盖度" alpha = clip(0.5 + f · R, 0, 1)：f · R 是以像素为单位的距离，
边界上的像素得到 0–1 之间的值（抗锯齿），里面是 1，外面是 0。
最终像素 = 背景色 · (1 − alpha) + 形状色 · alpha + 噪声。

【诚实的说明】形状的种类、干扰的强度（噪声 0.08、旋转 ±25°、半径 0.22–0.34 倍图宽、50% 的概率有干扰圆点）
    和纹理的参数，是我在写实验之前在草稿里看了几张图、训练了一两个小网络之后定下的：目标是让源任务
    "不太容易"（小网络训练几个 epoch 达不到 100%），目标任务在每类 10 张时明显困难、每类 300 张时接近解决。
    定下之后没有再为了实验结果去改。

-----------------------------------------------------------------------------
【形状约定】PyTorch 的习惯：每一行（第 0 维）一个样本，图像是 (m, 通道 C, 高 H, 宽 W)
-----------------------------------------------------------------------------
    X：(m, 3, S, S)，float32，像素大致在 [0, 1]（噪声会让少数像素略微越界）
    y：(m,) int64，类别编号 0..C−1
喂给网络之前用 channel_stats / normalize 做按通道的标准化（第 7 讲 §1：训练集和测试集用**同一组** μ、σ）。

【注释标签】【思路】【参数】【返回】【边界】【DL 联系】【numpy 小知识】【Python 小知识】

直接运行本文件：打印每个数据集的基本统计，并把每类一张图画成字符画（约 1 秒）：
    python3 shapes_world.py
"""

import numpy as np

SIZE = 24                        # 默认图像边长（像素）

SOURCE_CLASSES = ["disk", "ring", "square", "frame", "triangle", "plus", "xcross", "hbar",
                  "vbar", "diamond", "ellipse", "halfdisk", "twodots", "threedots", "star", "hexagon"]
TARGET_CLASSES = ["L", "T", "crescent", "equals", "chevron"]
ARROW_CLASSES = ["arrow_l", "arrow_r", "arrow_u", "arrow_d"]
TEXTURE_CLASSES = ["stripes", "checker", "dots", "clouds", "lines"]


# =============================================================================
# 1. 形状：有符号距离函数（近似）
# =============================================================================
def _box(u, v, hu, hv):
    """
    中心在原点、半宽 hu、半高 hv 的矩形：f = min(hu − |u|, hv − |v|)。

    【思路】点在矩形里，当且仅当 |u| < hu 且 |v| < hv；两个条件取 min，正好是"离最近的那条边有多远"。
    【参数】u、v：同形状的数组（以 R 为单位的坐标）；hu、hv：半宽、半高
    【返回】和 u 同形状的数组
    """
    return np.minimum(hu - np.abs(u), hv - np.abs(v))


def _polygon(u, v, angles_deg, h):
    """
    正多边形 / 三角形：若干个半平面的交集。每条边的外法线方向是 angles_deg 里的一个角度，离中心距离 h。

    【思路】点 (u, v) 在法线 n 的那条边"里面"，当且仅当 u·n_x + v·n_y < h；所有边取 min。
            坐标系约定：u 向右，v **向下**（图像的行号向下增大），所以角度 90° 指向下方。
    【参数】angles_deg：法线角度的列表（度）；h：中心到每条边的距离
    【返回】和 u 同形状的数组
    """
    out = None
    for a in np.deg2rad(angles_deg):
        f = h - (u * np.cos(a) + v * np.sin(a))
        out = f if out is None else np.minimum(out, f)
    return out


def _rot(u, v, deg):
    """把坐标逆时针转 deg 度（在 v 向下的坐标系里看是顺时针，不影响我们的用法）。返回 (u', v')。"""
    a = np.deg2rad(deg)
    return u * np.cos(a) + v * np.sin(a), -u * np.sin(a) + v * np.cos(a)


def _arrow_up(u, v):
    """朝上的箭头：一根竖杆 + 一个朝上的三角形箭头（两者取并集 = max）。"""
    shaft = _box(u, v - 0.3, 0.16, 0.55)
    head = _polygon(u, v + 0.35, [90, 211, 329], 0.3)        # 顶点朝上（v 负方向）的三角形
    return np.maximum(shaft, head)


def shape_sdf(name, u, v):
    """
    形状 name 的有符号距离函数（近似）：f > 0 在里面，f < 0 在外面，单位是形状半径 R。

    【参数】name：形状名（见模块开头的四个列表）；u、v：同形状的坐标数组
    【返回】和 u 同形状的数组
    【边界】未知的形状名抛出 KeyError。
    【Python 小知识】这里用一个字典把"名字"映射到 lambda（匿名函数）。lambda u, v: 表达式 等价于
            def f(u, v): return 表达式。用字典而不是一长串 if / elif，加一个新形状只要加一行。
    """
    r = np.hypot(u, v)                                       # 到中心的距离 √(u² + v²)
    table = {
        "disk": lambda: 0.85 - r,
        "ring": lambda: 0.2 - np.abs(r - 0.68),
        "square": lambda: _box(u, v, 0.72, 0.72),
        "frame": lambda: np.minimum(_box(u, v, 0.8, 0.8), -_box(u, v, 0.45, 0.45)),
        "triangle": lambda: _polygon(u, v - 0.1, [90, 210, 330], 0.45),
        "plus": lambda: np.maximum(_box(u, v, 0.2, 0.85), _box(u, v, 0.85, 0.2)),
        "xcross": lambda: np.maximum(_box(*_rot(u, v, 45), 0.2, 0.9), _box(*_rot(u, v, 45), 0.9, 0.2)),
        "hbar": lambda: _box(u, v, 0.9, 0.22),
        "vbar": lambda: _box(u, v, 0.22, 0.9),
        "diamond": lambda: (0.9 - (np.abs(u) + np.abs(v))) / np.sqrt(2),
        "ellipse": lambda: 0.45 * (1 - np.hypot(u / 0.9, v / 0.45)),
        "halfdisk": lambda: np.minimum(0.9 - np.hypot(u, v - 0.35), 0.35 - v),
        "twodots": lambda: np.maximum(0.3 - np.hypot(u - 0.5, v), 0.3 - np.hypot(u + 0.5, v)),
        "threedots": lambda: np.maximum.reduce([0.26 - np.hypot(u - 0.0, v + 0.5),
                                                0.26 - np.hypot(u - 0.5, v - 0.35),
                                                0.26 - np.hypot(u + 0.5, v - 0.35)]),
        "star": lambda: 0.5 * ((0.55 + 0.35 * np.cos(5 * (np.arctan2(v, u) + np.pi / 2))) - r),
        "hexagon": lambda: _polygon(u, v, [0, 60, 120, 180, 240, 300], 0.75),
        # ---- 相关的目标任务：新的形状，由同样的零件（直边、角、弧线）组成 ----
        "L": lambda: np.maximum(_box(u + 0.45, v, 0.2, 0.85), _box(u, v - 0.65, 0.65, 0.2)),
        "T": lambda: np.maximum(_box(u, v + 0.65, 0.85, 0.2), _box(u, v, 0.2, 0.85)),
        "crescent": lambda: np.minimum(0.85 - r, np.hypot(u - 0.45, v) - 0.62),
        "equals": lambda: np.maximum(_box(u, v - 0.35, 0.85, 0.16), _box(u, v + 0.35, 0.85, 0.16)),
        "chevron": lambda: np.maximum(_box(*_rot(u + 0.38, v, 30), 0.18, 0.85), _box(*_rot(u - 0.38, v, -30), 0.18, 0.85)),
        # ---- 箭头：朝上的箭头转 90° 的倍数 ----
        "arrow_u": lambda: _arrow_up(u, v),
        "arrow_d": lambda: _arrow_up(*_rot(u, v, 180)),
        "arrow_r": lambda: _arrow_up(*_rot(u, v, 90)),
        "arrow_l": lambda: _arrow_up(*_rot(u, v, -90)),
    }
    return table[name]()


# =============================================================================
# 2. 渲染：把一批形状画成彩色图
# =============================================================================
def _random_colors(n, rng, background):
    """
    给 n 张图各抽一个形状颜色，保证它和背景色的欧氏距离至少 0.45（否则形状可能"隐形"）。

    【思路】先随机抽，不合格的重抽；最多重抽 20 轮，剩下的（极少）直接取背景色的"补色"。
    【参数】n：图的张数；rng：np.random.Generator；background：(n, 3) 背景色
    【返回】(n, 3) 的颜色，每个分量在 [0, 1]
    【numpy 小知识】np.linalg.norm(a, axis=1)：按行算向量长度；bad = 条件数组，c[bad] = ... 只改不合格的行。
    """
    c = rng.random((n, 3))
    for _ in range(20):
        bad = np.linalg.norm(c - background, axis=1) < 0.45
        if not bad.any():
            break
        c[bad] = rng.random((int(bad.sum()), 3))
    bad = np.linalg.norm(c - background, axis=1) < 0.45
    c[bad] = 1.0 - background[bad]
    return c


def render_shapes(names, rng, size=SIZE, rotate_deg=25.0, noise=0.08, distractor=0.5):
    """
    把一组形状名画成彩色图像。

    【思路】对每张图随机抽：中心 (cx, cy)、半径 R、旋转角 θ、背景色、背景渐变、形状色；
            算出每个像素在形状局部坐标系里的 (u, v)，代入 shape_sdf 得到 f，再变成覆盖度 alpha。
            为了快，同一种形状的所有图一次算完（向量化），只对"形状种类"做 Python 循环。
    【参数】
        names      : 长度 m 的形状名列表（可以重复）
        rng        : np.random.Generator（所有随机性都来自它，同一个种子画出同样的图）
        size       : 图像边长 S
        rotate_deg : 旋转角在 [−rotate_deg, rotate_deg] 里均匀抽取。不转满 360° 是有意的：
                     否则"十字"转 45° 就成了"叉"、"方块"转 45° 就成了"菱形"，类别会互相重合
        noise      : 像素高斯噪声的标准差
        distractor : 每张图有一个小干扰圆点的概率（圆点的颜色随机，可能和形状同色）
    【返回】X：(m, 3, S, S) float32
    【边界】形状靠近边缘时可能被裁掉一小部分（中心在图宽的 30%–70% 之间，半径不超过 34%），这是有意的干扰。
    【numpy 小知识】
        a[:, None, None]  ：在后面加两个长度为 1 的维度，(m,) → (m, 1, 1)，好和 (S,) 的坐标广播成 (m, S, S)
        np.clip(a, lo, hi)：把数组限制在 [lo, hi] 里
    """
    m = len(names)
    names = np.asarray(names)
    S = size
    cx = rng.uniform(0.3, 0.7, m) * S
    cy = rng.uniform(0.3, 0.7, m) * S
    R = rng.uniform(0.22, 0.34, m) * S
    theta = np.deg2rad(rng.uniform(-rotate_deg, rotate_deg, m))
    bg = rng.random((m, 3)) * 0.8 + 0.1
    fg = _random_colors(m, rng, bg)
    grad_dir = rng.uniform(0, 2 * np.pi, m)                    # 背景渐变的方向
    grad_amp = rng.uniform(0.0, 0.25, m)                       # 渐变的强度
    has_dot = rng.random(m) < distractor
    dot_xy = rng.uniform(0.1, 0.9, (m, 2)) * S
    dot_r = rng.uniform(0.06, 0.1, m) * S
    dot_col = rng.random((m, 3))
    pix = np.arange(S) + 0.5                                   # 像素中心的坐标 0.5, 1.5, ..., S − 0.5
    xs, ys = pix[None, None, :], pix[None, :, None]            # 形状 (1, 1, S) 和 (1, S, 1)

    alpha = np.zeros((m, S, S))
    for name in np.unique(names):                              # 每种形状一次算完
        idx = np.nonzero(names == name)[0]
        dx = xs - cx[idx, None, None]
        dy = ys - cy[idx, None, None]
        c, s = np.cos(theta[idx])[:, None, None], np.sin(theta[idx])[:, None, None]
        u = (c * dx + s * dy) / R[idx, None, None]              # 旋转 + 缩放到以 R 为单位
        v = (-s * dx + c * dy) / R[idx, None, None]
        f = shape_sdf(str(name), u, v)
        alpha[idx] = np.clip(0.5 + f * R[idx, None, None], 0.0, 1.0)

    # 背景：底色 + 一个线性渐变（让"背景"不是一块纯色）
    ramp = ((xs / S - 0.5) * np.cos(grad_dir)[:, None, None] + (ys / S - 0.5) * np.sin(grad_dir)[:, None, None])
    X = bg[:, :, None, None] + (grad_amp[:, None, None] * ramp)[:, None, :, :]
    X = X * (1 - alpha[:, None]) + fg[:, :, None, None] * alpha[:, None]
    # 干扰圆点
    d = np.hypot(xs - dot_xy[:, 0, None, None], ys - dot_xy[:, 1, None, None])
    a_dot = np.clip(0.5 + (dot_r[:, None, None] - d), 0.0, 1.0) * has_dot[:, None, None]
    X = X * (1 - a_dot[:, None]) + dot_col[:, :, None, None] * a_dot[:, None]
    X = X + rng.normal(0.0, noise, X.shape)
    return X.astype(np.float32)


def make_shapes(n_per_class, classes, rng, size=SIZE, **kwargs):
    """
    每类 n_per_class 张，生成一个类别均衡、顺序打乱的形状数据集。

    【参数】
        n_per_class : 每类的张数
        classes     : 形状名的列表；第 k 个名字的类别编号是 k
        rng         : np.random.Generator
        size、kwargs: 传给 render_shapes（例如 noise=0.05、rotate_deg=0）
    【返回】(X, y)：X (m, 3, S, S) float32，y (m,) int64，m = n_per_class · len(classes)
    【Python 小知识】**kwargs 收集"其余的关键字参数"成一个字典，再原样传给 render_shapes。
    """
    y = np.repeat(np.arange(len(classes)), n_per_class)
    y = y[rng.permutation(len(y))]                           # 打乱顺序（否则同一类的图排在一起）
    X = render_shapes([classes[k] for k in y], rng, size=size, **kwargs)
    return X, y.astype(np.int64)


# =============================================================================
# 3. 纹理世界："不太相关"的目标任务
# =============================================================================
def _texture_pattern(name, rng, S):
    """
    画一张 (S, S) 的纹理"图案" p，值在 [0, 1]：0 用第一种颜色，1 用第二种颜色。

    【思路】五种纹理，每种都有随机的参数（方向、频率、密度……），所以同一类的图长得各不相同：
        stripes ：正弦条纹，方向任意，周期 3–6 像素
        checker ：两组正交条纹相乘取符号，得到（可旋转的）棋盘格，格子 2.5–5 像素
        dots    ：8–16 个半径 1–1.8 像素的小圆点
        clouds  ：低通滤波的随机噪声（"云纹"），再拉伸对比度
        lines   ：4–7 条随机方向、宽约 1 像素的细直线
    【参数】name：纹理名；rng：随机数生成器；S：边长
    【返回】(S, S) 数组
    """
    pix = np.arange(S) + 0.5
    xs, ys = pix[None, :], pix[:, None]
    if name == "stripes":
        a, period, ph = rng.uniform(0, np.pi), rng.uniform(3, 6), rng.uniform(0, 2 * np.pi)
        return 0.5 + 0.5 * np.sin(2 * np.pi * (xs * np.cos(a) + ys * np.sin(a)) / period + ph)
    if name == "checker":
        a, cell = rng.uniform(0, np.pi), rng.uniform(2.5, 5)
        p1 = np.sin(np.pi * (xs * np.cos(a) + ys * np.sin(a)) / cell + rng.uniform(0, 6.3))
        p2 = np.sin(np.pi * (-xs * np.sin(a) + ys * np.cos(a)) / cell + rng.uniform(0, 6.3))
        return np.clip(0.5 + 2.0 * p1 * p2, 0, 1)
    if name == "dots":
        p = np.zeros((S, S))
        for _ in range(rng.integers(8, 17)):
            cx, cy, r = rng.uniform(0, S), rng.uniform(0, S), rng.uniform(1.0, 1.8)
            p = np.maximum(p, np.clip(0.5 + r - np.hypot(xs - cx, ys - cy), 0, 1))
        return p
    if name == "clouds":
        k = np.fft.fftfreq(S)
        kk = np.hypot(k[None, :], k[:, None])
        spec = (rng.normal(size=(S, S)) + 1j * rng.normal(size=(S, S))) * np.exp(-(kk / 0.08) ** 2)
        p = np.real(np.fft.ifft2(spec))
        p = (p - p.mean()) / (p.std() + 1e-9)
        return np.clip(0.5 + 0.3 * p, 0, 1)
    if name == "lines":
        p = np.zeros((S, S))
        for _ in range(rng.integers(4, 8)):
            a, c0 = rng.uniform(0, np.pi), rng.uniform(0, S)
            dist = np.abs((xs - S / 2) * np.sin(a) - (ys - S / 2) * np.cos(a) - (c0 - S / 2))
            p = np.maximum(p, np.clip(1.0 - dist / 0.9, 0, 1))
        return p
    raise KeyError(name)


def make_textures(n_per_class, rng, size=SIZE, noise=0.08, classes=None):
    """
    每类 n_per_class 张的纹理数据集（"不太相关"的目标任务）。

    【思路】图案 p 在两种随机颜色之间插值：像素 = c1 · (1 − p) + c2 · p，再加噪声。
            两种颜色之间的距离至少 0.3（和形状世界一样，避免"看不见"）。
    【参数】n_per_class：每类张数；rng：随机数生成器；size：边长；noise：噪声标准差；
            classes：纹理名列表，默认 TEXTURE_CLASSES
    【返回】(X, y)：X (m, 3, S, S) float32，y (m,) int64
    【边界】纹理是逐张用 Python 循环画的（每张图的参数个数不同），所以比形状慢；几千张也只要零点几秒。
    """
    classes = TEXTURE_CLASSES if classes is None else classes
    y = np.repeat(np.arange(len(classes)), n_per_class)
    y = y[rng.permutation(len(y))]
    m = len(y)
    c1 = rng.random((m, 3)) * 0.8 + 0.1
    c2 = np.clip(_random_colors(m, rng, c1) * 0.8 + 0.1, 0, 1)
    X = np.empty((m, 3, size, size))
    for i in range(m):
        p = _texture_pattern(classes[y[i]], rng, size)
        X[i] = c1[i, :, None, None] * (1 - p) + c2[i, :, None, None] * p
    X = X + rng.normal(0.0, noise, X.shape)
    return X.astype(np.float32), y.astype(np.int64)


# =============================================================================
# 4. 标准化与显示
# =============================================================================
def channel_stats(X):
    """
    按通道算均值和标准差（对样本、高、宽三个维度求）。

    【参数】X：(m, C, H, W)
    【返回】(mean, std)：各是 (C,) 的 float32 数组
    【DL 联系】迁移学习时，目标任务的图要用**预训练时**的 μ、σ 标准化（预训练的权重是在那种尺度下学出来的）。
            用 ImageNet 预训练的模型，标准做法就是减去 ImageNet 的通道均值、除以通道标准差。
    """
    return X.mean(axis=(0, 2, 3)).astype(np.float32), X.std(axis=(0, 2, 3)).astype(np.float32)


def normalize(X, mean, std):
    """(X − μ) / σ，按通道广播。X：(m, C, H, W)；mean、std：(C,)。返回同形状的 float32 数组。"""
    return ((X - mean[None, :, None, None]) / std[None, :, None, None]).astype(np.float32)


def ascii_image(img, chars=" .:-=+*#%@"):
    """
    把一张彩色图按亮度画成字符画。

    【参数】img：(3, H, W)；chars：从暗到亮的字符表
    【返回】list[str]，每行一个字符串
    【思路】亮度 = 0.299 R + 0.587 G + 0.114 B（常用的人眼亮度公式），每张图按自己的最小 / 最大值缩放。
            注意：形状可能比背景暗，字符画里看起来就是"空心"的。
    """
    lum = 0.299 * img[0] + 0.587 * img[1] + 0.114 * img[2]
    lo, hi = float(lum.min()), float(lum.max())
    n = len(chars)
    idx = np.clip(((lum - lo) / (hi - lo + 1e-12) * n).astype(int), 0, n - 1)
    return ["".join(chars[k] for k in row) for row in idx]


def print_gallery(X, labels, per_row=6):
    """
    把若干张图的字符画并排打印（每行 per_row 张），每张图上方印它的标签。

    【参数】X：(k, 3, H, W)；labels：长度 k 的字符串列表；per_row：每行几张
    """
    for start in range(0, len(X), per_row):
        block = X[start:start + per_row]
        names = labels[start:start + per_row]
        W = block.shape[-1]
        print("   ".join(f"{n:<{W}}"[:W] for n in names))
        arts = [ascii_image(img) for img in block]
        for row in range(block.shape[-2]):
            print("   ".join(a[row] for a in arts))
        print()


def clean_masks(names, size=SIZE):
    """
    只为了"看清楚形状长什么样"：黑底白形状、居中、不旋转、没有噪声。返回 (k, 3, S, S)（三个通道相同）。

    【参数】names：形状名列表；size：边长
    【边界】训练用的图都来自 render_shapes（有颜色、旋转、噪声），这个函数只用于显示。
    """
    pix = np.arange(size) + 0.5
    R = 0.3 * size
    u, v = (pix[None, :] - size / 2) / R, (pix[:, None] - size / 2) / R
    out = [np.clip(0.5 + shape_sdf(n, u, v) * R, 0.0, 1.0) for n in names]
    return np.stack([np.stack([a, a, a]) for a in out])


if __name__ == "__main__":
    for title, classes in [("源任务 SOURCE_CLASSES", SOURCE_CLASSES), ("相关目标 TARGET_CLASSES", TARGET_CLASSES),
                           ("箭头 ARROW_CLASSES", ARROW_CLASSES)]:
        print(f"== {title}（{len(classes)} 类）：干净版（黑底白形状，只为看清楚）")
        print_gallery(clean_masks(classes), classes)
    rng = np.random.default_rng(0)
    X = render_shapes(SOURCE_CLASSES[:6], rng)
    print("== 真正用来训练的图（有颜色、旋转、渐变、噪声、干扰圆点；按亮度画成字符画，所以看起来乱一些）")
    print_gallery(X, SOURCE_CLASSES[:6])
    X, y = make_textures(1, np.random.default_rng(3), noise=0.0)
    order = np.argsort(y)
    print(f"== 纹理 TEXTURE_CLASSES（{len(TEXTURE_CLASSES)} 类），每类一张（这里没加噪声）")
    print_gallery(X[order], TEXTURE_CLASSES)
    X, y = make_shapes(100, SOURCE_CLASSES, np.random.default_rng(1))
    mu, sd = channel_stats(X)
    print(f"make_shapes(100, SOURCE_CLASSES)：X {X.shape}，y {y.shape}，每类 {np.bincount(y).tolist()[:4]}… 张；"
          f"像素范围 [{X.min():.2f}, {X.max():.2f}]；通道均值 ({', '.join(f'{v:.3f}' for v in mu)})，"
          f"标准差 ({', '.join(f'{v:.3f}' for v in sd)})")
