PROCITY/pipeline/seamless_tile.py
m3ultra 30b86a9ef7 Lane E R39 (2/n): THE ARCADE, MEASURED THEN DRESSED — 3 skins live ($0, on-device, green), and the instance count taken BEFORE generating
MEASURED FIRST (pipeline/arcade_measure.mjs, over 5 seeds, off Lane A's own generatePlan):
  the arcade is ONE 42.00 m x 5 m edge, 2 blocks, 16-17 shops (17 at the default seed), frontage
  3.00-4.94 m (mean 3.87-4.05), depth 5.1-8.0 m, 4-11 of them two-storey. Walkable 5.00 m between
  lot faces. AWNING_DEPTH 2.2 from each face means the two slabs already meet 0.60 m apart over the
  centreline — the arcade IS roofed, and the roof is an accident.
  ⚠ AND THE POSTS ARE IN THE WRONG PLACE: buildings.js:643 puts them 2.00 m out from each lot face,
  i.e. +/-0.50 m from the centreline — two rows of posts 1.00 m apart down a 5.00 m lane. No
  collider, so it walks; it just reads as a colonnade planted in its own doorway. Lane B ask filed.

SHIPPED (all riding existing shared materials or stating their draw):
  facade-shuttered   768x595 (the 1.29:1 arcade lot aspect, not the pool's 1.78:1) — the dead
                     tenant. +0 draws, +0 tris, 1 of the 14 FREE facade-atlas slots (22 of 36 used).
  ground-arcade-floor 512^2 terrazzo — +1 draw TOWN-WIDE, +0 tris (the 504 m2 lane quad moves out
                     of footGeos). use:'arcade-floor' names a slot that does not exist yet, on purpose.
  ground-arcade-roof  512^2 pressed-metal + wired glass — +1 draw in the 1-2 arcade chunks, +2 tris.

pipeline/seamless_tile.py: cut a regular-pattern skin to a whole number of its own periods instead
of feathering the joint. It has a guard, because on this pair the naive version made things WORSE:
the roof's x seam went 26.7 -> 8.3 (kept), the diffusion-drawn terrazzo floor is not periodic enough
to cut on (autocorr r=0.26) and 27.9 -> 31.9 was rejected and passed through. Recorded, not hidden.

manifest: facades 48->49, grounds 14->16. validate_manifest.py 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:46:43 +10:00

102 lines
4.6 KiB
Python

#!/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()