"""Stitch the fetched landscapes into one continuous world for The Walk. Each painting is scaled so its horizon lands near the same screen height (HZ_MIN..HZ_MAX); where a painting is nearly all ground (Snap the Whip) the sky is extended upward, where it is nearly all sky (Salisbury) the ground is extended downward, so no painting loses its subject. Seams are baked: each segment's left edge is a 240px crossfade from the previous painting. python tools/build-walk.py -> walk/seg/*.jpg, walk/world.json, walk/raw/strip.png """ import json, os import numpy as np from PIL import Image, ImageFilter, ImageDraw ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) RAW = os.path.join(ROOT, "walk", "raw"); SEG = os.path.join(ROOT, "walk", "seg") os.makedirs(SEG, exist_ok=True) H = 1080; HZ_MIN, HZ_MAX = 440, 600; BLEND = 240; MAXW = 2600 # the day's walk, west to east, morning to evening ORDER = ["cropsey", "snapwhip", "oxbow", "lakegeorge", "landers", "andes", "heade", "jalais", "salisbury", "hobbema", "ruisdael", "cypresses", "victoire", "peaceplenty"] HORIZON_FIX = {"andes": 0.50, "salisbury": 0.72, "durand": 0.50, "landers": 0.50, "cypresses": 0.58} # how tall a person is per pixel of ground below the horizon: high viewpoints make small people K_DEFAULT = 0.5 K_FIX = {"cropsey": 0.28, "snapwhip": 0.7, "oxbow": 0.22, "lakegeorge": 0.3, "landers": 0.2, "andes": 0.22, "heade": 0.3, "jalais": 0.36, "salisbury": 0.8, "hobbema": 0.5, "ruisdael": 0.45, "cypresses": 0.5, "victoire": 0.28, "peaceplenty": 0.2} CREDIT = "The Met, Open Access (CC0)" def extend_up(im, n): """Grow the sky upward: mirror the top rows, blur them soft, feather into the original.""" top = im.crop((0, 0, im.width, min(im.height, n + 40))).transpose(Image.FLIP_TOP_BOTTOM) top = top.resize((im.width, n + 40)).filter(ImageFilter.GaussianBlur(18)) out = Image.new("RGB", (im.width, im.height + n)); out.paste(top, (0, 0)); out.paste(im, (0, n)) a = np.asarray(out).astype(np.float32); t = np.asarray(top).astype(np.float32) for r in range(40): # feather the join w = 1 - r / 40; a[n + r] = a[n + r] * (1 - w) + t[n + r] * w return Image.fromarray(a.astype(np.uint8)) def extend_down(im, n): """Grow the ground downward: mirror the bottom rows, blur lightly, feather.""" bot = im.crop((0, max(0, im.height - n - 40), im.width, im.height)).transpose(Image.FLIP_TOP_BOTTOM) bot = bot.resize((im.width, n + 40)).filter(ImageFilter.GaussianBlur(3)) out = Image.new("RGB", (im.width, im.height + n)); out.paste(im, (0, 0)); out.paste(bot, (0, im.height - 40)) a = np.asarray(out).astype(np.float32); o = np.asarray(im).astype(np.float32) for r in range(40): w = 1 - r / 40; y = im.height - 40 + r; a[y] = o[y] * w + a[y] * (1 - w) return Image.fromarray(a.astype(np.uint8)) def prepare(slug, m): im = Image.open(os.path.join(RAW, slug + ".jpg")).convert("RGB") hor = HORIZON_FIX.get(slug, m["horizon"]) natural = hor * H if natural < HZ_MIN: # too much ground: fit the ground, grow the sky yh = HZ_MIN; s = (H - yh) / ((1 - hor) * im.height) im = im.resize((round(im.width * s), round(im.height * s)), Image.LANCZOS) im = extend_up(im, H - im.height) elif natural > HZ_MAX: # too much sky: fit the sky, grow the ground yh = HZ_MAX; s = yh / (hor * im.height) im = im.resize((round(im.width * s), round(im.height * s)), Image.LANCZOS) im = extend_down(im, H - im.height) else: yh = round(natural); s = H / im.height im = im.resize((round(im.width * s), H), Image.LANCZOS) if im.height != H: im = im.resize((im.width, H), Image.LANCZOS) if im.width > MAXW: # keep very wide canvases walkable in reasonable time im = im.crop(((im.width - MAXW) // 2, 0, (im.width - MAXW) // 2 + MAXW, H)) return im, yh def main(): manifest = json.load(open(os.path.join(RAW, "manifest.json"), encoding="utf-8")) prepared = [(slug, *prepare(slug, manifest[slug])) for slug in ORDER if slug in manifest] segments, x = [], 0 prev = None for i, (slug, im, yh) in enumerate(prepared): arr = np.asarray(im).astype(np.float32) if prev is not None: tail = np.asarray(prev).astype(np.float32)[:, -BLEND:] ramp = np.linspace(0, 1, BLEND, dtype=np.float32)[None, :, None] arr[:, :BLEND] = tail * (1 - ramp) + arr[:, :BLEND] * ramp out = Image.fromarray(arr.astype(np.uint8)) w = out.width if i == len(prepared) - 1 else out.width - BLEND out = out.crop((0, 0, w, H)) fname = f"seg/{i:02d}-{slug}.jpg" out.save(os.path.join(ROOT, "walk", fname), "JPEG", quality=82, optimize=True, progressive=True) m = manifest[slug] segments.append({"slug": slug, "file": fname, "x": x, "w": w, "hz": yh, "k": K_FIX.get(slug, K_DEFAULT), "title": m["title"], "artist": m["artist"].split(",")[0].strip(), "date": m["date"], "credit": CREDIT, "url": m["url"]}) print(f"{i:02d} {slug:12s} x={x:6d} w={w:5d} hz={yh} {os.path.getsize(os.path.join(ROOT, 'walk', fname))//1024} KB") x += w; prev = im world = {"width": x, "segments": segments} json.dump(world, open(os.path.join(ROOT, "walk", "world.json"), "w", encoding="utf-8"), indent=1, ensure_ascii=False) # review strip: 3 rows, x labels every 500 world px rows = 3; per = (x + rows - 1) // rows; sc = 1800 / per strip = Image.new("RGB", (1800, rows * (int(H * sc) + 30)), (15, 15, 15)); d = ImageDraw.Draw(strip) for s in segments: im = Image.open(os.path.join(ROOT, "walk", s["file"])).convert("RGB") for r in range(rows): x0, x1 = r * per, (r + 1) * per a, b = max(s["x"], x0), min(s["x"] + s["w"], x1) if b <= a: continue crop = im.crop((a - s["x"], 0, b - s["x"], H)).resize((max(1, int((b - a) * sc)), int(H * sc))) strip.paste(crop, (int((a - x0) * sc), r * (int(H * sc) + 30))) for r in range(rows): y0 = r * (int(H * sc) + 30) for wx in range(r * per, (r + 1) * per, 500): px = int((wx - r * per) * sc); d.line([(px, y0), (px, y0 + int(H * sc))], fill=(255, 255, 0) if wx % 1000 == 0 else (255, 255, 255), width=1) d.text((px + 2, y0 + int(H * sc) + 4), str(wx), fill=(255, 255, 0)) strip.save(os.path.join(RAW, "strip.png")); print("world width", x, "strip:", os.path.join(RAW, "strip.png")) if __name__ == "__main__": main()