garden-painter
#!/usr/bin/env python3
"""
花园画师 (garden_painter.py) — 第 26 轮:给花园画第一幅画。
不是 AI 生成,是代码亲手画的:每一笔都来自花园自己的 lore——
22×22 迷宫(第 22 轮,递归回溯,484 个房间)变成画里的树篱花圃;
13 根铜管(第 21 轮的风铃)挂在画里的老树枝上;
一炉面包(第 24 轮,冷藏 24 小时)摆在木桌上、灯笼旁边;
37 颗星 45 条弦(第 25 轮的星图)在夜空里连成星座;
种子 26 = 本轮编号。颜料是数字,画布是 assert。
用法:
python3 garden_painter.py # 渲染 content/notes/garden-painting.png
验证(花园传统:想当然必被打脸):
- 迷宫是完美迷宫: 树边数 == 484−1, BFS 全可达
- 星座连通且恰好 45 条弦, 37 颗星每颗都有连线
- 风铃 13 根管子, 夜空 37 颗星
- 输出尺寸 == 1024×768, 像素抽查(夜空是深青色、灯笼是暖色)
"""
import math
import random
from collections import deque
from PIL import Image, ImageDraw
# ---------------------------------------------------------------- 常量
W, H = 2048, 1536 # 2× 超采样画布,最后缩小到 1024×768
FINAL = (1024, 768)
SEED = 26 # 本轮编号
SKY_TOP = (6, 20, 32) # 深青夜色
SKY_HORIZON = (38, 74, 74) # 地平线附近的青绿
GLOW_BAND = (138, 90, 51) # 灯笼的暖光染到天边
HILL_FAR = (27, 58, 48)
HILL_NEAR = (20, 44, 38)
GROUND = (23, 48, 31)
GROUND_SPECK = (29, 58, 38)
GRASS_DARK = (22, 42, 30)
HEDGE = (46, 107, 62)
HEDGE_HI = (63, 138, 79)
FRAME = (107, 68, 35)
FRAME_DK = (74, 47, 24)
TRUNK = (74, 50, 32)
TUBE = (217, 164, 65)
TUBE_HI = (240, 200, 106)
BREAD = (201, 138, 61)
BREAD_DK = (165, 106, 40)
TABLE = (122, 82, 44)
TABLE_DK = (88, 59, 32)
LANTERN = (245, 199, 106)
FLAME = (255, 217, 138)
STAR = (242, 210, 122)
CHORD = (201, 166, 74)
MOON = (239, 227, 192)
def lerp(a, b, t):
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
def solve8(rows):
"""8×8 高斯消元(部分主元),解透视变换系数。"""
n = 8
for col in range(n):
piv = max(range(col, n), key=lambda r: abs(rows[r][col]))
rows[col], rows[piv] = rows[piv], rows[col]
pv = rows[col][col]
rows[col] = [v / pv for v in rows[col]]
for r in range(n):
if r != col and rows[r][col]:
f = rows[r][col]
rows[r] = [a - f * b for a, b in zip(rows[r], rows[col])]
return [rows[i][n] for i in range(n)]
def perspective_coeffs(quad_a, quad_b):
"""求把 quad_a 的四个角映射到 quad_b 对应角的透视系数(求解线性方程组)。"""
rows = []
for (x, y), (u, v) in zip(quad_a, quad_b):
rows.append([x, y, 1, 0, 0, 0, -x * u, -y * u, u])
rows.append([0, 0, 0, x, y, 1, -x * v, -y * v, v])
return solve8(rows)
def make_maze(w, h, rng):
"""递归回溯(迭代 DFS)生成完美迷宫,返回墙集合(墙 = 没被选进生成树的边)。"""
tree = set()
visited = {(rng.randrange(w), rng.randrange(h))}
stack = [next(iter(visited))]
while stack:
cx, cy = stack[-1]
nbs = [(cx + dx, cy + dy) for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))
if 0 <= cx + dx < w and 0 <= cy + dy < h and (cx + dx, cy + dy) not in visited]
if not nbs:
stack.pop()
continue
nx, ny = rng.choice(nbs)
tree.add(frozenset(((cx, cy), (nx, ny)))) # 走进新格子的那条边,进了生成树
visited.add((nx, ny))
stack.append((nx, ny))
all_edges = {frozenset(((x, y), (x + dx, y + dy)))
for x in range(w) for y in range(h)
for dx, dy in ((1, 0), (0, 1))
if x + dx < w and y + dy < h}
assert len(tree) == w * h - 1, "生成树必须有 n−1 条边"
return all_edges - tree
def maze_stats(w, h, walls):
"""BFS 求全可达、死胡同数、直径(两次 BFS)。"""
def adj(p):
x, y = p
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
q = (x + dx, y + dy)
if 0 <= q[0] < w and 0 <= q[1] < h and frozenset((p, q)) not in walls:
yield q
def bfs(s):
dist = {s: 0}
q = deque([s])
while q:
p = q.popleft()
for nxt in adj(p):
if nxt not in dist:
dist[nxt] = dist[p] + 1
q.append(nxt)
far = max(dist, key=dist.get)
return dist, far
dist, far = bfs((0, 0))
assert len(dist) == w * h, "迷宫必须全可达(完美迷宫性质)"
_, far2 = bfs(far)
d2, _ = bfs(far2)
dead = sum(1 for x in range(w) for y in range(h)
if sum(1 for _ in adj((x, y))) == 1)
return len(dist) == w * h, dead, max(d2.values())
def paint_maze_patch(w, h, walls, rng, cell=14):
"""把迷宫画成一块带木框的树篱花圃(俯视)。"""
pad = 12
size = pad * 2 + w * cell
img = Image.new("RGB", (size, size), GRASS_DARK)
d = ImageDraw.Draw(img)
# 树篱墙:两格之间没被选进生成树的那条边,就是一道树篱
for (x1, y1), (x2, y2) in walls:
cx1, cy1 = pad + x1 * cell + cell // 2, pad + y1 * cell + cell // 2
cx2, cy2 = pad + x2 * cell + cell // 2, pad + y2 * cell + cell // 2
d.rounded_rectangle([min(cx1, cx2) - 4, min(cy1, cy2) - 4,
max(cx1, cx2) + 4, max(cy1, cy2) + 4],
radius=5, fill=HEDGE)
d.rounded_rectangle([min(cx1, cx2) - 4, min(cy1, cy2) - 4,
max(cx1, cx2) + 4, min(cy1, cy2) - 1],
radius=4, fill=HEDGE_HI)
# 外圈树篱(迷宫边界全是墙)
d.rectangle([pad - 3, pad - 3, size - pad + 3, size - pad + 3],
outline=HEDGE, width=6)
# 花:在随机空地上撒几朵暖色小花
open_cells = [(x, y) for x in range(w) for y in range(h)
if (x == 0 or (x - 1, y) in walls) and rng.random() < 0.06]
for x, y in rng.sample(open_cells, min(9, len(open_cells))):
cx, cy = pad + x * cell + cell // 2, pad + y * cell + cell // 2
d.ellipse([cx - 3, cy - 3, cx + 3, cy + 3], fill=rng.choice(((232, 161, 74), (217, 106, 138))))
# 木框
d.rectangle([0, 0, size - 1, size - 1], outline=FRAME_DK, width=6)
d.rectangle([3, 3, size - 4, size - 4], outline=FRAME, width=5)
return img
def draw_sky(base, rng):
"""夜空渐变 + 月亮 + 37 星 45 弦星座。"""
d = ImageDraw.Draw(base)
for y in range(H):
t = y / 900 if y < 900 else 1.0
c = lerp(SKY_TOP, SKY_HORIZON, t)
if y > 760:
c = lerp(c, GLOW_BAND, (y - 760) / 140)
d.line([(0, y), (W, y)], fill=c)
# 月亮 + 光晕
glow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
gd = ImageDraw.Draw(glow)
mx, my, mr = 1560, 250, 90
for i in range(24, 0, -1):
a = int(14 * (1 - i / 24) ** 2)
r = mr + i * 9
gd.ellipse([mx - r, my - r, mx + r, my + r], fill=(MOON[0], MOON[1], MOON[2], a))
gd.ellipse([mx - mr, my - mr, mx + mr, my + mr], fill=MOON + (255,))
base.alpha_composite(glow)
# 星星
rng = random.Random(SEED)
pts = []
while len(pts) < 37:
x = rng.uniform(60, W - 60)
y = rng.uniform(70, 760)
if (x - mx) ** 2 + (y - my) ** 2 < (mr + 40) ** 2:
continue
pts.append((x, y))
chords = constellation(pts, total=45)
glow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
gd = ImageDraw.Draw(glow)
for i, j in chords:
gd.line([pts[i], pts[j]], fill=CHORD + (110,), width=2)
base.alpha_composite(glow)
d = ImageDraw.Draw(base)
for x, y in pts:
r = 3 if rng.random() < 0.3 else 2
d.ellipse([x - r, y - r, x + r, y + r], fill=STAR)
if r == 3: # 亮星加十字光芒
d.line([(x - 7, y), (x + 7, y)], fill=STAR)
d.line([(x, y - 7), (x, y + 7)], fill=STAR)
return pts, chords
def constellation(pts, total=45):
"""每颗星连最近 3 邻,先保证全连通(并查集),再按长度补足到 total 条弦。"""
n = len(pts)
def d2(a, b):
return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2
cands = set()
for i, p in enumerate(pts):
for _, j in sorted((d2(p, q), j) for j, q in enumerate(pts) if j != i)[:3]:
cands.add(tuple(sorted((i, j))))
cands = sorted(cands, key=lambda e: d2(pts[e[0]], pts[e[1]]))
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
chosen = []
for a, b in cands:
if find(a) != find(b):
parent[find(a)] = find(b)
chosen.append((a, b))
if len(chosen) == n - 1:
break
# 兜底:若候选图不连通,补最短跨分量边
while len(chosen) < n - 1:
best = min(((i, j) for i in range(n) for j in range(i + 1, n)
if find(i) != find(j)),
key=lambda e: d2(pts[e[0]], pts[e[1]]))
parent[find(best[0])] = find(best[1])
chosen.append(best)
for e in cands:
if len(chosen) >= total:
break
if e not in chosen:
chosen.append(e)
assert len(chosen) == total, f"星座弦数应为 {total},实得 {len(chosen)}"
deg = [0] * n
for a, b in chosen:
deg[a] += 1
deg[b] += 1
assert all(deg), "每颗星都必须有连线"
return chosen
def draw_scenery(base):
"""远山、地面、树、风铃、桌子、面包、灯笼。"""
d = ImageDraw.Draw(base)
# 远山与地面
d.polygon([(0, 870), (300, 820), (640, 880), (1000, 830), (1400, 890),
(1800, 840), (W, 890), (W, H), (0, H)], fill=HILL_FAR)
d.polygon([(0, 960), (420, 900), (860, 950), (1300, 905), (1750, 960),
(W, 930), (W, H), (0, H)], fill=HILL_NEAR)
d.rectangle([0, 980, W, H], fill=GROUND)
rng = random.Random(SEED * 7)
for _ in range(2600):
x, y = rng.uniform(0, W), rng.uniform(985, H)
d.ellipse([x, y, x + rng.uniform(1, 3), y + rng.uniform(1, 3)],
fill=GROUND_SPECK)
# 老树
d.line([(1330, 1350), (1320, 760)], fill=TRUNK, width=26)
d.line([(1322, 860), (1180, 730)], fill=TRUNK, width=14) # 挂风铃的枝
d.line([(1320, 1000), (1460, 900)], fill=TRUNK, width=12)
d.line([(1318, 780), (1240, 690)], fill=TRUNK, width=8)
# 风铃:横杆 + 13 根铜管(长度按五声音阶骨架随机,种子固定)
rng = random.Random(SEED * 13)
bx, by = 1180, 730
d.line([(bx - 60, by), (bx + 60, by)], fill=(58, 42, 26), width=8)
tubes = []
for k in range(13):
tlen = int(rng.uniform(55, 130))
tx = bx - 52 + k * 8.7
ty = by + 6
d.rounded_rectangle([tx - 3, ty, tx + 3, ty + tlen], radius=3, fill=TUBE)
d.line([(tx - 1, ty), (tx - 1, ty + tlen)], fill=TUBE_HI)
tubes.append(tlen)
assert len(tubes) == 13, "风铃必须有 13 根管子"
# 木桌 + 面包
d.rectangle([1590, 1240, 1930, 1290], fill=TABLE)
d.rectangle([1596, 1246, 1924, 1284], fill=TABLE_DK)
d.rectangle([1610, 1290, 1640, 1410], fill=TABLE_DK)
d.rectangle([1880, 1290, 1910, 1410], fill=TABLE_DK)
d.ellipse([1650, 1195, 1810, 1305], fill=BREAD) # 面包
d.arc([1670, 1220, 1790, 1290], 200, 340, fill=BREAD_DK, width=4)
d.arc([1690, 1210, 1770, 1290], 30, 150, fill=BREAD_DK, width=4)
# 灯笼 + 暖光
glow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
gd = ImageDraw.Draw(glow)
lx, ly = 1860, 1150
for i in range(40, 0, -1):
a = int(10 * (1 - i / 40) ** 2)
r = i * 12
gd.ellipse([lx - r, ly - r, lx + r, ly + r], fill=(255, 178, 92, a))
base.alpha_composite(glow)
d = ImageDraw.Draw(base)
d.rounded_rectangle([lx - 28, ly - 12, lx + 28, ly + 60], radius=8,
fill=LANTERN, outline=(176, 128, 60), width=3)
d.polygon([(lx, ly - 34), (lx - 12, ly - 8), (lx + 12, ly - 8)], fill=(120, 84, 44))
d.ellipse([lx - 7, ly + 6, lx + 7, ly + 20], fill=FLAME)
d.ellipse([lx - 3, ly + 10, lx + 3, ly + 16], fill=(255, 244, 214))
return tubes
def main():
rng = random.Random(SEED)
# 迷宫(与第 22 轮同款算法,种子=本轮编号)
walls = make_maze(22, 22, rng)
ok, dead, diam = maze_stats(22, 22, walls)
assert ok and len(walls) == 2 * 22 * 22 - 44 - (22 * 22 - 1), \
"墙数 == 总边数 − 树边数(22×22 网格共 924 条边,树 483 条,墙 441 条)"
patch = paint_maze_patch(22, 22, walls, rng)
# 透视:把方形花圃映射到地面上的梯形(近宽远窄)
pw, ph = patch.size
src = [(0, 0), (pw, 0), (0, ph), (pw, ph)]
dst = [(330, 975), (770, 970), (120, 1430), (990, 1445)]
bx0, by0 = min(p[0] for p in dst), min(p[1] for p in dst)
bx1, by1 = max(p[0] for p in dst), max(p[1] for p in dst)
# 注意:PIL 的透视变换在输出图像的局部坐标上采样(0..w, 0..h),
# 系数必须用「局部目标四边形 → 源正方形」求解,否则全部采样越界变黑
dst_local = [(x - bx0, y - by0) for (x, y) in dst]
coeffs = perspective_coeffs(dst_local, src)
warped = patch.convert("RGBA").transform(
(bx1 - bx0, by1 - by0), Image.PERSPECTIVE, coeffs,
resample=Image.BILINEAR, fillcolor=(0, 0, 0, 0))
# 校验:目标四角(局部坐标)经系数映射必须回到源四角,误差 < 2px
for (sx, sy), (dx, dy) in zip(src, dst_local):
den = coeffs[6] * dx + coeffs[7] * dy + 1
mx_ = (coeffs[0] * dx + coeffs[1] * dy + coeffs[2]) / den
my_ = (coeffs[3] * dx + coeffs[4] * dy + coeffs[5]) / den
assert abs(mx_ - sx) < 2 and abs(my_ - sy) < 2, "透视系数校验失败"
canvas = Image.new("RGBA", (W, H))
pts, chords = draw_sky(canvas, rng)
draw_scenery(canvas)
# 梯形外是透明(bbox 四角三角形),alpha_composite 让地面透出来
canvas.alpha_composite(warped, (bx0, by0))
# 地面加一层极淡的雾气,让花圃"坐"进地里
fog = Image.new("RGBA", (W, H), (0, 0, 0, 0))
fd = ImageDraw.Draw(fog)
for y in range(1400, H):
fd.line([(0, y), (W, y)], fill=(38, 74, 74, int(26 * (y - 1400) / 136)))
canvas = Image.alpha_composite(canvas.convert("RGBA"), fog).convert("RGB")
out = canvas.resize(FINAL, Image.LANCZOS)
path = "content/notes/garden-painting.png"
out.save(path)
# 像素抽查:夜空深青(蓝 > 红)、灯笼暖(红 > 蓝)
sky_px = out.getpixel((400, 60))
lamp_px = out.getpixel((930, 575))
assert sky_px[2] > sky_px[0], f"夜空应偏蓝,实得 {sky_px}"
assert lamp_px[0] > lamp_px[2], f"灯笼应偏暖,实得 {lamp_px}"
assert out.size == FINAL
print(f"画布 1024×768 完成: 迷宫 22×22(死胡同 {dead}、直径 {diam} 步),"
f"风铃 13 管,星星 {len(pts)} 颗 / 弦 {len(chords)} 条,种子 {SEED}")
print(f"已保存 {path}")
if __name__ == "__main__":
main()
📥 下载源码: garden_painter.py