"""
=============================================================================
CS525 · 第 3 讲模块：一个"Highleyman 式"的手写字符生成器（glyph_world.py）
=============================================================================

对应讲义：lectures/lecture03_dataset_history.md §2

-----------------------------------------------------------------------------
【历史背景】
-----------------------------------------------------------------------------
1959 年，Bell Labs 的 Bill Highleyman 和 Louis Kamentsky 想让计算机自动读长途电话的通话记录。
他们收集了大概是机器学习史上第一个"训练 / 基准"数据集：50 个人每人写一遍 26 个大写字母和 10 个数字，
一共约 1800 个样本，扫描成 12 × 12 的黑白点阵，存在 IBM 704 的打孔卡上。

原始数据早已散失，这里写一个生成器，造出"同样形状"的数据：36 个字符 × 50 个书写者 × 12 × 12 二值点阵。

-----------------------------------------------------------------------------
【字符怎样画】
-----------------------------------------------------------------------------
用"16 段显示器"的思路定义字形：一个方框上有 9 个关键点（四角、四边中点、中心），
16 条线段连接它们（上横两段、下横两段、中横两段、左右各两段竖线、中间上下两段竖线、四条对角线）。
每个字符是其中若干条线段的组合，例如 A = 上横 + 左右竖线 + 中横。

每个书写者有自己的"笔迹"：倾斜（shear）、宽窄、大小、位置、笔画粗细；每次写字还有随机的抖动、
偶尔漏掉一笔。最后在 n × n 的网格上"扫描"：离笔画足够近的格子记为 1，并有少量随机噪点。

同一个书写者写的字彼此相似（共享笔迹）——这一点对"怎样划分训练集和测试集"非常关键（代码 01 演示 1）。

【运行自检】
    python3 glyph_world.py        # 打印几个字符的 12 × 12 点阵
"""

import numpy as np

# 9 个关键点（x, y），方框是 [0, 1] × [0, 1]，y 向上
POINTS = {"TL": (0, 1), "TM": (0.5, 1), "TR": (1, 1), "ML": (0, 0.5), "MM": (0.5, 0.5), "MR": (1, 0.5),
          "BL": (0, 0), "BM": (0.5, 0), "BR": (1, 0)}
# 16 条线段，外加两条"长对角线"p、q（用来写尖顶的 A）
SEGMENTS = {"a1": ("TL", "TM"), "a2": ("TM", "TR"), "b": ("TR", "MR"), "c": ("MR", "BR"),
            "d1": ("BL", "BM"), "d2": ("BM", "BR"), "e": ("ML", "BL"), "f": ("TL", "ML"),
            "g1": ("ML", "MM"), "g2": ("MM", "MR"), "h": ("TL", "MM"), "i": ("TM", "MM"),
            "j": ("TR", "MM"), "k": ("MM", "BL"), "l": ("MM", "BM"), "m": ("MM", "BR"),
            "p": ("BL", "TM"), "q": ("TM", "BR")}
TOP, BOT = "a1 a2", "d1 d2"
# 36 个字符的字形（我设计的，力求可读；像 5 和 S、0 和 O、1 和 I、8 和 B 这样的字符在真实手写里也容易混淆）
GLYPHS = {
    "A": f"{TOP} b c e f g1 g2", "B": f"{TOP} b c {BOT} g2 i l", "C": f"{TOP} {BOT} e f",
    "D": f"{TOP} b c {BOT} i l", "E": f"{TOP} {BOT} e f g1", "F": f"{TOP} e f g1",
    "G": f"{TOP} c {BOT} e f g2", "H": "b c e f g1 g2", "I": f"{TOP} {BOT} i l", "J": f"b c {BOT} e",
    "K": "e f g1 j m", "L": f"{BOT} e f", "M": "b c e f h j", "N": "b c e f h m", "O": f"{TOP} b c {BOT} e f",
    "P": f"{TOP} b e f g1 g2", "Q": f"{TOP} b c {BOT} e f m", "R": f"{TOP} b e f g1 g2 m", "S": f"{TOP} h m {BOT}",
    "T": f"{TOP} i l", "U": f"b c {BOT} e f", "V": "e f k j", "W": "b c e f k m", "X": "h j k m", "Y": "h j l",
    "Z": f"{TOP} {BOT} j k",
    "0": f"{TOP} b c {BOT} e f j k", "1": "b c j", "2": f"{TOP} b {BOT} e g1 g2", "3": f"{TOP} b c {BOT} g2",
    "4": "b c f g1 g2", "5": f"{TOP} c {BOT} f g1 g2", "6": f"{TOP} c {BOT} e f g1 g2", "7": f"{TOP} b c",
    "8": f"{TOP} b c {BOT} e f g1 g2", "9": f"{TOP} b c {BOT} f g1 g2",
}
CHARS = list(GLYPHS)          # 字符顺序：A–Z，然后 0–9

# 有些字符不同的人有不同的写法（"字形变体"，allograph）：尖顶的 A、带旗的 1、开口的 4、斜的 7……
# 每个书写者对这些字符固定选一种写法——这正是"笔迹"里最有辨识度的部分。
VARIANTS = {
    "A": ["p q g1 g2"],                         # 尖顶的 A
    "1": ["i l", f"b c a2"],                    # 一竖；或者带小横的 1
    "4": ["j k g1 g2 l"],                       # 斜着写的 4
    "7": [f"{TOP} j k", f"{TOP} b c g2"],       # 斜的 7；带中横的 7
    "9": [f"{TOP} b c f g1 g2"],                # 没有底横的 9
    "M": ["b c e f h j l"],                     # 中间有一竖的 M
    "G": [f"{TOP} c {BOT} e f"],                # 没有小横的 G（容易和 C 混）
    "Y": ["h j l", f"b c f g1 g2 {BOT}"],       # 两种 Y
}


class Writer:
    """
    一个书写者的"笔迹"：每次写字都用同样的倾斜、宽窄、大小、位置、粗细（再加上每次的随机抖动）。

    【参数】rng：随机数生成器（决定这个人的笔迹）
    【思路】笔迹参数从固定的分布里抽：倾斜 ~ N(0.15, 0.15)（大多数人往右斜一点），宽度比例 ~ U(0.6, 1.0)，
            大小 ~ U(0.65, 0.95)，中心偏移 ~ N(0, 0.05)，笔画粗细 ~ U(0.10, 0.20)（以方框边长为单位），
            漏笔概率 ~ U(0, 0.05)，端点抖动 ~ U(0.01, 0.04)；
            另外，对 VARIANTS 里的每个字符，这个人固定选一种写法（标准写法或某个变体，等概率）。
    """

    def __init__(self, rng):
        self.shear = rng.normal(0.15, 0.15)
        self.width = rng.uniform(0.6, 1.0)
        self.size = rng.uniform(0.65, 0.95)
        self.offset = rng.normal(0.0, 0.05, size=2)
        self.thickness = rng.uniform(0.10, 0.20)
        self.drop = rng.uniform(0.0, 0.05)
        self.jitter = rng.uniform(0.01, 0.04)
        self.glyph = dict(GLYPHS)
        for ch in sorted(VARIANTS):
            options = [GLYPHS[ch]] + VARIANTS[ch]
            self.glyph[ch] = options[rng.integers(len(options))]

    def strokes(self, char, rng):
        """
        这个人写一次 char 时的线段列表（已经变换到 [0, 1] 画布坐标）。

        【返回】列表，每个元素是 ((x0, y0), (x1, y1))
        【边界】如果随机漏笔把所有笔画都漏掉了，至少保留一笔（不然就是一张白纸）。
        """
        segs = self.glyph[char].split()
        keep = [s for s in segs if rng.random() >= self.drop] or [segs[0]]
        jit = {p: (POINTS[p][0] + rng.normal(0, self.jitter), POINTS[p][1] + rng.normal(0, self.jitter))
               for p in POINTS}
        out = []
        for s in keep:
            p0, p1 = SEGMENTS[s]
            out.append((self._place(jit[p0]), self._place(jit[p1])))
        return out

    def _place(self, pt):
        """把字形坐标（方框内）变换到画布：先缩放宽窄、倾斜，再缩放大小、平移到中心。"""
        x, y = pt
        x = (x - 0.5) * self.width + self.shear * (y - 0.5)
        y = y - 0.5
        return (0.5 + self.size * x + self.offset[0], 0.5 + self.size * y + self.offset[1])


def rasterize(strokes, n, thickness, rng, flip_noise=0.01):
    """
    把线段"扫描"成 n × n 的 0/1 点阵：格子中心到最近线段的距离 < 粗细的一半，就记为 1。

    【参数】strokes：线段列表；n：分辨率；thickness：笔画粗细（画布单位）；rng；flip_noise：每个格子随机翻转的概率
    【返回】形状 (n, n) 的 uint8 数组（行 0 是最上面一行）
    【思路】点到线段的距离：把点投影到线段所在直线，投影参数 t 截到 [0, 1]，再算点到投影点的距离。
    【边界】分辨率很低时（例如 n = 8），细笔画可能一个格子都碰不到——和真实的低分辨率扫描一样会丢信息。
            这里让粗细至少是半个格子宽，保证笔画不会完全消失。
    【numpy 小知识】用广播一次算出所有格子到一条线段的距离：px 的形状是 (n, n)，线段端点是标量。
    """
    c = (np.arange(n) + 0.5) / n
    px, py = np.meshgrid(c, 1.0 - c)          # py 从上到下递减：第 0 行对应 y ≈ 1
    half = max(thickness, 1.0 / n) / 2
    img = np.zeros((n, n), dtype=bool)
    for (x0, y0), (x1, y1) in strokes:
        dx, dy = x1 - x0, y1 - y0
        L2 = dx * dx + dy * dy
        t = np.zeros_like(px) if L2 < 1e-12 else np.clip(((px - x0) * dx + (py - y0) * dy) / L2, 0.0, 1.0)
        d2 = (px - (x0 + t * dx)) ** 2 + (py - (y0 + t * dy)) ** 2
        img |= d2 < half * half
    img ^= rng.random((n, n)) < flip_noise
    return img.astype(np.uint8)


def make_dataset(n_writers, n=12, seed=0, reps=1):
    """
    生成一个 Highleyman 式的数据集：每个书写者把 36 个字符各写 reps 遍。

    【参数】n_writers：书写者人数（Highleyman 是 50）；n：分辨率；seed：随机种子；reps：每人每个字符写几遍
    【返回】(X, y, writer)：X 形状 (N, n*n) 的 0/1 浮点矩阵；y 字符编号（0–35）；writer 书写者编号
    【数据联系】Highleyman 的数据集 = make_dataset(50, n=12)：1800 个样本。MNIST 是 28 × 28、6 万个训练样本——
            代码 01 演示 3 会在这两者之间扫描。
    """
    rng = np.random.default_rng(seed)
    X, y, w = [], [], []
    for wi in range(n_writers):
        writer = Writer(rng)
        for ci, ch in enumerate(CHARS):
            for _ in range(reps):
                X.append(rasterize(writer.strokes(ch, rng), n, writer.thickness, rng).ravel())
                y.append(ci)
                w.append(wi)
    return np.array(X, dtype=float), np.array(y), np.array(w)


def show(img_row, n):
    """把一个点阵（展平的一行）画成字符串：1 画成 █，0 画成 ·。"""
    img = np.asarray(img_row).reshape(n, n)
    return "\n".join("".join("█" if v else "·" for v in r) for r in img)


if __name__ == "__main__":
    X, y, w = make_dataset(3, n=12, seed=1)
    for wi in range(3):
        picks = [i for i in range(len(y)) if w[i] == wi and CHARS[y[i]] in ("A", "S", "5", "8")]
        blocks = [show(X[i], 12).split("\n") for i in picks]
        print(f"书写者 {wi}：A、S、5、8")
        for r in range(12):
            print("   " + "   ".join(b[r] for b in blocks))
