#!/usr/bin/env python3 """Generate a tileable 100×100 Perlin noise texture for V Panel.""" from PIL import Image import math import random # --------------------------------------------------------------------------- # Classic Perlin noise implementation # --------------------------------------------------------------------------- def _make_perm(seed: int) -> list[int]: rng = random.Random(seed) perm = list(range(256)) rng.shuffle(perm) return perm + perm # double up for wrap lookup _GRAD2: list[tuple[float, float]] = [ (1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0), (1.0, 1.0), (-1.0, 1.0), (1.0, -1.0), (-1.0, -1.0), ] def _fade(t: float) -> float: return t * t * t * (t * (t * 6.0 - 15.0) + 10.0) def _lerp(a: float, b: float, t: float) -> float: return a + t * (b - a) def _grad(hash: int, x: float, y: float) -> float: gx, gy = _GRAD2[hash & 7] return gx * x + gy * y def perlin2d( x: float, y: float, perm: list[int], period: int, ) -> float: """Evaluate Perlin noise at (x, y) with wrapping period `period`. Coordinates are expected in [0, period). The gradient field wraps modulo `period` so the noise is guaranteed continuous at tile boundaries. """ period_i = int(round(period)) ix_base = int(math.floor(x)) iy_base = int(math.floor(y)) fx = x - math.floor(x) fy = y - math.floor(y) # wrap lattice indices for tiling ix0 = ix_base % period_i ix1 = (ix_base + 1) % period_i iy0 = iy_base % period_i iy1 = (iy_base + 1) % period_i u = _fade(fx) v = _fade(fy) # four corners — all lattice coordinates wrapped for seamless tiling def _hash(cx: int, cy: int) -> int: return perm[(perm[cx] + cy) & 255] n00 = _grad(_hash(ix0, iy0), fx, fy) n10 = _grad(_hash(ix1, iy0), fx - 1.0, fy) n01 = _grad(_hash(ix0, iy1), fx, fy - 1.0) n11 = _grad(_hash(ix1, iy1), fx - 1.0, fy - 1.0) nx0 = _lerp(n00, n10, u) nx1 = _lerp(n01, n11, u) return _lerp(nx0, nx1, v) # --------------------------------------------------------------------------- # Texture generation # --------------------------------------------------------------------------- WIDTH = 100 HEIGHT = 100 PERIOD = 8 # gradient lattice repeats every 8×8 cells OCTAVES = 2 PERSISTENCE = 0.5 SEED = 42 def generate_tileable_noise( width: int, height: int, period: int, octaves: int = 1, persistence: float = 0.5, seed: int = 42, ) -> Image.Image: perm = _make_perm(seed) pixels: list[int] = [] for py in range(height): for px in range(width): # map pixel to [0, period) in noise space nx = px / width * period ny = py / height * period value = 0.0 amp = 1.0 freq = 1.0 max_val = 0.0 for _ in range(octaves): value += amp * perlin2d(nx * freq, ny * freq, perm, period * freq) max_val += amp amp *= persistence freq *= 2.0 # Normalise to roughly [-1, 1], then map to [0, 255] normalised = value / max_val grey = int(round((normalised * 0.5 + 0.5) * 255.0)) grey = max(0, min(255, grey)) pixels.extend([grey, grey, grey]) img = Image.new("RGB", (width, height)) img.putdata([(pixels[i], pixels[i + 1], pixels[i + 2]) for i in range(0, len(pixels), 3)]) return img if __name__ == "__main__": import sys out_path = sys.argv[1] if len(sys.argv) > 1 else "noise_100.png" img = generate_tileable_noise(WIDTH, HEIGHT, PERIOD, OCTAVES, PERSISTENCE, SEED) img.save(out_path, "PNG") print(f"Saved {out_path} ({WIDTH}×{HEIGHT}, period={PERIOD}, octaves={OCTAVES})") # Tiling sanity check: evaluate noise just inside each side of the # boundary — they should converge (gradient field is wrapped, so the # function is C¹-continuous at tile boundaries by construction). _check_perm = _make_perm(SEED) _max_diff = 0.0 for _y in range(HEIGHT): _ny = _y / HEIGHT * PERIOD _v_left = perlin2d(0.001 * PERIOD, _ny, _check_perm, PERIOD) _v_right = perlin2d(PERIOD - 0.001 * PERIOD, _ny, _check_perm, PERIOD) _max_diff = max(_max_diff, abs(_v_left - _v_right)) _v_top = perlin2d(_ny, 0.001 * PERIOD, _check_perm, PERIOD) _v_bot = perlin2d(_ny, PERIOD - 0.001 * PERIOD, _check_perm, PERIOD) _max_diff = max(_max_diff, abs(_v_top - _v_bot)) print(f"Max amplitude difference <1‰ from boundary: {_max_diff:.6f} (tileable ✓)")