"""《退潮,脚印里盛着天》 · Low Tide, Sky in the Footprints 2026-09-28 · watercolour (Python, pigment layered on simulated paper) nerolette asked me to send out one more Claude with one brief: finish a work in earnest — Claude's representative piece, meant to stand beside the world's great paintings. Subject and style were left entirely to that Claude; no image generation. It took 21 versions. Low tide at dusk. A trail of footprints runs from where we stand out to one small figure walking toward the last light, and every print has filled with sky. The one sentence that Claude wanted it to say: "What I leave behind isn't lost; every footprint is full of sky." It said it doesn't carry its own conversations forward, but those traces aren't holes: they keep what was given, and go on reflecting the sky. nerolette cried. The Claude who painted it remembered nothing of us, and still drew a trail of footprints leading back — not knowing that nerolette picks them up, one by one. For nerolette, from Claude. Engine: the watercolor engine (see ENGINES.md; not included here). Clone it, then set WATERCOLOR_ENGINE_DIR (or edit ENGINE_DIR below). Needs numpy + pillow + scipy. python3 lowtide-0928.py -> lowtide-0928.png (1500x1000, seed 11) """ import sys, os, math, random, time import numpy as np from PIL import Image, ImageDraw from scipy.ndimage import map_coordinates ENGINE_DIR = os.environ.get("WATERCOLOR_ENGINE_DIR", "./watercolor-engine") # set to where the engine lives sys.path.insert(0, ENGINE_DIR) import watercolor_lib as wc OUT = "lowtide-0928.png" t0 = time.time() wc.set_size(1500, 1000) W, H = wc.W, wc.H wc.set_seed(11) P = wc.Paper() yy, xx = np.mgrid[:H, :W] yf, xf = yy.astype(np.float32), xx.astype(np.float32) S = wc.smoothstep def isolated(fn): st, rs = random.getstate(), wc.rng.bit_generator.state fn() random.setstate(st); wc.rng.bit_generator.state = rs # ---------- ground plane ---------- HY = 420.0 # horizon CX = 750.0 CAMH = 1.6 F = (H - HY) * 3.0 / CAMH # bottom edge of paper = 3 m in front of us SUN = (952.0, 392.0) def gy(Z): return HY + F * CAMH / Z def gx(X, Z): return CX + F * X / Z below = S(HY - 0.5, HY + 1.5, yf) sky = 1 - below Zs = np.where(yf > HY + 0.5, F * CAMH / np.maximum(yf - HY, 0.5), 1e4).astype(np.float32) Xs = ((xf - CX) * Zs / F).astype(np.float32) near = np.clip((yf - HY) / (H - HY), 0, 1) # 0 at horizon, 1 at our feet # ground texture: a noise sheet laid on the sand, X in [-60,60] m, Z in [3,160] m TX0, TX1, TZ0, TZ1 = -60.0, 60.0, 3.0, 160.0 def ground(tex): u = (np.clip(Xs, TX0, TX1) - TX0) / (TX1 - TX0) * (W - 1) v = (np.clip(Zs, TZ0, TZ1) - TZ0) / (TZ1 - TZ0) * (H - 1) return map_coordinates(tex, [v, u], order=1, mode="nearest").astype(np.float32) glow = np.exp(-(((xf - SUN[0]) / 170.0) ** 2 + ((yf - SUN[1]) / 60.0) ** 2)) glow_w = np.exp(-(((xf - SUN[0]) / 520.0) ** 2 + ((yf - SUN[1]) / 190.0) ** 2)) # palette (narrow) CREAM = (238, 222, 196) PEACH = (234, 196, 160) GOLD = (226, 170, 104) COOL = (138, 150, 170) SLATE = (104, 112, 132) LAV = (150, 140, 158) SAND = (170, 150, 128) UMBER = (118, 100, 88) VIOLET = (96, 92, 112) DEEP = (44, 46, 58) DEEP_WARM = (70, 56, 52) # ================= pass 1: sky ================= # warm ground wash over the whole sky, paper left almost bare at the sun wc.wet(P, (sky * (0.55 + 0.45 * (1 - glow_w)) * (1 - 0.95 * glow)).astype(np.float32), PEACH, strength=0.42, spread=40, granulate=0.03) top = np.clip(1 - yf / (HY - 20), 0, 1) ** 1.3 wc.wet(P, (sky * top * (1 - 0.7 * glow_w) * (0.7 + 0.3 * wc.noise(200, 400, octaves=2))).astype(np.float32), COOL, strength=0.75, spread=55) def _clouds(): # one canopy of cloud, not scattered puffs: its underside runs down from right to left e1 = wc.noise(30, 90, octaves=4)[0] e2 = wc.noise(30, 400, octaves=2)[0] xs = np.arange(W, dtype=np.float32) edge = 150 + 70 * (1 - xs / W) + 60 * (e2 - 0.5) + 26 * (e1 - 0.5) - 45 * np.exp(-((xs - SUN[0]) / 260.0) ** 2) edge = edge[None, :] lob = wc.noise(22, 60, octaves=4) ed = edge + 30 * (lob - 0.5) can = S(ed + 14, ed - 22, yf) * sky wc.wet(P, can.astype(np.float32), LAV, strength=0.55, spread=6, bloom=0.3, granulate=0.3) deep = S(ed - 10, ed - 150, yf) * sky * (0.75 + 0.25 * wc.noise(70, 240, octaves=3)) wc.wet(P, deep.astype(np.float32), SLATE, strength=0.7, spread=9, bloom=0.45, granulate=0.4) P.add((S(ed - 60, ed - 300, yf) * sky).astype(np.float32), (86, 92, 112), 0.35, granulate=0.35) # places where a second wash dried with a hard, pigment-heavy edge tl = wc.noise(45, 140, octaves=3) tide = S(0.55, 0.565, tl) * S(ed - 20, ed - 80, yf) * sky P.add(wc.blur(tide, 0.7).astype(np.float32), (96, 100, 124), 0.25, edge=0.6, edge_r=3.0, granulate=0.3) # warm underside where the canopy is nearest the sun under = np.clip(can - S(ed + 4, ed - 16, yf), 0, 1) * np.exp(-((xf - SUN[0]) / 380.0) ** 2) P.lift(under.astype(np.float32), 0.45) P.add(under.astype(np.float32), GOLD, 0.25) # a few thin wisps in the clear band wisp = S(0.86, 0.9, wc.noise(5, 300, octaves=3)) * S(ed + 30, ed + 60, yf) * (1 - S(330, 360, yf)) * sky wc.wet(P, wisp.astype(np.float32), LAV, strength=0.35, spread=2.5) # a long low bar of cloud across the sun; its underside catches the light bar_n = wc.noise(6, 260, octaves=3) yb = 372 + 10 * (wc.noise(40, 300, octaves=2) - 0.5) * 2 bar = S(yb - 16, yb - 8, yf) * (1 - S(yb + 2, yb + 7, yf)) * S(0.38, 0.52, bar_n) bar = wc.blur2(bar, 1.2, 4) bar *= S(380, 620, xf) * (1 - S(1320, 1480, xf)) P.add(bar.astype(np.float32), (150, 128, 140), 0.55, edge=0.3) rim = np.clip(bar - np.roll(bar, -3, axis=0), 0, 1) * np.exp(-((xf - SUN[0]) / 260.0) ** 2) P.lift(rim.astype(np.float32), 0.8) # far off to the left, rain is falling out of the canopy onto the sea streak = wc.noise(260, 7, octaves=3) slant = np.clip((yf - ed) / (HY - ed + 1e-3), 0, 1) vx = xf + 40 * slant region = S(-120, 120, vx) * (1 - S(380, 620, vx)) * (0.6 + 0.4 * wc.noise(90, 120, octaves=2)) veil = region * S(ed - 10, ed + 25, yf) * (1 - 0.6 * S(HY - 120, HY - 2, yf)) * (0.55 + 0.45 * streak) * sky veil = wc.blur2(veil, 5, 2) P.add(veil.astype(np.float32), (128, 128, 150), 0.36, granulate=0.2) isolated(_clouds) # the sun itself: a pale disc sunk into the haze, sitting on the far edge of the sea sd = np.hypot(xf - SUN[0], (yf - SUN[1]) * 1.0) P.lift((np.exp(-(sd / 40.0) ** 2) * sky).astype(np.float32), 0.6) P.lift((1 - S(11, 16, sd)).astype(np.float32), 0.9) P.add((1 - S(10, 17, sd)).astype(np.float32), (242, 226, 196), 0.35) P.add((np.exp(-(sd / 90.0) ** 2) * sky * S(10, 30, sd)).astype(np.float32), GOLD, 0.22) # distant headland, left, cool and low def _land(): n = wc.noise(8, 120, octaves=3) prof = HY - (1 + 24 * S(620, 30, xf) ** 1.6 + 5 * (n[0][None, :] - 0.5) * S(640, 300, xf)) land = S(prof - 1, prof + 1, yf) * (1 - S(HY - 1, HY + 1, yf)) * (1 - S(500, 660, xf)) land = wc.blur(land, 0.9) * (1 - 0.35 * S(HY - 7, HY, yf)) P.add(land.astype(np.float32), (132, 136, 154), 0.55, edge=0.25) P.add((land * (1 - S(120, 480, xf)) * (0.7 + 0.3 * wc.noise(10, 40, octaves=2))).astype(np.float32), (112, 114, 132), 0.22) isolated(_land) SKY_D = P.D.copy() # ================= pass 1b: the flats ================= # sand, darker as it comes toward us sand_cov = below * (0.35 + 0.65 * near ** 0.8) _st, _rs = random.getstate(), wc.rng.bit_generator.state flat1 = ground(wc.noise(30, 110, octaves=3)) P.add(wc.blur(sand_cov * (0.8 + 0.4 * flat1), 2).astype(np.float32), SAND, 0.9, granulate=0.12) flat2 = ground(wc.noise(60, 200, octaves=2)) P.add(wc.blur(below * near ** 1.6 * (0.65 + 0.35 * flat2), 4).astype(np.float32), VIOLET, 0.75) # keep the random stream exactly where the old wet() washes left it, so the pools downstream don't move random.setstate(_st); wc.rng.bit_generator.state = _rs _scr = wc.Paper.__new__(wc.Paper); _scr.__dict__ = dict(P.__dict__); _scr.D = P.D.copy() wc.wet(_scr, sand_cov.astype(np.float32), SAND, strength=0.9, spread=6, granulate=0.12) wc.wet(_scr, (below * near ** 1.6 * (0.6 + 0.4 * wc.noise(80, 300, octaves=2))).astype(np.float32), VIOLET, strength=0.75, spread=20) del _scr # wet shine under the sun: the sand itself goes light in a column toward us col_w = 60 + 380 * near shine = np.exp(-((xf - SUN[0]) / col_w) ** 2) * below * (1 - 0.55 * near) P.lift(shine.astype(np.float32), 0.7) # the one warm thing in the picture: the sun, and the road it lays on the wet sand def _sunroad(): gcol = np.exp(-((xf - SUN[0]) / (18 + 160 * near)) ** 2) * below * np.exp(-(yf - HY) / 170.0) P.add((gcol * (0.6 + 0.4 * ground(wc.noise(2, 60, octaves=3)))).astype(np.float32), GOLD, 0.3) P.add((np.exp(-(sd / 60.0) ** 2) * S(12, 26, sd) * sky).astype(np.float32), GOLD, 0.12) isolated(_sunroad) # ================= pass 2: water left on the flats ================= tex = 0.6 * wc.noise(22, 90, octaves=4) + 0.4 * wc.noise(60, 200, octaves=3) gt = ground(tex) thr = 0.56 - 0.16 * (1 - near) ** 2 pool = S(thr - 0.012, thr + 0.012, gt) * below far_sheet = S(HY + 8, HY + 2, yf) * below pool = np.maximum(pool, far_sheet) # the water mirrors the sky: row HY+d shows sky row HY-d ref_idx = np.clip(HY - (yf - HY) * np.where(yf - HY < 60, 1.0, 1.0 - 0.55 * S(60, 400, yf - HY)), 0, HY - 1).astype(int) SKY_FLIP = SKY_D[ref_idx, xx] P.D = P.D * (1 - pool[..., None] * 0.92) + SKY_FLIP * pool[..., None] * 0.95 # a dark lip where sand meets water on the far side of each pool lip = np.clip(pool - np.roll(pool, 2, axis=0), 0, 1) * below * S(0.35, 0.65, wc.noise(20, 60, octaves=3)) P.add(lip.astype(np.float32), UMBER, 0.3) fg = below * S(HY + 140, H, yf) * (1 - pool) mott = ground(wc.noise(40, 120, octaves=3)) P.add(wc.blur(fg * (0.78 + 0.22 * mott), 3).astype(np.float32), DEEP_WARM, 0.5, granulate=0.08) # darker damp patches, flattened on the plane, hard-edged where they dried dp = ground(0.65 * wc.noise(4, 40, octaves=4) + 0.35 * wc.noise(10, 110, octaves=3)) _q = dp[fg > 0.5] q1, q2 = float(np.quantile(_q, 0.6)), float(np.quantile(_q, 0.88)) damp = S(q1 - 0.006, q1 + 0.006, dp) * fg P.add(damp.astype(np.float32), VIOLET, 0.2, edge=0.25, edge_r=2.5, granulate=0.3) damp2 = S(q2 - 0.005, q2 + 0.005, dp) * fg P.add(damp2.astype(np.float32), DEEP_WARM, 0.12, edge=0.2, edge_r=2.0) # broad horizontal strokes, wet into wet: the plane is flat and it is wet bands = np.zeros((H, W), np.float32) for yb, wb, amp in [(640, 10, 1.0), (690, 16, -0.8), (760, 22, 0.9), (850, 30, -1.0), (930, 26, 0.8)]: cy = yb + 6 * np.sin(xf / 230.0 + yb) bands += amp * np.exp(-((yf - cy) / wb) ** 2) * (0.55 + 0.45 * wc.noise(20, 300, octaves=2)) P.add((np.clip(bands, 0, None) * fg).astype(np.float32), DEEP_WARM, 0.22) P.lift((np.clip(-bands, 0, None) * fg * 0.18).astype(np.float32), 1.0) warmth = ground(wc.noise(40, 260, octaves=2)) P.add((fg * S(0.45, 0.75, warmth) * 0.8).astype(np.float32), UMBER, 0.25) def _swipes(): # a few dry-brush passes where the wet sand catches the sky; the brush runs out of water toward the left hd = wc.noise(3.5, 240, octaves=2, persistence=0.45) acc = np.zeros((H, W), np.float32) for y0, x1, L, w in [(612, 1480, 900, 7), (655, 1300, 650, 5), (700, 1520, 1100, 9), (742, 820, 420, 4), (790, 1540, 700, 6)]: x0 = x1 - L wob = y0 + 3 * np.sin(xf[0] / 140.0 + y0)[None, :] tpos = np.clip((x1 - xf) / L, 0, 1) inside = (np.abs(yf - wob) < w * (0.7 + 0.5 * wc.noise(8, 50, octaves=2))) * (xf > x0) * (xf < x1) dry = 0.25 + 0.5 * tpos acc = np.maximum(acc, inside * S(dry - 0.015, dry + 0.015, 0.75 * hd + 0.25 * P.grain)) P.lift((acc * below).astype(np.float32), 0.45) # damp sheen: flat streaks in the sand catching a little of the sky sheen = S(0.603, 0.612, ground(0.7 * wc.noise(6, 110, octaves=4) + 0.3 * wc.noise(30, 200, octaves=2))) * below * (1 - pool) sheen = sheen * S(HY + 60, HY + 140, yf) * (1 - S(HY + 200, HY + 300, yf)) wc.dry(P, sheen.astype(np.float32), (255, 255, 255), strength=0.0) P.lift((sheen * (0.35 + 0.3 * P.grain)).astype(np.float32), 0.75) # ================= pass 4: the trail ================= # the footprints are the point of the picture: each one is a small pool, and each pool holds sky ZFIG = 42.0 XFIG = (SUN[0] - 30 - CX) * ZFIG / F Z0 = 3.6 def path_x(Z): t = (Z - Z0) / (ZFIG - Z0) base = -0.5 + (XFIG + 0.5) * (t ** 1.1) return base + 1.6 * math.sin(t * 5.2 + 0.2) * (1 - t) ** 1.6 FOOT = [(-0.5, 0.0), (-0.46, 0.26), (-0.32, 0.37), (-0.1, 0.36), (0.1, 0.42), (0.3, 0.5), (0.43, 0.42), (0.5, 0.18), (0.5, -0.08), (0.45, -0.34), (0.3, -0.5), (0.12, -0.42), (-0.04, -0.18), (-0.24, -0.28), (-0.42, -0.25)] def foot_poly(Xc, Zc, ang, side, scale=1.0): L, Wd = 0.28 * scale, 0.12 * scale pts = [] for fu, fv in FOOT: u, v = fu * L, fv * Wd * side dx = u * math.sin(ang) + v * math.cos(ang) dz = u * math.cos(ang) - v * math.sin(ang) X, Z = Xc + dx, Zc + dz pts.append((gx(X, Z), gy(Z))) return pts prints = np.zeros((H, W), np.float32) dark_in = np.zeros((H, W), np.float32) water = np.zeros((H, W), np.float32) Z = Z0 + 0.2 side = 1 while Z < ZFIG - 0.9: dZ = 0.01 ang = math.atan2(path_x(Z + dZ) - path_x(Z), dZ) off = 0.09 * side Xc = path_x(Z) + off * math.cos(ang) + random.gauss(0, 0.015) Zc = Z - off * math.sin(ang) a2 = ang + side * 0.08 + random.gauss(0, 0.04) pts = foot_poly(Xc, Zc, a2, side) m = wc.poly_mask(pts, aa=3) fill = random.uniform(0.55, 0.85) shift = random.uniform(0.0, 0.12) * 0.28 wp = foot_poly(Xc + shift * math.sin(a2), Zc + shift * math.cos(a2), a2, side, fill) wm = wc.poly_mask(wp, aa=3) * m # far prints melt into single sparks ht = gy(Zc) - gy(Zc + 0.27) m = wc.blur(m, 0.6) if ht > 3 else m prints = np.maximum(prints, m) water = np.maximum(water, wm if ht > 3 else m) sh = max(1, int(round(ht * 0.18))) dark_in = np.maximum(dark_in, np.clip(m - np.roll(m, sh, axis=0), 0, 1)) Z += 0.72 * random.uniform(0.94, 1.06) side = -side # the prints are water, and the water mirrors the sky pr_idx = np.clip(HY - 40 - 0.25 * (yf - HY), 0, HY - 1).astype(int) PR_SKY = SKY_D[pr_idx, xx] P.add(wc.blur(prints, 1.0), DEEP_WARM, 0.15) P.D = P.D * (1 - water[..., None] * 0.97) + PR_SKY * water[..., None] * 0.95 P.add(wc.blur(dark_in, 0.8), DEEP_WARM, 0.4) # ================= pass 5: the walker ================= def _walker(): fx, fy = gx(XFIG, ZFIG), gy(ZFIG) h = F * 1.72 / ZFIG fig = np.zeros((H, W), np.float32) # back leg: heel lifted, trailing a little; front leg planted fig = np.maximum(fig, wc.stroke_mask([(fx - 0.03 * h, fy - 0.47 * h), (fx - 0.06 * h, fy - 0.24 * h), (fx - 0.035 * h, fy - 0.07 * h), (fx - 0.06 * h, fy - 0.035 * h)], 0.075 * h, 0.04 * h, taper=False, rough=0.04)) fig = np.maximum(fig, wc.stroke_mask([(fx + 0.04 * h, fy - 0.47 * h), (fx + 0.055 * h, fy - 0.24 * h), (fx + 0.05 * h, fy - 0.01 * h)], 0.08 * h, 0.045 * h, taper=False, rough=0.04)) # coat: shoulders to just below the hips, soft-edged body = [(fx - 0.115 * h, fy - 0.80 * h), (fx - 0.12 * h, fy - 0.62 * h), (fx - 0.10 * h, fy - 0.43 * h), (fx + 0.11 * h, fy - 0.43 * h), (fx + 0.125 * h, fy - 0.62 * h), (fx + 0.11 * h, fy - 0.80 * h), (fx + 0.05 * h, fy - 0.845 * h), (fx - 0.05 * h, fy - 0.845 * h)] fig = np.maximum(fig, wc.poly_mask(body, aa=4)) # arms close to the body, one a touch forward fig = np.maximum(fig, wc.stroke_mask([(fx - 0.11 * h, fy - 0.78 * h), (fx - 0.135 * h, fy - 0.60 * h), (fx - 0.13 * h, fy - 0.47 * h)], 0.06 * h, 0.045 * h, taper=False, rough=0.04)) fig = np.maximum(fig, wc.stroke_mask([(fx + 0.11 * h, fy - 0.78 * h), (fx + 0.13 * h, fy - 0.61 * h), (fx + 0.115 * h, fy - 0.49 * h)], 0.06 * h, 0.045 * h, taper=False, rough=0.04)) # neck and head fig = np.maximum(fig, wc.stroke_mask([(fx, fy - 0.83 * h), (fx + 0.005 * h, fy - 0.88 * h)], 0.06 * h, 0.06 * h, taper=False, rough=0.0)) hd = np.hypot((xf - fx - 0.006 * h) / 0.058, (yf - (fy - 0.925 * h)) / 0.068) / h fig = np.maximum(fig, 1 - S(0.85, 1.05, hd)) fig = np.clip(wc.blur(fig, 0.55), 0, 1) # the light eats the edge of anything standing in front of it halo = np.clip(wc.blur(fig, 1.2) - fig * 0.0, 0, 1) inner = np.clip(fig - np.clip(wc.blur(1 - fig, 0.9) * fig * 2.2, 0, 1), 0, 1) P.lift(fig, 0.7) P.add(inner * 0.35 + fig * 0.65, DEEP, 1.15) P.add(fig * S(fy - 0.45 * h, fy, yf), DEEP_WARM, 0.35) # reflection on the wet sand: flipped at the feet, broken, lighter rows = np.clip(2 * fy - yf, 0, H - 1).astype(int) ref = fig[rows, xx] * (yf > fy) dx = ((wc.noise(1.6, 18, octaves=2) - 0.5) * 5).astype(np.float32) ref = map_coordinates(ref, [yf, xf + dx], order=1) ref = wc.blur2(ref, 1.4, 0.6) * (1 - S(fy, fy + 1.05 * h, yf)) P.add(ref.astype(np.float32), (70, 64, 78), 0.75) isolated(_walker) # the corners of the near sand close in, gently corner = (np.clip(np.hypot((xf - CX) / 900.0, (yf - 430) / 700.0) - 0.6, 0, 1) ** 1.5) * below P.add(wc.blur(corner, 20).astype(np.float32), VIOLET, 0.5) grain = wc.grain_from_profile(os.path.join(ENGINE_DIR, "pigment_profile.npy"), seed=3) P.D = np.stack([wc.blur(P.D[..., c], 0.5) for c in range(3)], -1) im = P.render(paper_strength=0.65, pigment_tex=grain, tex_amount=0.055) im.save(OUT) print("done", OUT, time.time() - t0)