#!/usr/bin/env python3 """PROCITY Lane E — seamless_tile.py (R39) Make a REGULAR-PATTERN skin tile without a seam, by cropping it to a whole number of its own periods instead of blurring the joint. Why this exists: every ground skin in `web/assets/gen/` tiles through `RepeatWrapping` with the tile scale baked into the mesh UVs (`ground.js` TILE = 5 m). Grass and bitumen hide the joint because they have no structure. A TILED FLOOR does not — the arcade's 42 x 12 m lane is 504 m2, the single biggest surface in the district, and a terrazzo grid whose lines jump at every repeat reads as a mistake rather than as a floor. The usual fix (offset + feather) is wrong for a grid: it smears the grout lines. The right fix is arithmetic — find the pattern's pitch and cut on it. PY=~/Documents/MODELBEAST/venvs/mflux/bin/python $PY pipeline/seamless_tile.py IN.png OUT.png [--check OUT_3x3.png] Pitch is found by autocorrelating the mean-absolute column/row gradient (a grout line is a gradient spike; the spacing of the spikes IS the pitch), searching 24..W/3 px. Prints the residual seam error so the result is falsifiable: the mean |difference| between the left and right edge columns before and after. If the image has no periodic structure the correlation is flat and the script says so and copies the input through rather than cropping something arbitrary. """ import sys import numpy as np from PIL import Image def pitch(sig, lo=24): """Dominant period of a 1-D signal, by normalised autocorrelation.""" x = sig - sig.mean() n = len(x) hi = max(lo + 1, n // 3) ac = np.correlate(x, x, mode="full")[n - 1:] ac = ac / (ac[0] or 1) band = ac[lo:hi] if band.max() < 0.12: # no periodic structure worth cutting on return None, float(band.max()) return int(lo + band.argmax()), float(band.max()) def edge_err(a, axis): """Mean |difference| across the wrap seam, 0-255.""" if axis == 1: return float(np.abs(a[:, 0].astype(np.float32) - a[:, -1].astype(np.float32)).mean()) return float(np.abs(a[0].astype(np.float32) - a[-1].astype(np.float32)).mean()) def main(): src, dst = sys.argv[1], sys.argv[2] im = Image.open(src).convert("RGB") a = np.asarray(im).astype(np.float32) g = a.mean(axis=2) gx = np.abs(np.diff(g, axis=1)).mean(axis=0) # column gradient profile -> vertical lines gy = np.abs(np.diff(g, axis=0)).mean(axis=1) # row gradient profile -> horizontal lines px, cx = pitch(gx) py, cy = pitch(gy) before = (edge_err(np.asarray(im), 1), edge_err(np.asarray(im), 0)) print(f"in {im.size} pitch x={px} (r={cx:.2f}) y={py} (r={cy:.2f}) seam before x={before[0]:.1f} y={before[1]:.1f}") if px is None and py is None: print("no periodic structure found — passing through unchanged") im.save(dst) return # PER-AXIS, AND ONLY IF IT ACTUALLY HELPS. Measured on the R39 arcade pair: a diffusion-drawn # tiled floor is NOT periodic enough to cut on (autocorrelation peaked at r=0.26/0.30 and the # "period" it found was not the grout pitch), and cropping to it made the seam WORSE — 27.9→31.9 # across x. So the crop is a proposal that has to beat the measurement it is trying to improve, # per axis, or it is discarded. A tool that can only make a number worse is worse than no tool. def try_axis(p, axis, full): if not p or full // p < 2: return full, None return (full // p) * p, None W, _ = try_axis(px, 1, im.width) H, _ = try_axis(py, 0, im.height) cand = im.crop(((im.width - W) // 2, (im.height - H) // 2, (im.width - W) // 2 + W, (im.height - H) // 2 + H)) c = np.asarray(cand) trial = (edge_err(c, 1), edge_err(c, 0)) keep_x, keep_y = trial[0] < before[0], trial[1] < before[1] if not keep_x: W = im.width if not keep_y: H = im.height x0, y0 = (im.width - W) // 2, (im.height - H) // 2 out = im.crop((x0, y0, x0 + W, y0 + H)) b = np.asarray(out) after = (edge_err(b, 1), edge_err(b, 0)) print(f"out {out.size} keep x={keep_x} y={keep_y} seam after x={after[0]:.1f} y={after[1]:.1f}" + ("" if (keep_x or keep_y) else " ← NO IMPROVEMENT AVAILABLE, passed through")) out.save(dst) if "--check" in sys.argv: chk = sys.argv[sys.argv.index("--check") + 1] t = Image.new("RGB", (out.width * 3, out.height * 3)) for i in range(3): for j in range(3): t.paste(out, (i * out.width, j * out.height)) t.resize((out.width, out.height), Image.LANCZOS).save(chk) print(f"3x3 tiling check -> {chk}") main()