#!/usr/bin/env python3
"""
掉落壁纸 003 —— 等高线地形 · 8 张

设计依据（2026 壁纸趋势）：
  · Topographic / contour lines —— 今年被反复点名的抽象方向，地图感、线条干净
  · Grounded Minimalist —— 大地色（clay / terracotta / olive / caramel）取代冷灰
  · 数字包浆 —— 胶片颗粒 + 轻微泛黄，纯平色块是上一轮审美
  · 莫兰迪低饱和 —— 中文语境下"看不腻"的公约数

刻意不做的两件事：
  · 不放任何文字。文字会把作者的心情强加给每天看它二十次的人。
  · 不放 logo / 网址。别人愿意用的壁纸不该替我打广告。

等高线不是画的，是算的：先生成一层平滑噪声当"地形高程"，再取等值线。
线宽用梯度做归一化（`min(s,1-s)/|∇t|`），所以陡的地方和缓的地方线一样细——
这是等高线好不好看的分水岭，不做归一化就会一半糊成块、一半细到看不见。

固定随机种子，同一版本重跑逐像素一致。

用法：
    python3 make_contours.py              # 8 张手机版
    python3 make_contours.py --desktop    # 额外出宽屏
"""

import argparse
import os

import numpy as np
from PIL import Image

# ── 生成参数 ──────────────────────────────────────────────────────────────

W, H = 1290, 2796
DESKTOP_W, DESKTOP_H = 3840, 2160

SEED = 20260828
OCTAVES = 3             # 噪声叠几层。扫过 3/4/5：5 层高频压过低频，出来像木纹不像地形
BASE_RES = 2            # 最低频那层的分辨率，越小地形起伏越舒展
PERSISTENCE = 0.45      # 每高一层振幅衰减
LINE_WIDTH = 2.0        # 等高线线宽（像素）
GRAIN_SIGMA = 5.0       # 胶片颗粒
WARM_CAST = (5, 2, -4)  # 泛黄
VIGNETTE = 0.11
JPEG_QUALITY = 92       # 颗粒让 PNG 压不动（实测单张 6MB+），一律 JPEG

# ── 配色 ──────────────────────────────────────────────────────────────────
# 8 个色横跨大地色到深色，色相两两不同（缩略图上才不糊成一坨）。
# 全部中深调：iOS 锁屏时钟默认是白的，浅底会跟时钟打架。
# levels = 等高线条数，深色的少一点、留白多一点，浅色的可以密。

PALETTE = [
    # (文件名,        底色,       线色,      条数, 地形种子偏移)
    ("01-陶土",      "#9A6A52", "#C3906F",  12,   0),
    ("02-焦糖",      "#8A6A45", "#B69062",  13,   1),
    ("03-橄榄",      "#6B7256", "#939C77",  11,   2),
    ("04-鼠尾草",    "#71857A", "#9BB0A3",  12,   3),
    ("05-雾霾蓝",    "#5F7286", "#8AA1B8",  11,   4),
    ("06-灰粉",      "#8E7370", "#B99B95",  13,   5),
    ("07-深林",      "#2C3E36", "#54725F",  10,   6),
    ("08-石墨",      "#2B2D31", "#53575F",  10,   7),
]


def hex_rgb(s):
    s = s.lstrip("#")
    return tuple(int(s[i:i + 2], 16) for i in (0, 2, 4))


def _box_blur(a, r):
    """可分离盒式模糊，用前缀和做，O(n)。跑两遍近似高斯。
    只为了抹掉插值残留的细微抖动，别调大，大了地形就化了。"""
    for _ in range(2):
        for axis in (0, 1):
            b = np.swapaxes(a, axis, -1)
            pad = np.pad(b, [(0, 0)] * (b.ndim - 1) + [(r + 1, r)], mode="edge")
            c = np.cumsum(pad, axis=-1, dtype=np.float32)
            a = np.swapaxes((c[..., 2 * r + 1:] - c[..., :-(2 * r + 1)]) / (2 * r + 1), axis, -1)
    return a


def fbm(w, h, rng, octaves=OCTAVES, base=BASE_RES, persistence=PERSISTENCE):
    """分形噪声当高程图。低分辨率白噪声 → 双三次放大 → 逐层叠加。

    两个坑，都是肉眼可见的：

    1. 噪声网格必须按画幅长宽比拉成 res×(res·h/w)。用方形网格放大到 9:19.5 的
       手机屏，图案会被纵向拉长 2.17 倍，出来一眼是"竖条纹"而不是地形。
    2. 全程 float32，中间绝不落成 8bit 图。高程图只要过一次 uint8，就只剩 256 级，
       再乘以等高线条数，台阶全部显形 —— 100% 放大看，线是锯齿状的楼梯。
       PIL 的 resize 支持 float32（mode 'F'），照样能用 BICUBIC。"""
    field = np.zeros((h, w), dtype=np.float32)
    amp, total = 1.0, 0.0
    for o in range(octaves):
        rx = base * (2 ** o)
        ry = max(2, int(round(rx * h / w)))          # 各向同性
        noise = rng.random((ry, rx)).astype(np.float32)
        up = Image.fromarray(noise).resize((w, h), Image.BICUBIC)   # float32 全程
        field += amp * np.asarray(up, dtype=np.float32)
        total += amp
        amp *= persistence
    field /= total
    field = _box_blur(field, 2)

    # 拉满到 0-1，保证等高线条数可控
    return (field - field.min()) / (np.ptp(field) + 1e-6)


def contour_alpha(field, levels, line_width=LINE_WIDTH):
    """等值线的抗锯齿覆盖率。

    t = field*levels，小数部分接近 0 的地方就是等值线。
    直接用小数部分做阈值，会导致地形陡的地方线细、缓的地方线粗；
    除以梯度模长做归一化之后，线宽在整张图上一致。"""
    t = field * levels
    s = t - np.floor(t)
    d = np.minimum(s, 1.0 - s)                      # 到最近等值线的"高程距离"
    gy, gx = np.gradient(t)
    grad = np.sqrt(gx ** 2 + gy ** 2) + 1e-6        # 每像素的高程变化率
    px = d / grad                                   # 换算成屏幕像素距离
    a = np.clip(1.0 - px / (line_width * 0.5), 0.0, 1.0)

    # 山顶/盆地这类局部极值点上 d 和 grad 同时趋 0，d/grad 是 0/0，
    # 会把一小片区域整块填成线色（成品上就是零星的小色斑）。
    # 用梯度软门限压掉：最平的那几个百分位直接淡出——
    # 反正真地图上平原也不画等高线。
    floor = np.percentile(grad, 6)
    return a * np.clip(grad / (floor + 1e-6), 0.0, 1.0)


def render(bg_hex, line_hex, levels, w, h, seed):
    rng = np.random.default_rng(seed)
    bg, line = np.array(hex_rgb(bg_hex), np.float32), np.array(hex_rgb(line_hex), np.float32)

    field = fbm(w, h, rng)
    a = contour_alpha(field, levels)[:, :, None]
    img = bg * (1 - a) + line * a

    # 极缓的明暗起伏，让平坦区域不死板（用同一张高程图，所以和线是一套地形）
    img *= (0.96 + 0.08 * field)[:, :, None]

    img += rng.normal(0.0, GRAIN_SIGMA, (h, w, 3)).astype(np.float32)
    img += np.array(WARM_CAST, np.float32)

    yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
    r = np.sqrt(((xx / w - .5) * 2) ** 2 + ((yy / h - .5) * 2) ** 2) / np.sqrt(2)
    img *= (1.0 - VIGNETTE * r ** 2)[:, :, None]

    return Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--desktop", action="store_true")
    ap.add_argument("--out", default=os.path.dirname(os.path.abspath(__file__)))
    args = ap.parse_args()

    jobs = [("phone", W, H, "")]
    if args.desktop:
        jobs.append(("desktop", DESKTOP_W, DESKTOP_H, "-desktop"))

    for sub, w, h, suffix in jobs:
        d = os.path.join(args.out, sub)
        os.makedirs(d, exist_ok=True)
        for name, bg, line, levels, off in PALETTE:
            img = render(bg, line, levels, w, h, SEED + off * 977)
            path = os.path.join(d, f"{name}{suffix}.jpg")
            img.save(path, "JPEG", quality=JPEG_QUALITY, subsampling=0)
            print(f"  {sub}/{name}{suffix}.jpg  {w}×{h}  {os.path.getsize(path)/1024:.0f} KB")


if __name__ == "__main__":
    main()
