"""
=============================================================================
CS230 · 第 11 讲工具模块：一个合成的"工厂质检"世界（贯穿本讲的迷你项目）
=============================================================================

对应讲义：lectures/lecture11_full_cycle_dl_project.md（§3–§8 的所有代码都用这个世界）

-----------------------------------------------------------------------------
【为什么需要一个新世界】
-----------------------------------------------------------------------------
本讲要把一个深度学习项目从头走到尾：采数据 → baseline → 调试 → 误差分析 → 迭代 → 部署 → 监控。
要让每一步的"坑"都能被看见，我们需要一个**知道真相**的世界：每张图是怎么生成的、缺陷在哪里、
有多淡、在什么光照下拍的，全部记在 meta 里。真实项目里这些信息要靠人去看、去标；在这里它们是免费的，
所以我们能精确地回答"模型到底错在哪一类图上"。

-----------------------------------------------------------------------------
【一张图是怎么画出来的】（16×16 灰度，像素值在 [0, 1]）
-----------------------------------------------------------------------------
产品是一块拉丝金属板，相机从正上方拍一个 16×16 的小区域：
    · 底色：亮度 b（每张图随机，由"在哪个工位、什么灯光下拍"决定）；
    · 拉丝纹理：每一行加一个小的随机偏移（标准差 0.015），看上去是一条条横纹；
    · 无害的干扰（和类别无关，正常品和缺陷品上都可能有）：
        灰尘：一半的图上有 1–3 粒，每粒是一个像素大小的亮点（幅度 0.06–0.18）；
        水渍 / 反光斑：30% 的图上有一块，半径 2.5–4 像素、幅度 ±0.06–0.14 的很淡的明暗变化；
    · 噪声：每个像素加标准差 noise（默认 0.02）的高斯噪声。
然后按类别画缺陷（缺陷中心 (cx, cy) 在中间 9×9 的区域里随机 —— 相机对准零件上容易出问题的部位 —— 所以位置不固定）：
    0 正常（OK）    ：什么都不画；
    1 划痕（scratch）：一条**亮**的细线段（金属被划开后反光），长 5–10 像素、角度随机；
    2 污点（spot）   ：一个**暗**的圆斑（油污），半径 0.8–1.8 像素；
    3 裂纹（crack）  ：一条**暗**的细线，中间再分出一个小叉。**训练数据里没有这一类**，
                       只在第 4 个脚本"上线后出现新的缺陷类型"时出现（它算缺陷，但不是 0/1/2 里的任何一类）。
缺陷的对比度 c 在 defect_contrast 范围内均匀随机；实际画上去的幅度是 c × (b / 0.5)：
光线越暗，缺陷越不明显（这是真实相机的样子）。

-----------------------------------------------------------------------------
【可以调的"世界旋钮"】（第 1、4 个脚本用它们制造问题）
-----------------------------------------------------------------------------
    brightness      ：底色亮度 b 的范围。"产线工位"默认 (0.44, 0.56)；"质检实验室"更亮 (0.56, 0.66)。
    gain, offset    ：相机的增益和偏置：最终像素 = gain × 像素 + offset（换了一台相机）。
    gradient        ：从左到右的光照渐变幅度（换了灯的位置）。
    noise           ：像素噪声的标准差（相机老化 / 换成便宜的传感器）。
    blur、rotate90  ：镜头模糊；零件转 90° 摆放（拉丝纹理变成竖的，代码 03 的 2 号产线）。
    p_dust、p_stain ：带灰尘、带水渍的图的比例。
    p_class         ：各类的比例。采数据时可以故意多采缺陷；产线上缺陷很少（例如 90% 正常）。
    defect_contrast ：缺陷对比度 c 的范围（"淡的缺陷"多不多）。
    p_crack         ：裂纹（新缺陷类型）在**缺陷**里的比例。

【诚实的说明】这些数字（亮度范围、噪声 0.02、对比度 0.10–0.40、灰尘和水渍的强度、缺陷只在中间 9×9、
    两个工位亮度只差约 0.1……）是我先在草稿里生成了几批图、训练了几个小网络之后定下来的：
    · 第一版（噪声 0.04、对比度 0.06–0.30）太难：全连接网络在 6000 张图上只有约 0.69；
    · 第二版（噪声 0.02、没有灰尘和水渍）太容易被规则解决："减去每行中位数、看最突出的一点"这条两个参数的规则
      就有约 0.985，网络没有存在的理由；
    · 最终版加了无害的灰尘和水渍（规则分不清它们和缺陷的形状），并把缺陷限制在中间 9×9。
    目标是：(1) 一条手写规则是强 baseline，全连接网络要几千张图才追上它；(2) 网络在几千张图上做到 0.8–0.9，
    留出"误差分析 → 改进"的空间。具体数值不代表任何真实产线。

-----------------------------------------------------------------------------
【形状约定】（PyTorch 习惯，每一行一个样本，第 9 讲 §4.3）
-----------------------------------------------------------------------------
    make_images(...) 返回 dict：
        "X"    : (m, 16, 16) float32，像素值在 [0, 1]
        "y"    : (m,) int64，0 正常 / 1 划痕 / 2 污点（裂纹的 y 记为 3，只在漂移实验里出现）
        "meta" : dict，每个值都是长度 m 的数组：kind（同 y）、contrast（c）、size（划痕长度或污点半径）、
                 cx、cy（缺陷中心）、theta（线的角度）、edge（缺陷中心到最近边缘的距离）、light（底色亮度 b）、
                 n_dust（灰尘粒数）、stain（有没有水渍）、
                 station（在哪拍的：0 产线 1 号相机 / 1 质检实验室 / 2 产线 2 号相机）

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

直接运行本文件会打印每类一张图的字符画和几项基本统计：
    python3 factory_world.py          # < 1 秒
"""

import numpy as np

IMG = 16                                        # 图像边长
CLASS_NAMES = ["正常", "划痕", "污点"]           # 模型要分的三类（下标就是标签 y）
KIND_NAMES = ["正常", "划痕", "污点", "裂纹"]     # 世界里一共有的四种情况（裂纹只在漂移实验里出现）
LINE_BRIGHTNESS = (0.44, 0.56)                  # 产线工位的底色亮度范围
LAB_BRIGHTNESS = (0.56, 0.66)                   # 质检实验室（另一盏灯）的底色亮度范围

# 像素中心的坐标网格：XS[i, j] = j（列，横坐标），YS[i, j] = i（行，纵坐标），形状都是 (16, 16)
YS, XS = np.mgrid[0:IMG, 0:IMG].astype(np.float64)


# =============================================================================
# 1. 画缺陷的几个小函数（全部向量化：一次画 m 张图）
# =============================================================================
def _segment_distance(cx, cy, theta, length):
    """
    每个像素中心到一条线段的距离（一次算 m 条线段）。

    【思路】线段的两个端点 P0 = C − (L/2)·u，P1 = C + (L/2)·u，u = (cos θ, sin θ) 是方向。
            像素 Q 到线段的距离：先把 Q − P0 投影到 u 上得到 t，把 t 截到 [0, L]（落在线段外时取端点），
            再算 Q 到 P0 + t·u 的距离。
    【参数】cx, cy, theta, length：都是 (m,) 数组（线段中心、角度、长度）
    【返回】(m, 16, 16) 的距离
    【numpy 小知识】cx[:, None, None] 把 (m,) 变成 (m, 1, 1)，和 (16, 16) 的网格做运算时会**广播**成 (m, 16, 16)
            （第 2 讲 §8）：每张图用自己的 cx，每个像素用自己的坐标。
    """
    ux, uy = np.cos(theta)[:, None, None], np.sin(theta)[:, None, None]
    x0 = cx[:, None, None] - 0.5 * length[:, None, None] * ux
    y0 = cy[:, None, None] - 0.5 * length[:, None, None] * uy
    t = np.clip((XS - x0) * ux + (YS - y0) * uy, 0.0, length[:, None, None])
    return np.hypot(XS - (x0 + t * ux), YS - (y0 + t * uy))


def _line_mask(cx, cy, theta, length, width):
    """
    一条"有宽度"的线：离中线越近越接近 1，超过 width 就是 0。返回 (m, 16, 16)，值在 [0, 1]。

    【思路】1 − d / width 再截到 [0, 1]：一个像素宽的抗锯齿线段（斜着的线也不会断成一个个点）。
    """
    d = _segment_distance(cx, cy, theta, length)
    return np.clip(1.0 - d / width, 0.0, 1.0)


def _blob_mask(cx, cy, radius):
    """一个高斯圆斑：exp(−距离² / (2 r²))。返回 (m, 16, 16)，中心是 1，往外衰减。"""
    d2 = (XS - cx[:, None, None]) ** 2 + (YS - cy[:, None, None]) ** 2
    return np.exp(-d2 / (2.0 * radius[:, None, None] ** 2))


# =============================================================================
# 2. 生成一批图
# =============================================================================
def make_images(m, rng, p_class=(1 / 3, 1 / 3, 1 / 3), brightness=LINE_BRIGHTNESS, station=0,
                gain=1.0, offset=0.0, gradient=0.0, noise=0.02, defect_contrast=(0.10, 0.40), p_crack=0.0, blur=0.0, rotate90=False,
                p_dust=0.5, p_stain=0.3):
    """
    生成 m 张 16×16 的金属表面图。

    【思路】先画背景（底色 + 横向拉丝纹理 + 光照渐变），再按类别把缺陷"加"上去（划痕加亮、污点和裂纹变暗），
            然后加噪声、过相机（gain、offset），最后截到 [0, 1]。所有 m 张图一起算（向量化），不写 for 循环。
    【参数】
        m               : 图片张数
        rng             : np.random.Generator（每个演示用自己的，保证可复现）
        p_class         : (正常, 划痕, 污点) 的比例，三个数加起来是 1
        brightness      : 底色亮度 b 的范围 (lo, hi)，每张图在里面均匀随机
        station         : 记到 meta 里的拍摄地点编号（0 产线 1 号相机 / 1 质检实验室 / 2 产线 2 号相机），只是记录，不影响画法
        gain, offset    : 相机：像素 ← gain × 像素 + offset
        gradient        : 光照从左到右线性变化的幅度：最左边 −gradient/2，最右边 +gradient/2
        noise           : 每个像素的高斯噪声标准差
        defect_contrast : 缺陷对比度 c 的范围 (lo, hi)
        p_crack         : 缺陷图里有多大比例换成"裂纹"（新类型，y = 3）
        blur            : 镜头模糊（高斯模糊的标准差，单位像素；0 表示不模糊）。模糊发生在加噪声之前（先过镜头，再到传感器）
        rotate90        : True 时零件转了 90° 摆放：整张图转置，拉丝纹理从横的变成竖的（2 号产线就是这样，代码 03）
        p_dust          : 有多大比例的图带 1–3 粒**灰尘**（单个像素大小的亮点，无害，不算缺陷）
        p_stain         : 有多大比例的图带一块**水渍 / 反光斑**（半径 2.5–4 像素、很淡、可亮可暗，无害，不算缺陷）
    【返回】dict(X=(m,16,16) float32, y=(m,) int64, meta=dict of (m,) arrays)，见文件开头
    【边界】像素被截到 [0, 1]：增益很大或底色很亮时，亮的划痕会"顶到 1"，信息被截掉一部分（真实相机的过曝也是这样）。
    【numpy 小知识】rng.choice(3, size=m, p=p_class)：按概率 p_class 从 {0, 1, 2} 里抽 m 次。
            rng.uniform(lo, hi, size=m)：m 个 [lo, hi) 上的均匀随机数。
    【DL 联系】meta 里的信息相当于真实项目里"人工给错例打标签"（第 10 讲的误差分析表）：
            本讲的代码 03 用它统计"错在哪一类图上"。
    """
    kind = rng.choice(3, size=m, p=np.asarray(p_class, dtype=float) / np.sum(p_class))
    if p_crack > 0:                                             # 一部分缺陷换成新类型"裂纹"
        is_defect = kind > 0
        kind = np.where(is_defect & (rng.uniform(size=m) < p_crack), 3, kind)
    b = rng.uniform(brightness[0], brightness[1], size=m)
    c = rng.uniform(defect_contrast[0], defect_contrast[1], size=m)
    amp = c * (b / 0.5)                                         # 光线越暗，缺陷越淡
    cx = rng.uniform(3.0, IMG - 4.0, size=m)                    # 缺陷中心在中间 9×9 的区域里（相机对准零件上容易出问题的部位）
    cy = rng.uniform(3.0, IMG - 4.0, size=m)
    theta = rng.uniform(0.0, np.pi, size=m)
    length = rng.uniform(5.0, 10.0, size=m)                     # 划痕 / 裂纹的长度
    radius = rng.uniform(0.8, 1.8, size=m)                      # 污点的半径
    branch = rng.uniform(0.5, 1.0, size=m) * np.where(rng.uniform(size=m) < 0.5, -1.0, 1.0)  # 裂纹分叉的角度
    # 无害的干扰：灰尘（最多 3 粒）和水渍。它们和缺陷互相独立 —— 正常品和缺陷品上都可能有
    n_dust = np.where(rng.uniform(size=m) < p_dust, rng.integers(1, 4, size=m), 0)
    dust_xy = rng.uniform(0.5, IMG - 1.5, size=(m, 3, 2))
    dust_amp = rng.uniform(0.06, 0.18, size=(m, 3))
    has_stain = rng.uniform(size=m) < p_stain
    stain_xy = rng.uniform(2.0, IMG - 3.0, size=(m, 2))
    stain_r = rng.uniform(2.5, 4.0, size=m)
    stain_amp = rng.uniform(0.06, 0.14, size=m) * np.where(rng.uniform(size=m) < 0.5, -1.0, 1.0)

    # ---- 背景：底色 + 拉丝横纹 + 光照渐变 ----
    img = b[:, None, None] + rng.normal(0.0, 0.015, size=(m, IMG, 1))     # (m,16,1) 广播到每一列：同一行同一个偏移
    img = img + gradient * (XS / (IMG - 1) - 0.5)                           # 从左到右变亮（或变暗）

    # ---- 缺陷 ----
    scratch = _line_mask(cx, cy, theta, length, 0.9)                       # (m,16,16)
    spot = _blob_mask(cx, cy, radius)
    crack = np.maximum(_line_mask(cx, cy, theta, length, 0.75),
                       _line_mask(cx, cy, theta + branch, 0.5 * length, 0.75))
    defect = np.where((kind == 1)[:, None, None], scratch, 0.0) \
        - np.where((kind == 2)[:, None, None], spot, 0.0) \
        - np.where((kind == 3)[:, None, None], crack, 0.0)
    img = img + amp[:, None, None] * defect
    for j in range(3):                                                      # 第 j 粒灰尘（n_dust 不到 j + 1 的图上不画）
        speck = _blob_mask(dust_xy[:, j, 0], dust_xy[:, j, 1], np.full(m, 0.45))
        img = img + ((n_dust > j) * dust_amp[:, j] * (b / 0.5))[:, None, None] * speck
    stain = _blob_mask(stain_xy[:, 0], stain_xy[:, 1], stain_r)
    img = img + (has_stain * stain_amp)[:, None, None] * stain
    if blur > 0:                                                            # 镜头没对好焦：细线变宽、变淡
        img = gaussian_blur(img, blur)

    # ---- 噪声与相机 ----
    img = img + rng.normal(0.0, noise, size=(m, IMG, IMG))
    img = np.clip(gain * img + offset, 0.0, 1.0)
    if rotate90:
        img = img.transpose(0, 2, 1)                       # 行列互换：横纹变竖纹（缺陷的方向本来就随机，不受影响）
        cx, cy, theta = cy, cx, np.pi / 2 - theta

    size = np.where(kind == 2, radius, np.where(kind == 0, 0.0, length))
    edge = np.minimum(np.minimum(cx, cy), np.minimum(IMG - 1 - cx, IMG - 1 - cy))
    meta = {"kind": kind.astype(np.int64), "contrast": np.where(kind == 0, 0.0, c), "size": size,
            "cx": cx, "cy": cy, "theta": np.where(kind == 0, np.nan, theta),
            "edge": np.where(kind == 0, np.nan, edge), "light": b, "n_dust": n_dust, "stain": has_stain,
            "station": np.full(m, station, dtype=np.int64)}
    return {"X": img.astype(np.float32), "y": kind.astype(np.int64), "meta": meta}


def gaussian_blur(img, sigma):
    """
    对一批图做高斯模糊（先沿行、再沿列各做一次一维卷积，"可分离"卷积）。

    【思路】二维高斯核 = 两个一维高斯核的外积，所以先横着模糊、再竖着模糊，结果和直接用二维核一样，计算量小得多。
            图像边缘用"复制边缘像素"的方式补边（np.pad 的 mode="edge"），这样模糊后边缘不会变暗。
    【参数】img：(m, 16, 16)；sigma：高斯核的标准差（像素）
    【返回】(m, 16, 16)
    【DL 联系】这就是一次卷积（convolution）：同一个小核在整张图上滑动。第 12 讲会从零讲卷积。
    """
    r = int(np.ceil(2.5 * sigma))
    k = np.exp(-0.5 * (np.arange(-r, r + 1) / sigma) ** 2)
    k = k / k.sum()                                                   # 核的元素加起来为 1：模糊不改变平均亮度
    p = np.pad(img, ((0, 0), (r, r), (r, r)), mode="edge")
    rows = sum(k[i] * p[:, :, i:i + IMG] for i in range(2 * r + 1))           # 横向：(m, 16 + 2r, 16)
    return sum(k[i] * rows[:, i:i + IMG, :] for i in range(2 * r + 1))        # 纵向：(m, 16, 16)


def concat(*parts):
    """
    把几批 make_images 的结果拼成一批（X、y、meta 的每个数组都沿第 0 维拼起来）。

    【参数】parts：若干个 make_images 返回的 dict
    【返回】同样格式的一个 dict
    【Python 小知识】*parts 收集任意多个位置参数，parts 是一个元组；{k: ... for k in d} 是字典推导式。
    """
    return {"X": np.concatenate([p["X"] for p in parts]),
            "y": np.concatenate([p["y"] for p in parts]),
            "meta": {k: np.concatenate([p["meta"][k] for p in parts]) for k in parts[0]["meta"]}}


def add_label_noise(data, rate, rng):
    """
    模拟标注错误：随机挑 rate 比例的样本，把标签改成另外两类中的一个（真实标签保存在 meta["true_y"]）。

    【思路】真实项目里标签由人标，总有一点错（看漏、手滑、标准不一）。这里把"错了哪些"记下来，
            误差分析时就能数出"有多少个模型的错误其实是标签的错误"（第 10 讲：错误标签什么时候值得清理）。
    【参数】data：make_images 格式的 dict；rate：出错比例（例如 0.02）；rng：随机数生成器
    【返回】新的 dict（不修改原来的），y 是带噪声的标签，meta 多了 "true_y" 和 "noisy"（布尔）
    【边界】只对 0/1/2 三类操作；改错时在另外两类里等概率选一个。
    """
    y = data["y"].copy()
    flip = rng.uniform(size=len(y)) < rate
    shift = rng.integers(1, 3, size=len(y))                    # 加 1 或 2 再对 3 取余：一定变成另外两类之一
    y = np.where(flip, (y + shift) % 3, y)
    meta = dict(data["meta"])
    meta["true_y"], meta["noisy"] = data["y"].copy(), flip
    return {"X": data["X"], "y": y, "meta": meta}


def subset(data, idx):
    """按下标取一部分样本（X、y、meta 一起取，保证它们始终对齐）。idx 可以是整数数组或布尔数组。"""
    return {"X": data["X"][idx], "y": data["y"][idx], "meta": {k: v[idx] for k, v in data["meta"].items()}}


def shuffled(data, rng):
    """把一批数据打乱顺序（X、y、meta 用**同一个**排列——代码 02 会演示只打乱 X 的后果）。"""
    return subset(data, rng.permutation(len(data["y"])))


# =============================================================================
# 3. 两种"采数据的方式"（代码 01 的主角）
# =============================================================================
def collect_biased(m, rng):
    """
    "图省事"的采法：正常品在产线工位拍；缺陷品是从废品箱里挑出来、拿到**质检实验室**（另一盏更亮的灯）拍的。

    【思路】每张图本身都是"真"的，标签也全对；问题出在**采集过程**让"亮度"和"有没有缺陷"绑在了一起。
            模型完全可以只看亮度就在这批数据上拿高分 —— 而产线上所有零件都在同一个工位拍，亮度不再说明任何事。
    【参数】m：张数（三类大致各三分之一）；rng：随机数生成器
    【返回】make_images 格式的 dict（meta["station"] 记录了每张图在哪拍的）
    【DL 联系】这类"捷径"（shortcut）在医疗影像里真实发生过：模型学会了认医院、认便携式 X 光机，而不是认病（讲义 §9）。
    """
    n_ok = m // 3
    ok = make_images(n_ok, rng, p_class=(1, 0, 0), brightness=LINE_BRIGHTNESS, station=0)
    bad = make_images(m - n_ok, rng, p_class=(0, 0.5, 0.5), brightness=LAB_BRIGHTNESS, station=1)
    return shuffled(concat(ok, bad), rng)


def collect_line(m, rng, p_class=(1 / 3, 1 / 3, 1 / 3), **world):
    """
    "正确"的采法：所有零件都在产线工位、同一个相机下拍（缺陷品比例可以故意调高，方便采够缺陷）。

    【参数】m：张数；rng；p_class：各类比例（采训练数据时常用均衡的比例；模拟产线时用真实比例，比如 0.9 / 0.05 / 0.05）；
            **world：其余传给 make_images 的世界旋钮（gain、gradient、brightness……；没给 brightness 时用产线的灯）
    【返回】make_images 格式的 dict
    【Python 小知识】**world 把"多余的关键字参数"收集成一个字典，再用 **world 原样传下去。
            dict.setdefault(键, 值)：字典里没有这个键时才放进去（调用者给了就不覆盖）。
    """
    world.setdefault("brightness", LINE_BRIGHTNESS)           # 没指定亮度时用产线工位的灯
    world.setdefault("station", 0)
    return make_images(m, rng, p_class=p_class, **world)


# =============================================================================
# 4. 小工具
# =============================================================================
def flat(X):
    """(m, 16, 16) → (m, 256)：全连接网络要求每个样本是一个向量（第 1 讲 §3.4）。"""
    return X.reshape(len(X), -1)


def is_defect(y):
    """多类标签 → 二类"是不是缺陷"：0 正常 → False，1 / 2 / 3 → True。产线真正关心的是"放行还是拦下"。"""
    return np.asarray(y) > 0


def ascii_patch(img, lo=None, hi=None, chars=" .:-=+*#%@"):
    """
    把一张图画成字符画，越亮的像素用越"浓"的字符。

    【思路】两种映射：
            · 默认（lo、hi 不给）：以这张图的中位数为中心，上下各 0.12 —— 看清**形状**（划痕、污点、灰尘）；
            · 给定 lo、hi：所有图用同一个亮度范围 —— 看清**两张图之间的亮度差**（比如两个工位的灯）。
            第 9 讲 multiclass_data.ascii_image 是"每张图按自己的最小 / 最大值缩放"，会把亮度差抹掉。
    【参数】img：(16, 16)；lo、hi：映射到最暗、最亮字符的像素值
    【返回】list[str]，16 个字符串，每个长 16
    """
    img = np.asarray(img, dtype=np.float64)
    if lo is None or hi is None:
        mid = float(np.median(img))
        lo, hi = mid - 0.12, mid + 0.12
    n = len(chars)
    idx = np.clip(((img - lo) / (hi - lo) * n).astype(int), 0, n - 1)
    return ["".join(chars[v] for v in row) for row in idx]


def example_per_kind(data, kinds=(0, 1, 2)):
    """每种 kind 挑第一张（按对比度最高的一张更容易看清，这里挑对比度在中间以上的第一张）。返回下标列表。"""
    out = []
    for k in kinds:
        cand = np.flatnonzero((data["meta"]["kind"] == k) & ((data["meta"]["contrast"] >= 0.18) | (k == 0)))
        out.append(int(cand[0]) if len(cand) else int(np.flatnonzero(data["meta"]["kind"] == k)[0]))
    return out


if __name__ == "__main__":
    import os
    import sys
    sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lecture01"))
    from console import pad
    rng = np.random.default_rng(0)
    d = make_images(3000, rng, p_crack=0.2)
    idx = example_per_kind(d, kinds=(0, 1, 2, 3))
    panels = [ascii_patch(d["X"][i]) for i in idx]
    print("每种情况一张（以每张图的中位数为中心 ±0.12 映射到字符）：")
    print("   ".join(pad(f"{KIND_NAMES[k]} c={d['meta']['contrast'][i]:.2f}", IMG) for k, i in zip((0, 1, 2, 3), idx)))
    for r in range(IMG):
        print("   ".join(p[r] for p in panels))
    print(f"\n各情况张数：{np.bincount(d['y'], minlength=4).tolist()}（正常 / 划痕 / 污点 / 裂纹）")
    print(f"像素均值 {d['X'].mean():.3f}，标准差 {d['X'].std():.3f}；被截到 0 或 1 的像素比例 "
          f"{np.mean((d['X'] <= 0) | (d['X'] >= 1)):.4f}；带灰尘的图 {np.mean(d['meta']['n_dust'] > 0):.3f}，"
          f"带水渍的图 {np.mean(d['meta']['stain']):.3f}")
    biased = collect_biased(600, np.random.default_rng(1))
    mean_b = biased["X"].reshape(600, -1).mean(axis=1)
    print(f"「图省事」的采法：正常品平均亮度 {mean_b[biased['y'] == 0].mean():.3f}，缺陷品 {mean_b[biased['y'] > 0].mean():.3f}")
