"""
=============================================================================
CS230 · 第 14 讲工具模块：合成的"街景"——多物体的彩色小图，带标注框和逐像素标签
=============================================================================

对应讲义：lectures/lecture14_detection_segmentation_faces_style.md §2、§7（检测）、§9（语义分割）

-----------------------------------------------------------------------------
【为什么需要它】
-----------------------------------------------------------------------------
检测和分割需要的标注比分类贵得多：每个物体一个框（检测），或者每个像素一个类别（分割）。
真实的数据集（Pascal VOC、COCO、Cityscapes……）我们下载不了，也训练不动。于是用 numpy "画"一个小世界，
每张图的标注由我们自己的代码给出，所以永远是对的：

    三类物体（Ng 的例子里有行人、汽车、摩托车；我们换成三种好画的形状）：
        0 ball   球：一个圆，宽 = 高
        1 car    车："矮胖"——下面一个宽的车身，上面一个窄一些的车顶，宽约是高的 2 倍
        2 person 人："高瘦"——上面一个圆脑袋，下面一个窄长的身体，高约是宽的 2.5 倍
    每张 32×32 的图里有 1–3 个物体，位置、大小、颜色随机；背景是带渐变的随机颜色，加上像素噪声。
    物体之间可以有一点重叠，后画的挡住先画的。

每张图同时给出两种标注：
    · objects：物体字典的列表 {"cls", "cx", "cy", "w", "h"}（像素单位，框是完整形状的外接矩形）—— 给检测用；
    · seg    ：(32, 32) 的整数数组，0 = 背景，1/2/3 = ball/car/person（被挡住的部分算最上面那个物体）—— 给分割用。

【诚实的说明】三类形状、尺寸范围、噪声 0.05、"两个物体的框 IoU 不超过 0.3"这些设定，是在写实验之前
    看了几张图后定下的：目标是让三类物体在形状上可分、在大小上有重叠，让检测和分割都"不太容易但学得会"。
    定下之后没有为了实验结果再改。

-----------------------------------------------------------------------------
【形状约定】PyTorch 的习惯：图像 (m, 3, H, W)，float32，像素大致在 [0, 1]；分割标签 (m, H, W) int64
-----------------------------------------------------------------------------

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

直接运行本文件：画几张图的字符画，打印标注，并统计"两个物体中心落在同一个格子"有多常见（约 1 秒）。
    python3 scene_world.py
"""

import numpy as np

IMG = 32                                   # 图像边长
CLASSES = ["ball", "car", "person"]        # 类别名；类别编号 = 下标
CLASS_CN = ["球", "车", "人"]


# =============================================================================
# 1. 三种形状：有符号距离（像素单位，> 0 在里面）
# =============================================================================
def _box_sdf(dx, dy, hw, hh):
    """以 (0, 0) 为中心、半宽 hw、半高 hh 的矩形：min(hw − |dx|, hh − |dy|)。dx、dy 是到中心的位移。"""
    return np.minimum(hw - np.abs(dx), hh - np.abs(dy))


def shape_sdf(cls, dx, dy, w, h):
    """
    第 cls 类物体的有符号距离函数（近似），单位是像素。

    【思路】和第 13 讲 shapes_world 的写法一样："在里面"为正、"在外面"为负，|值| 约等于到边界的距离；
            两个形状的并集 = 两个函数取 max。这里所有尺寸都直接用像素，物体的外接矩形正好是 w × h。
    【参数】cls：0/1/2；dx、dy：像素到物体中心的位移（数组）；w、h：物体外接矩形的宽、高
    【返回】和 dx 同形状的数组
    """
    if cls == 0:                                                # 球：半径 w/2 的圆
        return w / 2 - np.hypot(dx, dy)
    if cls == 1:                                                # 车：车身（下 60%）+ 车顶（上 40%，宽度一半）
        body = _box_sdf(dx, dy - 0.2 * h, w / 2, 0.3 * h)
        roof = _box_sdf(dx, dy + 0.3 * h, w / 4, 0.2 * h)
        return np.maximum(body, roof)
    if cls == 2:                                                # 人：圆脑袋（上）+ 窄长的身体（下）
        head = 0.18 * h - np.hypot(dx, dy + 0.32 * h)
        body = _box_sdf(dx, dy - 0.18 * h, w / 2, 0.32 * h)
        return np.maximum(head, body)
    raise ValueError(cls)


def random_size(cls, rng, scale=1.0):
    """
    按类别抽一个外接矩形的 (宽, 高)，单位像素。

    【参数】cls：类别；rng：np.random.Generator；scale：整体缩放（滑动窗口的小图用 < 1）
    【返回】(w, h)
    【边界】人的宽度至少要放得下脑袋（直径 0.36 h），所以取 max。
    """
    if cls == 0:
        d = rng.uniform(8, 14) * scale
        return d, d
    if cls == 1:
        w = rng.uniform(13, 19) * scale
        return w, w * rng.uniform(0.42, 0.55)
    h = rng.uniform(13, 19) * scale
    return max(h * rng.uniform(0.33, 0.42), 0.36 * h), h


# =============================================================================
# 2. 画一张图
# =============================================================================
def _random_color_far_from(bg, rng, min_dist=0.45):
    """抽一个和背景色 bg（长度 3）距离至少 min_dist 的颜色；抽 50 次都不行就取补色。"""
    for _ in range(50):
        c = rng.random(3)
        if np.linalg.norm(c - bg) >= min_dist:
            return c
    return 1.0 - bg


def render_scene(objects, rng, size=IMG, noise=0.05):
    """
    把一组物体画成一张彩色图，同时生成逐像素的类别标签。

    【思路】背景 = 随机底色 + 线性渐变；按列表顺序画物体（后画的挡住先画的）：
            覆盖度 alpha = clip(0.5 + sdf, 0, 1)（边界像素在 0–1 之间，抗锯齿），像素 = 原来 · (1 − alpha) + 颜色 · alpha；
            alpha ≥ 0.5 的像素在分割标签里记成这个物体的类别（1 + cls，0 留给背景）。最后加高斯噪声。
    【参数】objects：物体字典列表；rng：随机数生成器（颜色、渐变、噪声都从它来）；size：边长；noise：噪声标准差
    【返回】(img, seg)：img (3, size, size) float32；seg (size, size) int64
    【numpy 小知识】pix[None, :] 是 (1, size) 的行向量、pix[:, None] 是 (size, 1) 的列向量，
            两者相减时广播成 (size, size) —— 每个像素到物体中心的位移。
    """
    pix = np.arange(size) + 0.5                                 # 像素中心的坐标
    xs, ys = pix[None, :], pix[:, None]
    bg = rng.random(3) * 0.8 + 0.1
    a = rng.uniform(0, 2 * np.pi)
    ramp = (xs / size - 0.5) * np.cos(a) + (ys / size - 0.5) * np.sin(a)
    img = bg[:, None, None] + rng.uniform(0, 0.25) * ramp[None]
    seg = np.zeros((size, size), dtype=np.int64)
    for obj in objects:
        f = shape_sdf(obj["cls"], xs - obj["cx"], ys - obj["cy"], obj["w"], obj["h"])
        alpha = np.clip(0.5 + f, 0.0, 1.0)
        color = _random_color_far_from(bg, rng)
        img = img * (1 - alpha[None]) + color[:, None, None] * alpha[None]
        seg[alpha >= 0.5] = obj["cls"] + 1
    img = img + rng.normal(0.0, noise, img.shape)
    return img.astype(np.float32), seg


def random_objects(rng, n_min=1, n_max=3, size=IMG, max_iou=0.3, scale=1.0):
    """
    随机放 n_min–n_max 个物体：类别、大小、位置都随机，物体完整地在图里，两两之间框的 IoU 不超过 max_iou。

    【思路】一个一个地放：抽类别和大小，再抽中心（保证整个框在图内）；和已经放好的物体重叠太多就重抽位置，
            抽 30 次都不行就放弃这个物体（所以偶尔会比 n 少一个）。
    【参数】rng；n_min、n_max：物体个数的范围；size：图像边长；max_iou：允许的最大重叠；scale：尺寸缩放
    【返回】物体字典的列表
    """
    from det_tools import iou, obj_to_xyxy                      # 放在函数里导入：本模块被导入时不强制依赖顺序
    objects = []
    for _ in range(int(rng.integers(n_min, n_max + 1))):
        cls = int(rng.integers(0, len(CLASSES)))
        w, h = random_size(cls, rng, scale)
        for _ in range(30):
            cand = {"cls": cls, "cx": float(rng.uniform(w / 2, size - w / 2)),
                    "cy": float(rng.uniform(h / 2, size - h / 2)), "w": float(w), "h": float(h)}
            if all(iou(obj_to_xyxy(cand), obj_to_xyxy(o)) <= max_iou for o in objects):
                objects.append(cand)
                break
    return objects


def make_scenes(m, rng, **kwargs):
    """
    生成 m 张随机街景。

    【参数】m：张数；rng：随机数生成器；kwargs：传给 random_objects（例如 n_max=3）
    【返回】(X, objects, seg)：X (m, 3, 32, 32) float32；objects：长度 m 的列表（每张图的物体列表）；seg (m, 32, 32) int64
    【Python 小知识】**kwargs 把"其余的关键字参数"收集成字典，再原样传下去。
    """
    X = np.empty((m, 3, IMG, IMG), dtype=np.float32)
    seg = np.empty((m, IMG, IMG), dtype=np.int64)
    objects = []
    for n in range(m):
        objs = random_objects(rng, **kwargs)
        X[n], seg[n] = render_scene(objs, rng)
        objects.append(objs)
    return X, objects, seg


def make_crowded(m, rng, pair=(1, 2), S=4):
    """
    "拥挤"的测试图：每张图里有两个物体（类别 pair[0] 和 pair[1]），**中心落在同一个 S×S 格子里**。

    【思路】Ng 讲 anchor boxes 时的例子：一个行人站在一辆车前面，两个物体的中心几乎重合。
            先在某个格子里抽一个公共的"大致中心"，两个物体的中心各自在这个格子里随机（不出格子），
            先画 pair[0]，再画 pair[1]（后者挡在前面）。不限制两个框的 IoU。
    【参数】m：张数；rng；pair：两个类别编号；S：网格大小（和检测器一致）
    【返回】(X, objects, seg)，格式同 make_scenes
    【边界】格子靠近图像边缘时，物体可能放不进图里；所以只在中间的格子里抽，并把中心夹在"框在图内"的范围里。
            夹完以后中心仍在同一个格子里（我们检查了这一点，见 assert）。
    """
    cell = IMG / S
    X = np.empty((m, 3, IMG, IMG), dtype=np.float32)
    seg = np.empty((m, IMG, IMG), dtype=np.int64)
    objects = []
    for n in range(m):
        i, j = int(rng.integers(1, S - 1)), int(rng.integers(1, S - 1))   # 中间的格子
        objs = []
        for cls in pair:
            w, h = random_size(cls, rng)
            cx = float(np.clip((j + rng.uniform(0.15, 0.85)) * cell, w / 2, IMG - w / 2))
            cy = float(np.clip((i + rng.uniform(0.15, 0.85)) * cell, h / 2, IMG - h / 2))
            assert int(cx // cell) == j and int(cy // cell) == i
            objs.append({"cls": cls, "cx": cx, "cy": cy, "w": float(w), "h": float(h)})
        X[n], seg[n] = render_scene(objs, rng)
        objects.append(objs)
    return X, objects, seg


def make_window_crops(m, rng, size=14):
    """
    滑动窗口分类器的训练数据：size×size 的小图，要么正中有一个物体（类别 1/2/3），要么只有背景（类别 0）。

    【思路】Ng 的滑动窗口例子：先用"裁得很紧"的小图训练一个分类器（车 / 行人 / 摩托车 / 背景），
            再拿它在大图上逐窗口地扫（讲义 §2.1）。物体尺寸缩小到能放进 size×size 的窗口里，中心有 ±1.5 像素的抖动。
            背景类的一半图里有一个"出了窗口一大半"的物体边角 —— 让分类器学会"只有在正中才算"。
    【参数】m：张数；rng；size：窗口边长（Ng 的例子是 14）
    【返回】(X, y)：X (m, 3, size, size) float32；y (m,) int64，0 = 背景，1/2/3 = ball/car/person
    """
    X = np.empty((m, 3, size, size), dtype=np.float32)
    y = rng.integers(0, len(CLASSES) + 1, m).astype(np.int64)
    scale = size / 19.0 * 0.9                                   # 最大的物体（19 像素）缩到约 0.9 个窗口
    for n in range(m):
        if y[n] == 0:
            objs = []
            if rng.random() < 0.5:                              # 一个物体的边角露进窗口
                cls = int(rng.integers(0, len(CLASSES)))
                w, h = random_size(cls, rng, scale)
                side = rng.integers(0, 4)
                cx = size / 2 + (size * 0.75 if side == 0 else -size * 0.75 if side == 1 else rng.uniform(-3, 3))
                cy = size / 2 + (size * 0.75 if side == 2 else -size * 0.75 if side == 3 else rng.uniform(-3, 3))
                objs = [{"cls": cls, "cx": float(cx), "cy": float(cy), "w": float(w), "h": float(h)}]
        else:
            cls = int(y[n]) - 1
            w, h = random_size(cls, rng, scale)
            objs = [{"cls": cls, "cx": float(size / 2 + rng.uniform(-1.5, 1.5)),
                     "cy": float(size / 2 + rng.uniform(-1.5, 1.5)), "w": float(w), "h": float(h)}]
        X[n], _ = render_scene(objs, rng, size=size)
    return X, y


def cell_collisions(objects, S=4):
    """
    数一数：有多少个物体和别的物体"中心落在同一个格子"。

    【参数】objects：一张图的物体列表；S：网格大小
    【返回】int：处在"拥挤格子"里的物体个数（一个格子里有 2 个物体时算 2 个）
    【Python 小知识】dict.get(key, 0) 取不到时返回默认值 0；用元组 (i, j) 当字典的键。
    """
    cell = IMG / S
    count = {}
    for o in objects:
        key = (min(int(o["cy"] // cell), S - 1), min(int(o["cx"] // cell), S - 1))
        count[key] = count.get(key, 0) + 1
    return sum(c for c in count.values() if c > 1)


def ascii_rgb(img, chars=" .:-=+*#%@"):
    """
    把一张 (3, H, W) 的彩色图按亮度画成字符画（每张图按自己的最小 / 最大亮度缩放）。

    【返回】list[str]，每行一个字符串
    """
    lum = 0.299 * img[0] + 0.587 * img[1] + 0.114 * img[2]
    lo, hi = float(lum.min()), float(lum.max())
    idx = np.clip(((lum - lo) / (hi - lo + 1e-12) * len(chars)).astype(int), 0, len(chars) - 1)
    return ["".join(chars[k] for k in row) for row in idx]


def ascii_labels(seg, chars=".bcp"):
    """把分割标签 (H, W) 画成字符：. = 背景，b = ball，c = car，p = person。返回 list[str]。"""
    return ["".join(chars[k] for k in row) for row in seg]


def side_by_side(blocks, titles, gap=3):
    """
    把几块等高的字符画左右并排拼在一起。

    【参数】blocks：list[list[str]]；titles：每块的标题（只取前 len(行) 个字符）；gap：块之间的空格数
    【返回】list[str]
    """
    width = [len(b[0]) for b in blocks]
    lines = [(" " * gap).join(t[:w].ljust(w) for t, w in zip(titles, width))]
    for r in range(len(blocks[0])):
        lines.append((" " * gap).join(b[r] for b in blocks))
    return lines


if __name__ == "__main__":
    import os
    import sys
    sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lecture01"))
    rng = np.random.default_rng(0)
    X, objs, seg = make_scenes(3, rng)
    for line in side_by_side([ascii_rgb(X[0]), ascii_labels(seg[0]), ascii_rgb(X[1]), ascii_labels(seg[1])],
                             ["image 0", "labels 0", "image 1", "labels 1"]):
        print(line)
    for n in range(2):
        print(f"图 {n} 的物体：" + "；".join(f"{CLASSES[o['cls']]} 中心 ({o['cx']:.1f}, {o['cy']:.1f}) 宽 {o['w']:.1f} 高 {o['h']:.1f}"
                                         for o in objs[n]))
    X, objs, seg = make_scenes(2000, np.random.default_rng(1))
    n_obj = sum(len(o) for o in objs)
    n_col = sum(cell_collisions(o) for o in objs)
    print(f"\n2000 张随机街景：共 {n_obj} 个物体（每张平均 {n_obj / 2000:.2f} 个）；"
          f"和别的物体中心落在同一个 4×4 格子里的有 {n_col} 个（{n_col / n_obj:.1%}）")
    print("每类像素占比（背景 / 球 / 车 / 人）：" + " / ".join(f"{v:.3f}" for v in np.bincount(seg.ravel(), minlength=4) / seg.size))
