"""
=============================================================================
第 16 讲 · 共享模块：一个小小的 3D 世界——零件拼成的物体、深度渲染、新视角预测
=============================================================================

对应讲义：lectures/lecture16_3d_data.md §0.4

-----------------------------------------------------------------------------
【这个世界长什么样】
-----------------------------------------------------------------------------
物体：由 2–5 个零件（盒子或椭球）拼成。一个"类别"是一个零件模板（零件的位置、大小、形状）；
      同一类别的不同"实例"在模板上加一点随机扰动（零件挪一点、大小变一点、整体缩放一点）。
      就像 ShapeNet 里的椅子：都有座、腿、靠背，但每把椅子的比例不同。
渲染：绕竖直轴转一个方位角、从 20° 的仰角正交投影，得到 16 × 16 的**深度图**
      （像素值 = 离相机多近；背景为 0）。物体表面用 384 个随机点表示，每个像素取最近的点。
任务：Zero-1-to-3 式的**新视角预测**——给一张深度图，预测相机绕物体转 90° 之后看到的深度图。
      看不见的那一面只能靠"见过很多类似的物体"来猜：这就是 3D 数据的作用。
模型：线性 + 随机傅里叶特征的岭回归（256 维输入 → 256 维输出）。真实的 Zero-1-to-3 是微调过的
      Stable Diffusion，但"从大量多视角渲染里学到 3D 先验"这一点相同。
指标：轮廓的 IoU（预测的深度 > 0.5 的像素 vs 真实的物体像素，越高越好）和深度的均方误差。

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python3 shape_world.py        # 自检：两个基线和一个小模型的 IoU（约 1 秒）

依赖 numpy 和第 1 讲的 console.py。
"""

import hashlib
import os
import sys

import numpy as np

_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(_HERE, "..", "lecture01"))
from console import print_header, print_observations, row  # noqa: E402

RES = 16                 # 深度图的分辨率
N_PTS = 384              # 每个物体用多少个表面点表示
ELEV = np.deg2rad(20)    # 相机仰角
N_AZ = 16                # 方位角离散成 16 档（每档 22.5°），同一档的物体一起渲染


# =============================================================================
# 1. 物体
# =============================================================================
def make_template(rng):
    """
    一个类别的零件模板：2–5 个零件，每个零件有中心 c、半尺寸 s、形状 t（0 = 椭球，1 = 盒子）。

    【数据联系】ShapeNetCore 有 55 个类别、约 5.1 万个模型；Objaverse 1.0 有约 2.1 万个 WordNet 概念、81.8 万个物体。
              这里"类别数 × 每类的实例数"就是在模拟这两种数据集的组成。
    """
    k = rng.integers(2, 6)
    return dict(c=rng.uniform(-0.5, 0.5, (k, 3)), s=rng.uniform(0.08, 0.4, (k, 3)), t=rng.integers(0, 2, k))


def make_instance(tpl, rng, jitter=0.06):
    """一个实例：零件中心加 N(0, jitter²) 的扰动，尺寸 × U(0.85, 1.15)，整体缩放 g ∈ U(0.85, 1.15)。"""
    k = len(tpl["c"])
    return dict(c=tpl["c"] + rng.normal(0, jitter, (k, 3)), s=tpl["s"] * rng.uniform(0.85, 1.15, (k, 3)),
                t=tpl["t"], g=rng.uniform(0.85, 1.15))


def surface_points(inst, rng, n=N_PTS):
    """
    在物体表面随机撒 n 个点。

    【思路】先按零件的"表面积"（近似为尺寸乘积的 2/3 次方）选零件，再在零件表面取点：
            椭球——标准正态向量归一化到单位球面，再按半尺寸拉伸；
            盒子——在 [−1, 1]³ 里随机取点，再把随机一个坐标推到 ±1（落到某个面上）。
    【返回】(n, 3)
    """
    k = len(inst["c"])
    area = np.prod(inst["s"], axis=1) ** (2 / 3)
    part = rng.choice(k, n, p=area / area.sum())
    u = rng.normal(size=(n, 3))
    sphere = u / np.linalg.norm(u, axis=1, keepdims=True)
    box = rng.uniform(-1, 1, (n, 3))
    box[np.arange(n), rng.integers(0, 3, n)] = rng.choice([-1.0, 1.0], n)
    shape = np.where((inst["t"][part] == 0)[:, None], sphere, box)
    return (inst["c"][part] + shape * inst["s"][part]) * inst["g"]


def make_objects(templates, n_per, rng):
    """
    每个模板（类别）造 n_per 个实例，返回 (点云 (m, N_PTS, 3), 类别编号 (m,))。

    【边界】templates 为空或 n_per = 0 时返回空数组。
    """
    pts, lab = [], []
    for ci, tpl in enumerate(templates):
        for _ in range(n_per):
            pts.append(surface_points(make_instance(tpl, rng), rng))
            lab.append(ci)
    if not pts:
        return np.zeros((0, N_PTS, 3)), np.zeros(0, int)
    return np.array(pts), np.array(lab)


# =============================================================================
# 2. 渲染
# =============================================================================
def render(P, az, elev=ELEV):
    """
    把 m 个物体从方位角 az、仰角 elev 渲染成深度图。

    【参数】P：(m, N_PTS, 3) 点云；az、elev：方位角和仰角（弧度，所有物体相同）
    【返回】(m, RES·RES)，像素值 = 1.5 − 深度（越近越大）；没有点落入的像素为 0
    【思路】先绕 z 轴转 az，再绕 x 轴转仰角；沿 +y 方向看，图像平面是 (x, z)。
            每个像素取落在里面的点中最近的那个——一个最简单的"z-buffer"。
    【numpy 小知识】np.maximum.at(img, idx, val) 对重复的下标做"取最大值"的累积；
            普通的 img[idx] = np.maximum(img[idx], val) 遇到重复下标时只保留最后一次赋值。
    """
    ca, sa, ce, se = np.cos(az), np.sin(az), np.cos(elev), np.sin(elev)
    Rz = np.array([[ca, -sa, 0], [sa, ca, 0], [0, 0, 1]])
    Rx = np.array([[1, 0, 0], [0, ce, -se], [0, se, ce]])
    Q = P @ (Rx @ Rz).T
    u = np.clip(((Q[..., 0] + 1.1) / 2.2 * RES).astype(int), 0, RES - 1)
    v = np.clip(((Q[..., 2] + 1.1) / 2.2 * RES).astype(int), 0, RES - 1)
    m = P.shape[0]
    idx = (np.arange(m)[:, None] * RES * RES + v * RES + u).ravel()
    img = np.zeros(m * RES * RES)
    np.maximum.at(img, idx, (1.5 - Q[..., 1]).ravel())
    return img.reshape(m, RES * RES)


def view_pairs(P, rng, n_views=1, delta=np.pi / 2, elev_range=None):
    """
    为每个物体渲染 n_views 对（输入视角, 相机绕物体转 delta 之后的视角）。

    【参数】elev_range：None 表示固定仰角 20°；(lo, hi)（度）表示每对视角随机取一个仰角（离散成 5 档）
    【思路】每个物体随机选一个方位角（离散成 16 档，同一档的物体一起渲染，快很多）。
            Zero-1-to-3 为每个 Objaverse 物体渲染 12 个随机视角，两两组成训练对。
    【返回】(X, Y)：(m·n_views, 256) 的输入和目标深度图
    """
    elevs = [ELEV] if elev_range is None else list(np.deg2rad(np.linspace(elev_range[0], elev_range[1], 5)))
    Xs, Ys = [], []
    for _ in range(n_views):
        az_bin = rng.integers(0, N_AZ, len(P))
        el_bin = rng.integers(0, len(elevs), len(P))
        X = np.zeros((len(P), RES * RES))
        Y = np.zeros((len(P), RES * RES))
        for b in range(N_AZ):
            for e, el in enumerate(elevs):
                sel = (az_bin == b) & (el_bin == e)
                if sel.any():
                    a = b * 2 * np.pi / N_AZ
                    X[sel] = render(P[sel], a, el)
                    Y[sel] = render(P[sel], a + delta, el)
        Xs.append(X)
        Ys.append(Y)
    return np.concatenate(Xs), np.concatenate(Ys)


def asset_hash(points, exact=True):
    """
    资产的"指纹"，用来去重。

    【参数】exact=True：对点云的原始字节做 SHA-256——完全相同的文件才相同（Objaverse-XL 的做法）；
            exact=False：把正面的深度图粗化成 8 × 8 的轮廓再哈希——"看起来一样"的资产相同（感知哈希）
    【Python 小知识】hashlib.sha256(bytes).hexdigest() 返回 64 个十六进制字符；
                    ndarray.tobytes() 把数组的内存原样转成字节串（形状和数值完全相同才会得到相同的字节）。
    """
    if exact:
        return hashlib.sha256(np.ascontiguousarray(points).tobytes()).hexdigest()
    img = render(points[None], 0.0)[0].reshape(RES, RES)
    coarse = img.reshape(8, 2, 8, 2).max(axis=(1, 3)) > 0
    return hashlib.sha256(coarse.tobytes()).hexdigest()


# =============================================================================
# 3. 新视角预测模型
# =============================================================================
class NVSModel:
    """
    深度图 → 转 90° 后的深度图：Y ≈ φ(X) W，φ(X) = [X, 1, 4·√(2/K)·cos(Ω X + β)]。

    【参数】X、Y：(N, 256)；K：随机特征的个数（容量）；lam：岭惩罚；
            prior：(可选) 另一个 NVSModel——把它的权重当作"初始化"，惩罚 ‖W − W_prior‖² 而不是 ‖W‖²。
            这就是线性模型里的"微调"：先在全部数据上训练，再在一个高质量的子集上、以第一步的结果为中心继续拟合
            （Objaverse-XL 的 alignment finetuning）。
    【边界】prior 必须用同样的 K 和种子（同样的随机特征），否则权重对不上。
    """

    def __init__(self, X, Y, K=2000, lam=10.0, seed=3, prior=None):
        rng = np.random.default_rng(seed)
        self.Om = rng.normal(0, 1 / 8, (X.shape[1], K))
        self.beta = rng.uniform(0, 2 * np.pi, K)
        self.K = K
        F = self.features(X)
        A = F.T @ F + lam * np.eye(F.shape[1])
        B = F.T @ Y
        if prior is not None:
            B = B + lam * prior.W           # argmin ‖Y − FW‖² + λ‖W − W₀‖² 的闭式解
        self.W = np.linalg.solve(A, B)

    def features(self, X):
        return np.concatenate([X, np.ones((len(X), 1)), 4 * np.sqrt(2 / self.K) * np.cos(X @ self.Om + self.beta)], axis=1)

    def __call__(self, X):
        return self.features(X) @ self.W


def iou(pred, true):
    """轮廓 IoU：预测深度 > 0.5 的像素 vs 真实深度 > 0 的像素；对每张图算再平均。"""
    a, b = pred > 0.5, true > 0
    return float(np.mean(np.logical_and(a, b).sum(1) / np.maximum(np.logical_or(a, b).sum(1), 1)))


def depth_mse(pred, true):
    return float(np.mean((pred - true) ** 2))


def novel_test_set(templates, seed=9, n_per=2, elev_range=None):
    """一组固定的测试数据：这些模板（新类别）的实例，各一对视角。"""
    rng = np.random.default_rng(seed)
    P, _ = make_objects(templates, n_per, rng)
    return view_pairs(P, rng, elev_range=elev_range)


ALL_TEMPLATES = [make_template(np.random.default_rng(10_000 + i)) for i in range(5000)]
TRAIN_TEMPLATES, NOVEL_TEMPLATES = ALL_TEMPLATES[:4500], ALL_TEMPLATES[4500:]


# =============================================================================
# 自检
# =============================================================================
if __name__ == "__main__":
    print_header("shape_world 自检：新视角预测（转 90°）在 1000 个新类别物体上的表现")
    Xn, Yn = novel_test_set(NOVEL_TEMPLATES)
    rng = np.random.default_rng(1)
    P, _ = make_objects(TRAIN_TEMPLATES[:500], 4, rng)
    X, Y = view_pairs(P, rng, n_views=4)
    model = NVSModel(X, Y)
    rows = {"基线：直接输出输入的那张图": Xn, "基线：输出训练目标的平均图": np.tile(Y.mean(0), (len(Xn), 1)),
            "岭回归，2000 个物体 × 4 对视角": model(Xn)}
    w = [34, 12, 14]
    print(row(["方法", "轮廓 IoU", "深度均方误差"], w))
    for name, pred in rows.items():
        print(row([name, f"{iou(pred, Yn):.3f}", f"{depth_mse(pred, Yn):.4f}"], w))
    print_observations([
        "\"直接输出输入的图\"假设物体转 90° 后看起来一样（对球形的物体是对的）；\"平均图\"只知道物体大致在画面中间。"
        "学到的模型要好于这两者，才说明它从数据里学到了一些关于 3D 形状的东西。",
    ])
