"""
=============================================================================
CS230 · 第 5 讲共享模块：一个"知道真相"的白天 / 黑夜照片世界
=============================================================================

对应讲义：lectures/lecture05_deep_learning_intuition.md 第 2 节（案例 1：Day'n'Night）；
          第 4.4 节（代码 03 演示 3 也用它训练一个小分类器）

-----------------------------------------------------------------------------
【为什么要合成数据】
-----------------------------------------------------------------------------
CS230 的热身案例是："给一张照片，判断它是白天（y = 0）还是夜晚（y = 1）拍的。"
Kian 问的第一个设计问题是：**输入图片要多大的分辨率？** 太大浪费算力和数据，太小信息不够。
他给的办法是：**把图片缩小到不同分辨率，看人还能不能分辨。** 人还能分辨的最小分辨率，大致就是够用的分辨率。

真实照片我们没法在一个小脚本里提供，所以这里写一个"照片生成器"。因为照片是我们按公式画出来的，
我们知道每张图的**真相**（是什么场景、标签是怎么来的），可以精确地说出"任何人、任何模型最多能做到多好"。

-----------------------------------------------------------------------------
【这个世界长什么样】 32×32 的 RGB 小照片，像素值在 [0, 1]
-----------------------------------------------------------------------------
每张照片有一条地平线（第 12–20 行之间，随机），上面是天空，下面是地面。五种场景：

    场景            占比    标签 y        画面特征
    晴天            30%     0（白天）     明亮的蓝天；40% 的照片里有太阳（一个亮圆盘）
    阴天            20%     0（白天）     灰蒙蒙的天空和地面（中等亮度），光线均匀，**没有点光源**
    晴朗的夜晚      20%     1（夜晚）     很暗的天空和地面；星星、40% 有月亮、几盏路灯
    城市夜景        20%     1（夜晚）     天空被城市灯光照成灰色（亮度分布和"阴天"**完全一样**），
                                          但地面上有很多盏**只占 1 个像素的极亮路灯 / 窗户**
    黄昏            10%     随机 0 / 1    橙色的天空、暗的地面、几盏刚亮的灯。
                                          标签由"拍摄时间是在日落前还是日落后"决定，画面本身看不出来

另外每张照片都有：随机曝光（整体亮度 ×0.85–1.1）、随机色偏（白平衡，每个通道 ±0.03）、像素噪声（σ = 0.03）。

两个刻意设计的"难点"，都是为了让讲义的现象清楚（真实照片里也有对应的情况，但比例是我们定的）：
    1. **阴天 vs 城市夜景**：两者的天空、地面亮度从同一个分布里抽，颜色也一样是灰色。
       粗看（整体亮度、上下亮暗）完全分不开；唯一的区别是夜景里那些很小、很亮的点光源。
       点光源只占 1 个像素 —— 分辨率一降低，它就被平均进周围的暗像素里，看不见了。
    2. **黄昏**：标签和画面无关（相当于"标注员自己都说不清"），任何人、任何模型在这部分只能猜，
       期望准确率 50%。所以整个世界的**准确率上限（Bayes 最优）是 1 − 10% × 50% = 95%**。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 daynight_world.py      # 自检：生成 2000 张图，打印每种场景的占比、平均亮度和"人类代理"的准确率（约 1 秒）

只需要 numpy。
"""

import numpy as np

from case_tools import downsample, luminance, pad, print_header

SIZE = 32                                   # 相机拍出来的分辨率：32×32
SCENES = ["晴天", "阴天", "晴朗的夜晚", "城市夜景", "黄昏"]
SUNNY, OVERCAST, CLEAR_NIGHT, CITY_NIGHT, DUSK = range(5)
SCENE_PROBS = [0.30, 0.20, 0.20, 0.20, 0.10]
SCENE_LABEL = [0, 0, 1, 1, -1]              # −1 表示"标签随机"（黄昏）
BAYES_CEILING = 1.0 - SCENE_PROBS[DUSK] * 0.5     # = 0.95：黄昏那 10% 只能猜对一半

LAMP_COLOR = np.array([1.0, 0.85, 0.5])     # 暖黄色的灯光


def _disc(img, r0, c0, radius, color):
    """在 img 上以 (r0, c0) 为圆心画一个实心圆盘（太阳 / 月亮）。原地修改 img。"""
    H, W, _ = img.shape
    rr, cc = np.mgrid[0:H, 0:W]             # np.mgrid：生成每个像素的行号矩阵 rr、列号矩阵 cc
    mask = (rr - r0) ** 2 + (cc - c0) ** 2 <= radius ** 2
    img[mask] = color


def _points(img, rng, n, row_lo, row_hi, color_fn):
    """
    在第 row_lo 到 row_hi − 1 行之间随机放 n 个单像素的点光源（路灯、窗户、星星）。原地修改 img。

    【参数】color_fn：一个函数，调用 color_fn() 返回这个点的 RGB 颜色（每个点可以不一样亮）
    【Python 小知识】函数也可以当参数传进来（这里传 lambda）；range(n) 产生 0, 1, ..., n − 1。
    """
    for _ in range(n):
        r = rng.integers(row_lo, row_hi)
        c = rng.integers(0, img.shape[1])
        img[r, c] = color_fn()


def render_scene(scene, rng):
    """
    画一张 32×32 的照片。

    【思路】按"天空 + 地面 + 光源 + 相机效应（曝光、色偏、噪声）"的顺序叠加，最后裁剪到 [0, 1]。
            阴天和城市夜景的天空 / 地面亮度**从同一个分布里抽**（见文件开头的"难点 1"）。
    【参数】scene：0–4 的场景编号；rng：np.random.Generator
    【返回】(32, 32, 3) 的 float 数组
    【numpy 小知识】rows = np.arange(H)[:, None] 是形状 (H, 1) 的列向量；sky = rows < h 是 (H, 1) 的布尔数组。
            np.where(sky[..., None], A, B)：天空的位置取 A、其余取 B（[..., None] 在最后加一维，让它能和 RGB 广播）。
    """
    H = W = SIZE
    h = int(rng.integers(12, 21))                       # 地平线所在的行
    rows = np.arange(H)[:, None]                        # (H, 1)
    sky = rows < h                                      # (H, 1) 布尔
    t = np.clip(rows / h, 0, 1)                         # 天空里从上（0）到地平线（1）的位置，用来画渐变

    if scene == SUNNY:
        sky_col = np.array([0.45, 0.65, 0.95]) * (0.85 + 0.2 * t[..., None])      # 越靠近地平线越亮
        ground_col = np.array([0.35, 0.45, 0.25]) * rng.uniform(0.8, 1.2)
        img = np.where(sky[..., None], sky_col, ground_col) * np.ones((H, W, 3))
        if rng.random() < 0.4:                                                   # 40% 的晴天能看到太阳
            _disc(img, rng.integers(3, h - 2), rng.integers(3, W - 3), 2.0, [1.0, 1.0, 0.92])
    elif scene in (OVERCAST, CITY_NIGHT):
        g_sky = rng.uniform(0.25, 0.45)                 # 两种场景共用同一个亮度分布 → 粗看分不开
        g_ground = rng.uniform(0.15, 0.35)
        img = np.where(sky[..., None], g_sky, g_ground) * np.ones((H, W, 3))
        # 一点低频的明暗变化（云层的厚薄、地面的起伏）：一个缓慢的左右渐变，两种场景都有
        img = img + rng.normal(0, 0.03) * np.linspace(-1, 1, W)[None, :, None]
        if scene == CITY_NIGHT:
            n_lamps = int(rng.poisson(12)) + 2          # 很多盏灯：平均 14 盏，至少 2 盏
            _points(img, rng, n_lamps, max(h - 3, 0), H, lambda: LAMP_COLOR * rng.uniform(0.85, 1.0))
    elif scene == CLEAR_NIGHT:
        img = np.where(sky[..., None], np.array([0.03, 0.04, 0.10]), np.array([0.04, 0.04, 0.05])) * np.ones((H, W, 3))
        _points(img, rng, int(rng.poisson(6)), 0, h, lambda: np.full(3, rng.uniform(0.5, 0.9)))   # 星星
        if rng.random() < 0.4:
            _disc(img, rng.integers(2, h - 2), rng.integers(2, W - 2), 1.5, [0.95, 0.95, 0.9])  # 月亮
        _points(img, rng, int(rng.poisson(3)), h, H, lambda: LAMP_COLOR * rng.uniform(0.85, 1.0))
    elif scene == DUSK:
        b = rng.uniform(0.7, 1.0)
        sky_col = np.array([0.75, 0.45, 0.25]) * b * (0.6 + 0.4 * t[..., None])   # 靠近地平线最亮的橙色
        ground_col = np.array([0.12, 0.10, 0.10]) * rng.uniform(0.7, 1.3)
        img = np.where(sky[..., None], sky_col, ground_col) * np.ones((H, W, 3))
        _points(img, rng, int(rng.poisson(3)), h, H, lambda: LAMP_COLOR * rng.uniform(0.85, 1.0))
    else:
        raise ValueError(f"unknown scene {scene}")

    gain = rng.uniform(0.85, 1.1)                       # 曝光
    cast = rng.normal(0, 0.03, size=3)                  # 白平衡色偏
    img = img * gain + cast + rng.normal(0, 0.03, size=(H, W, 3))   # 像素噪声
    return np.clip(img, 0.0, 1.0)


def make_day_night(m, rng):
    """
    生成 m 张照片。

    【参数】m：张数；rng：np.random.Generator
    【返回】
        imgs   : (m, 32, 32, 3) float32，像素值在 [0, 1]
        y      : (m,) int，0 = 白天，1 = 夜晚
        scenes : (m,) int，场景编号（0–4），只用来分析"错在哪类场景"，模型看不到它
    【边界】黄昏的标签用 rng 随机抽 0 / 1，和画面无关。
    【numpy 小知识】float32 每个数占 4 字节（float64 是 8 字节）。8000 张 32×32×3 的图用 float32 约 100 MB，
            用 float64 要 200 MB。图片的精度用 float32 绰绰有余。
    """
    scenes = rng.choice(len(SCENES), size=m, p=SCENE_PROBS)
    imgs = np.empty((m, SIZE, SIZE, 3), dtype=np.float32)
    y = np.empty(m, dtype=int)
    for i, s in enumerate(scenes):                      # enumerate：同时拿到下标 i 和元素 s
        imgs[i] = render_scene(s, rng)
        y[i] = SCENE_LABEL[s] if SCENE_LABEL[s] >= 0 else int(rng.integers(0, 2))
    return imgs, y, scenes


def to_X(imgs, res):
    """
    把一批照片缩小到 res × res，再拉平成 Andrew Ng 约定的输入矩阵。

    【参数】imgs：(m, 32, 32, 3)；res：32 能整除的分辨率（32、16、8、4、2、1）
    【返回】X：(res·res·3, m)，每一列是一张图（float64）
    【DL 联系】第 1 讲 §3.4：全连接网络要求输入是一个向量，所以把 (res, res, 3) 拉平成 n_x = 3·res² 维。
            n_x 决定了第一层的参数个数：分辨率每翻一倍，n_x 和第一层的参数就变成 4 倍。
    """
    small = downsample(imgs, SIZE // res)
    return small.reshape(small.shape[0], -1).T.astype(np.float64)


# =============================================================================
# "人类代理"：一个知道这个世界怎么生成的手写规则
# =============================================================================
POINT_TAU = 0.10      # 比周围每个邻居都亮多少才算"一个亮点"（亮度单位，0–1）：约等于"人眼一眼能注意到"的差别


def count_point_lights(lum, tau=POINT_TAU):
    """
    数一数图里有多少个"比周围 8 个邻居中最亮的那个还亮 tau 以上"的像素（孤立的点光源）。

    【思路】路灯、窗户、星星的共同特点是**又小又亮**：比紧挨着的每一个邻居都亮得多。
            均匀的光照（阴天）没有这种像素；太阳、月亮是一个圆盘，圆盘边缘的像素旁边还有同样亮的圆盘像素，
            所以不会被当成"点"（第一版规则用的是"比邻居的中位数亮"，会把太阳的边缘误当成路灯，见代码 README）。
    【参数】lum：(r, r) 亮度图；tau：亮度差的阈值
    【返回】int，点光源的个数
    【边界】r < 3 时没有"完整的 3×3 邻域"，返回 0（太小的图上根本谈不上"点"）。
            两盏紧挨着的灯会互相"挡住"（各自都有一个同样亮的邻居），这种情况不多，忽略。
    【numpy 小知识】np.lib.stride_tricks.sliding_window_view(lum, (3, 3)) 不复制数据地取出所有 3×3 的窗口，
            形状 (r − 2, r − 2, 3, 3)。reshape 成 (..., 9) 后用 np.delete 删掉第 4 个（正中心），
            对剩下 8 个邻居取最大值。
    """
    r = lum.shape[0]
    if r < 3:
        return 0
    win = np.lib.stride_tricks.sliding_window_view(lum, (3, 3)).reshape(r - 2, r - 2, 9)
    neighbors = np.delete(win, 4, axis=2)
    center = win[:, :, 4]
    return int(np.sum(center - neighbors.max(axis=2) > tau))


def human_proxy(img):
    """
    模拟"一个知道白天黑夜长什么样的人"看一张（可能被缩小过的）照片时的判断。

    【思路】按人的直觉分三步（规则是我们写的，这正是它叫"代理"的原因）：
            1. 看天空（最上面 1/4 的行）：又亮又蓝 → 白天；很暗 → 夜晚。
            2. 天空是灰色 / 橙色的，拿不准 → 找"又小又亮"的孤立点光源：至少 2 个 → 夜晚。
            3. 都没有 → 白天（均匀的光 = 阴天）。
            黄昏的标签是随机的，这个规则（和任何人）在黄昏上都只能对一半。
    【参数】img：(r, r, 3)，任意分辨率 r
    【返回】0（白天）或 1（夜晚）
    【DL 联系】Kian 的方法：**把图缩小到不同分辨率，看人还能不能分辨**（讲义 §2.3）。
            这里用这个规则代替"人"，在每个分辨率上测一次准确率。真实项目里你会自己（或请几个同学）看图标注。
    """
    lum = luminance(img)
    r = lum.shape[0]
    top = max(1, r // 4)
    sky_lum = lum[:top].mean()
    sky_rgb = img[:top].reshape(-1, 3).mean(axis=0)
    if sky_lum > 0.5 and sky_rgb[2] - sky_rgb[0] > 0.1:
        return 0
    if sky_lum < 0.15:
        return 1
    return 1 if count_point_lights(lum) >= 2 else 0


def human_proxy_accuracy(imgs, y, res):
    """把 imgs 缩小到 res × res 后逐张用 human_proxy 判断，返回 (准确率, 每张的预测 (m,))。"""
    small = downsample(imgs.astype(np.float64), SIZE // res)
    pred = np.array([human_proxy(im) for im in small])
    return float(np.mean(pred == y)), pred


if __name__ == "__main__":
    print_header("daynight_world 自检：2000 张照片")
    imgs, y, scenes = make_day_night(2000, np.random.default_rng(0))
    print(pad("场景", 12) + pad("占比", 8, ">") + pad("夜晚比例", 10, ">") + pad("平均亮度", 10, ">")
          + pad("天空平均亮度", 14, ">"))
    for s, name in enumerate(SCENES):
        k = scenes == s
        lum = luminance(imgs[k].astype(np.float64))
        print(pad(name, 12) + f"{k.mean():>8.3f}{y[k].mean():>10.3f}{lum.mean():>10.3f}{lum[:, :8].mean():>14.3f}")
    for res in (32, 16, 8, 4):
        acc, _ = human_proxy_accuracy(imgs, y, res)
        print(f"人类代理在 {res:>2}×{res:<2} 上的准确率：{acc:.3f}    （上限 {BAYES_CEILING:.2f}）")
