"""
=============================================================================
CS230 · 第 2 讲共享模块：合成的"小图像"二分类数据集（橙色十字 vs 其他）
=============================================================================

对应讲义：lectures/lecture02_logistic_regression_nn_mindset.md §1、§9

-----------------------------------------------------------------------------
【为什么要自己造一个图像数据集】
-----------------------------------------------------------------------------
Coursera 编程作业 "Logistic Regression with a Neural Network Mindset" 用的是"猫 / 非猫"数据集：
209 张训练图、50 张测试图，每张 64×64 的 RGB 照片。那个数据集不在这里，而且逻辑回归在上面学到的
"模板"很难看出是什么（真实的猫姿态、颜色、位置千变万化）。所以我们造一个**同样形状的问题**，但
"真相"由我们决定，这样就能检查逻辑回归到底学到了什么：

    每张图：16×16 像素、3 个通道（RGB），像素值 0–255 的整数 —— 和真实照片的格式一样，只是小得多
    正类 y = 1：图里有一个**橙色的十字**（plus 形状，7×7 的框，笔画 3 像素宽）
    负类 y = 0：三种"干扰项"各占约 1/3
        · 橙色方块      —— 颜色一样、形状不一样（模型必须看形状）
        · 其他颜色的十字 —— 形状一样、颜色不一样（黄 / 绿 / 蓝 / 紫；模型必须看颜色）
        · 只有背景      —— 什么都没有
    背景：每张图一个随机底色 + 随机的明暗渐变 + 每个像素的随机噪声
    位置：图案的位置有小幅随机抖动（默认上下左右各 ±1 像素，可以用 jitter 参数调）

【为什么这样设计 —— 让"线性模板"能学到有意义的东西，但又不至于太容易】
    逻辑回归的打分是 z = wᵀx + b：给每个像素的每个通道一个权重，加权求和。所以它学到的 w 可以
    还原成一张 16×16×3 的"模板图"，打分 = 图像和模板"有多像"。
        · 要区分"橙色十字"和"其他颜色的十字"，模板要在十字的位置上偏爱橙色（R 高、B 低）；
        · 要区分"橙色十字"和"橙色方块"，模板要在十字的位置给正权重、在方块多出来的四个角给负权重。
          注意：方块的橙色像素（49 个）比十字（33 个）多，所以"橙色越多越像正类"这种简单规则行不通，
          模型必须用到**空间结构**。
        · 位置抖动越大，固定位置的模板越难对上 —— 第 2 讲代码 04 演示 4 会看到这一点
          （这也是第 12 讲卷积网络要解决的问题：平移不变性）。
    这些参数（图案大小、颜色、噪声强度、抖动 ±1）是我先在草稿里试了几组值之后选的，目的是让
    "模板"清楚可见、准确率不是 100% 也不是瞎猜。这是教学设定，不代表任何真实任务。

-----------------------------------------------------------------------------
【记号约定】（全课统一，见第 1 讲 §0.4）
-----------------------------------------------------------------------------
    · 原始图像 X_orig 的形状是 (m, 高, 宽, 通道) = (m, 16, 16, 3)，和 Coursera 作业里的 train_set_x_orig 一样；
    · 拉平之后 X 的形状是 (n_x, m) = (768, m)：**每一列是一张图**；
    · 标签 Y 的形状是 (1, m)。

【注释标签】
    【思路】为什么这样写   【参数】【返回】输入输出（含形状）   【边界】什么时候会出问题
    【DL 联系】和深度学习的哪个概念有关、后面哪一讲深入
    【numpy 小知识】/【Python 小知识】第一次出现的写法

直接运行本文件会打印数据集的基本统计和几张示例图的字符画（< 1 秒）：
    python3 image_data.py
"""

import numpy as np

IMG_SIZE = 16                          # 图像边长（像素）
N_CHANNELS = 3                         # RGB 三个通道
N_X = IMG_SIZE * IMG_SIZE * N_CHANNELS # 拉平后的特征数 n_x = 768
SHAPE_SIZE = 7                         # 图案外框的边长（像素）
BASE_TOP_LEFT = 4                      # 不抖动时图案左上角的位置：(4, 4)，图案正好在中间（4..10）

# 图案的颜色（R, G, B），0–255。正类只用 orange；其他四种颜色只出现在"其他颜色的十字"里。
COLORS = {
    "orange": (235, 125, 35),
    "yellow": (225, 205, 50),
    "green": (70, 170, 70),
    "blue": (60, 100, 215),
    "purple": (150, 70, 190),
}
OTHER_COLORS = ["yellow", "green", "blue", "purple"]

# 每张图的"种类"（kind）。只有 plus 是正类。
KINDS = ["plus", "orange_square", "other_plus", "empty"]
KIND_NAMES = {
    "plus": "橙色十字（正类）",
    "orange_square": "橙色方块",
    "other_plus": "其他颜色的十字",
    "empty": "只有背景",
}
CLASSES = ["非目标 (y=0)", "橙色十字 (y=1)"]


# =============================================================================
# 1. 图案的形状：用布尔"蒙版"（mask）表示哪些像素属于图案
# =============================================================================
def plus_mask(size=SHAPE_SIZE, thickness=3):
    """
    十字形的蒙版：size×size 的布尔矩阵，十字上的像素为 True。

    【思路】先画一条横杠（中间 thickness 行全为 True），再画一条竖杠（中间 thickness 列全为 True），
            两者"或"起来就是十字。size = 7、thickness = 3 时十字有 7·3 + 7·3 − 3·3 = 33 个像素。
    【参数】size：外框边长；thickness：笔画宽度（应和 size 同奇偶，十字才对称）
    【返回】(size, size) 的 bool 数组
    【numpy 小知识】np.zeros((7, 7), dtype=bool) 生成全 False 的布尔矩阵；
            m[s:s + t, :] = True 把第 s 到 s+t−1 行全部设为 True（切片包含起点、不包含终点）。
    """
    m = np.zeros((size, size), dtype=bool)
    s = (size - thickness) // 2            # // 是整除：(7 − 3) // 2 = 2
    m[s:s + thickness, :] = True           # 横杠
    m[:, s:s + thickness] = True           # 竖杠
    return m


def square_mask(size=SHAPE_SIZE):
    """实心方块的蒙版：size×size 全为 True（49 个像素）。比十字多出四个 2×2 的"角"。"""
    return np.ones((size, size), dtype=bool)


# =============================================================================
# 2. 生成一张图
# =============================================================================
def draw_background(rng):
    """
    随机背景：底色 + 线性明暗渐变 + 像素噪声。

    【思路】真实照片的背景千差万别。这里用三样东西模拟"背景在变"：
            · 底色：每个通道在 [30, 200] 里均匀随机 —— 有时候背景本身就偏橙，会让任务变难；
            · 渐变：亮度沿行、列方向线性变化（每个通道单独的斜率，每像素最多 ±3），像光照不均；
            · 噪声：每个像素每个通道加 N(0, 20²) 的随机数，像传感器噪声。
    【参数】rng：np.random.Generator
    【返回】(16, 16, 3) 的浮点数组（还没裁剪到 0–255）
    【numpy 小知识】np.arange(16)[:, None, None] 的形状是 (16, 1, 1)，np.arange(16)[None, :, None] 是 (1, 16, 1)，
            它们和形状 (3,) 的斜率相乘、相加时会**广播**成 (16, 16, 3)：每个像素 (r, c) 的每个通道都得到
            "底色 + 行斜率·r + 列斜率·c"。广播的规则见讲义 §8。
    """
    base = rng.uniform(30, 200, N_CHANNELS)                       # (3,)
    slope_r = rng.uniform(-3, 3, N_CHANNELS)                      # (3,)
    slope_c = rng.uniform(-3, 3, N_CHANNELS)                      # (3,)
    r = np.arange(IMG_SIZE)[:, None, None] - (IMG_SIZE - 1) / 2   # (16, 1, 1)，以图像中心为 0
    c = np.arange(IMG_SIZE)[None, :, None] - (IMG_SIZE - 1) / 2   # (1, 16, 1)
    img = base + slope_r * r + slope_c * c                        # 广播成 (16, 16, 3)
    return img + rng.normal(0, 20, (IMG_SIZE, IMG_SIZE, N_CHANNELS))


def draw_shape(img, mask, color_name, rng, jitter):
    """
    把一个图案"画"到背景上（原地修改 img）。

    【思路】图案左上角 = (4, 4) + 随机偏移，偏移在 [−jitter, jitter] 里均匀取整数（行、列各自独立）。
            图案颜色 = 基准颜色 + 每张图一个 N(0, 12²) 的色偏（同一种橙色在不同照片里也不完全一样）
            + 每个像素 N(0, 20²) 的噪声。
    【参数】
        img        : (16, 16, 3) 浮点数组，会被原地修改
        mask       : (7, 7) 布尔蒙版
        color_name : COLORS 里的颜色名
        rng        : 随机数生成器
        jitter     : 位置抖动幅度（像素），0 ≤ jitter ≤ 4（再大图案会超出图像边界）
    【返回】(top, left)：图案左上角的位置（方便调试）
    【边界】jitter > 4 时抛出 ValueError。
    【numpy 小知识】
        rng.integers(lo, hi)：[lo, hi) 里的随机整数（不含 hi），所以写 jitter + 1。
        sub = img[top:top+7, left:left+7] 是 img 的一个"视图"（view）：修改 sub 就是修改 img 的那一块。
        sub[mask] = ...：布尔索引，只修改 mask 为 True 的那些像素；sub[mask] 的形状是 (像素个数, 3)。
    """
    if not 0 <= jitter <= 4:
        raise ValueError("jitter must be between 0 and 4 for a 7x7 shape in a 16x16 image")
    top = BASE_TOP_LEFT + int(rng.integers(-jitter, jitter + 1))
    left = BASE_TOP_LEFT + int(rng.integers(-jitter, jitter + 1))
    color = np.array(COLORS[color_name], dtype=float) + rng.normal(0, 12, N_CHANNELS)
    sub = img[top:top + SHAPE_SIZE, left:left + SHAPE_SIZE]
    n_pix = int(mask.sum())
    sub[mask] = color + rng.normal(0, 20, (n_pix, N_CHANNELS))
    return top, left


def make_images(m, rng, jitter=1, pos_frac=0.5):
    """
    生成 m 张图和它们的标签。

    【思路】每张图先抽"是不是正类"（概率 pos_frac），负类再在三种干扰项里均匀抽一种。
            最后把像素裁剪到 [0, 255] 并四舍五入成 uint8 整数，和真实照片的存储格式一样。
    【参数】
        m        : 图片张数
        rng      : 随机数生成器
        jitter   : 位置抖动幅度（像素）
        pos_frac : 正类比例
    【返回】
        X_orig : (m, 16, 16, 3) uint8 数组 —— 和 Coursera 作业里 train_set_x_orig 的格式相同（样本在第 0 维）
        Y      : (1, m) int 数组，0 或 1
        kinds  : (m,) 字符串数组，每张图的种类（KINDS 里的一个），只用于分析"错在哪类图上"，不给模型看
        colors : (m,) 字符串数组，图案的颜色名（「只有背景」的图是空字符串），同样只用于分析
    【numpy 小知识】np.clip(a, 0, 255) 把超出范围的值截断；np.round 四舍五入；.astype(np.uint8) 转成 0–255 的整数。
    """
    X_orig = np.zeros((m, IMG_SIZE, IMG_SIZE, N_CHANNELS), dtype=np.uint8)
    Y = np.zeros((1, m), dtype=int)
    kinds, colors = [], []
    for i in range(m):
        img = draw_background(rng)
        color = ""
        if rng.random() < pos_frac:
            kind, color = "plus", "orange"
            draw_shape(img, plus_mask(), color, rng, jitter)
        else:
            kind = KINDS[1 + int(rng.integers(3))]          # 在三种负类里均匀选一种
            if kind == "orange_square":
                color = "orange"
                draw_shape(img, square_mask(), color, rng, jitter)
            elif kind == "other_plus":
                color = OTHER_COLORS[int(rng.integers(len(OTHER_COLORS)))]
                draw_shape(img, plus_mask(), color, rng, jitter)
            # kind == "empty"：什么都不画
        X_orig[i] = np.round(np.clip(img, 0, 255)).astype(np.uint8)
        Y[0, i] = 1 if kind == "plus" else 0
        kinds.append(kind)
        colors.append(color)
    return X_orig, Y, np.array(kinds), np.array(colors)


def load_dataset(jitter=1, m_train=1000, m_test=500, seed=0):
    """
    生成训练集和测试集（接口模仿 Coursera 作业的 load_dataset，只是返回一个 dict）。

    【参数】
        jitter          : 位置抖动幅度
        m_train, m_test : 训练集、测试集的大小
        seed            : 种子。训练集用 default_rng(seed)，测试集用 default_rng(seed + 1000)：
                          两个集合来自同一个分布，但图片互不相同。
    【返回】dict：
        "train_x_orig" (m_train, 16, 16, 3) uint8，"train_y" (1, m_train)，"train_kind" (m_train,)，"train_color" (m_train,)
        "test_x_orig"、"test_y"、"test_kind"、"test_color" 同理；"classes"：两个类别的名字
    【DL 联系】训练集和测试集必须来自同一个分布、但不能有重复的样本 —— 第 6 讲（数据划分）、第 10 讲（数据不匹配）。
    """
    tr_x, tr_y, tr_k, tr_c = make_images(m_train, np.random.default_rng(seed), jitter)
    te_x, te_y, te_k, te_c = make_images(m_test, np.random.default_rng(seed + 1000), jitter)
    return {"train_x_orig": tr_x, "train_y": tr_y, "train_kind": tr_k, "train_color": tr_c,
            "test_x_orig": te_x, "test_y": te_y, "test_kind": te_k, "test_color": te_c, "classes": CLASSES}


def flatten_and_scale(X_orig):
    """
    把 (m, 16, 16, 3) 的图像数组变成 (768, m) 的特征矩阵，并把像素缩放到 [0, 1]。

    【思路】这正是 Coursera 作业里的两行：
                X_flatten = X_orig.reshape(X_orig.shape[0], -1).T
                X = X_flatten / 255.
            第一步 reshape(m, -1)：每张图拉成一行（−1 表示"这一维的长度让 numpy 自己算"，这里是 768）；
            第二步 .T：转置成每张图一列，符合 (n_x, m) 约定。
            **不能**直接写 X_orig.reshape(-1, m)：形状一样是 (768, m)，但每一列会混进好几张不同图片的像素，
            程序不报错，模型却学不到任何东西（代码 04 演示 1 会演示这个 bug）。
    【参数】X_orig：(m, 16, 16, 3) uint8
    【返回】(768, m) float64，值在 [0, 1]
    【边界】除以 255. 时 uint8 会自动转成 float64（写成 255. 或 255.0 更明确是浮点除法）。
    """
    m = X_orig.shape[0]
    return X_orig.reshape(m, -1).T / 255.0


def weights_to_image(w):
    """
    把权重向量 w（形状 (768, 1)）还原成 (16, 16, 3) 的"模板图"。

    【思路】flatten_and_scale 用的是 reshape(m, -1)：每张图按 (行, 列, 通道) 的顺序拉平，最后一维变化最快。
            所以只要按同样的顺序 reshape 回去，第 k 个权重就回到它对应的像素和通道上。
    【参数】w：(768, 1) 或 (768,) 数组
    【返回】(16, 16, 3) 数组
    """
    return np.asarray(w).reshape(IMG_SIZE, IMG_SIZE, N_CHANNELS)


def shape_footprints(jitter=1):
    """
    把 16×16 的像素分成四类区域，用来"量化"学到的模板：
        十字核心：在**每一个**抖动位置下都会被十字覆盖的像素（不管十字怎么抖，这里一定是图案的颜色）
        十字边缘：在**某些**抖动位置下会被十字覆盖的像素
        四角区  ：会被方块覆盖、但在任何抖动位置下都不会被十字覆盖的像素（方块"多出来"的部分）
        外围区  ：任何抖动位置下都不会被图案覆盖的像素（永远是背景）

    【参数】jitter：抖动幅度
    【返回】(core, edge, corner, outside)，四个 (16, 16) 布尔数组，互不重叠、合起来覆盖整张图
    【Python 小知识】for dr in range(-jitter, jitter + 1) 遍历所有可能的偏移；
            &= 和 |= 是"按位与 / 或并赋值"，对布尔数组就是逐元素的"且 / 或"；~ 是逐元素取反。
    """
    core = np.ones((IMG_SIZE, IMG_SIZE), dtype=bool)
    plus_any = np.zeros((IMG_SIZE, IMG_SIZE), dtype=bool)
    square_any = np.zeros((IMG_SIZE, IMG_SIZE), dtype=bool)
    for dr in range(-jitter, jitter + 1):
        for dc in range(-jitter, jitter + 1):
            t, l = BASE_TOP_LEFT + dr, BASE_TOP_LEFT + dc
            this_plus = np.zeros((IMG_SIZE, IMG_SIZE), dtype=bool)
            this_plus[t:t + SHAPE_SIZE, l:l + SHAPE_SIZE] = plus_mask()
            core &= this_plus
            plus_any |= this_plus
            square_any[t:t + SHAPE_SIZE, l:l + SHAPE_SIZE] = True
    return core, plus_any & ~core, square_any & ~plus_any, ~square_any


# =============================================================================
# 3. 在终端里"看"图：字符画
# =============================================================================
def color_letters(img, threshold=70.0):
    """
    把一张 (16, 16, 3) 的图画成字母：每个像素找最接近的图案颜色，足够接近就写它的首字母，否则写 "."。

    【思路】终端里不能显示彩色像素，但图案的颜色是我们定的（橙 O、黄 Y、绿 G、蓝 B、紫 P），
            所以"离哪个颜色最近"就能把图案的形状和颜色一起画出来。背景偶尔也会碰巧接近某个颜色，
            那几个像素会显示成字母 —— 这正是模型要面对的"噪声"。
    【参数】img：(16, 16, 3) 数组（0–255）；threshold：RGB 欧氏距离小于它才算"是这个颜色"
    【返回】list[str]，16 行，每个像素占 2 个字符
    【numpy 小知识】np.linalg.norm(a, axis=-1) 沿最后一维求向量长度；np.argmin(…, axis=-1) 沿最后一维找最小值的位置。
    """
    names = list(COLORS)
    palette = np.array([COLORS[n] for n in names], dtype=float)                     # (5, 3)
    dist = np.linalg.norm(img[:, :, None, :].astype(float) - palette, axis=-1)      # (16, 16, 5)
    idx = np.argmin(dist, axis=-1)
    near = np.min(dist, axis=-1) < threshold
    letters = {"orange": "O", "yellow": "Y", "green": "G", "blue": "B", "purple": "P"}
    rows = []
    for r in range(IMG_SIZE):
        rows.append("".join((letters[names[idx[r, c]]] if near[r, c] else ".") + " " for c in range(IMG_SIZE)))
    return rows


def signed_heatmap(M, scale=None):
    """
    把一个有正有负的矩阵（比如某个通道的权重）画成字符热力图。

    【思路】7 个等级：强负 "@"、负 "="、弱负 "-"、接近 0 " "、弱正 "."、正 "+"、强正 "#"。
            每个值先除以 scale（默认是 |M| 的最大值），再映射到 −3 … +3 的整数等级。
            多个通道一起画时，传入同一个 scale，三张图才能互相比较。
    【参数】M：(h, w) 数组；scale：正数，归一化用的尺度
    【返回】list[str]，h 行，每个值占 2 个字符
    【边界】M 全为 0 时 scale = 0，这里用 1e−12 兜底，避免除以 0。
    """
    chars = "@=- .+#"
    if scale is None:
        scale = float(np.max(np.abs(M)))
    scale = max(scale, 1e-12)
    lv = np.clip(np.round(M / scale * 3), -3, 3).astype(int) + 3    # 0..6
    return ["".join(chars[v] * 2 for v in row) for row in lv]


def mask_rows(mask, on="#", off="."):
    """把一个 (h, w) 的布尔蒙版画成字符：True 画 on，False 画 off，每格 2 个字符。"""
    return ["".join((on if v else off) * 2 for v in row) for row in mask]


# =============================================================================
# 自检：直接运行本文件
# =============================================================================
if __name__ == "__main__":
    data = load_dataset()
    X = flatten_and_scale(data["train_x_orig"])
    print(f"train_x_orig 形状 {data['train_x_orig'].shape}，dtype {data['train_x_orig'].dtype}；"
          f"拉平缩放后 X 形状 {X.shape}，取值范围 [{X.min():.3f}, {X.max():.3f}]")
    for split in ("train", "test"):
        Y, K = data[f"{split}_y"], data[f"{split}_kind"]
        counts = "，".join(f"{KIND_NAMES[k]} {int(np.sum(K == k))}" for k in KINDS)
        print(f"{split}: m = {Y.shape[1]}，正类比例 {Y.mean():.3f}；{counts}")
    print(f"十字 {int(plus_mask().sum())} 个像素，方块 {int(square_mask().sum())} 个像素")
    for k in KINDS:
        i = int(np.argmax(data["train_kind"] == k))
        print(f"\n第 {i} 张训练图（{KIND_NAMES[k]}，y = {data['train_y'][0, i]}）：")
        for row in color_letters(data["train_x_orig"][i]):
            print("    " + row)
