#!/usr/bin/env python3 """ Aggressive pixel art wall weathering with zoomed comparison output. """ import colorsys import random from noise import pnoise2 from PIL import Image, ImageDraw, ImageFont def make_noise_map(w, h, seed=0, scale=0.08, octaves=3): """Generate a 2D Perlin noise map normalized to 0.0-1.0""" nmap = [] for y in range(h): row = [] for x in range(w): # pnoise2 returns roughly -1 to 1, normalize to 0-1 val = pnoise2( x * scale + seed * 100, y * scale + seed * 100, octaves=octaves, persistence=0.5, lacunarity=2.0, ) row.append((val + 1.0) / 2.0) # normalize to 0-1 nmap.append(row) return nmap def rgb_to_hsv(r, g, b): h, s, v = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255) return h * 360, s * 100, v * 100 def hsv_to_rgb(h, s, v): r, g, b = colorsys.hsv_to_rgb(h / 360, s / 100, v / 100) return (int(r * 255), int(g * 255), int(b * 255)) def shift_color(rgb, hue_shift=0, sat_shift=0, val_shift=0): h, s, v = rgb_to_hsv(*rgb) h = (h + hue_shift) % 360 s = max(0, min(100, s + sat_shift)) v = max(0, min(100, v + val_shift)) return hsv_to_rgb(h, s, v) def is_mortar(pixel, threshold=120): return pixel[1] < threshold def is_bright_brick(pixel, threshold=180): return pixel[1] > threshold def apply_weathering(img, noise_seed=0): result = img.copy() w, h = img.size random.seed(42 + noise_seed) # Generate noise maps at different scales for different effects # Coarse noise: large blotchy weathering patterns noise_coarse = make_noise_map(w, h, seed=noise_seed, scale=0.06, octaves=2) # Fine noise: small pixel-level variation noise_fine = make_noise_map(w, h, seed=noise_seed + 50, scale=0.15, octaves=3) # PASS 1: Deepen mortar intersections - MORE AGGRESSIVE for y in range(h): for x in range(w): px = img.getpixel((x, y)) if not is_mortar(px): continue mortar_neighbors = 0 for dx, dy in [ (-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (1, -1), (-1, 1), (1, 1), ]: nx, ny = (x + dx) % w, (y + dy) % h if is_mortar(img.getpixel((nx, ny))): mortar_neighbors += 1 if mortar_neighbors >= 6: # Deep blue-shifted shadow new_px = shift_color(px, hue_shift=25, sat_shift=15, val_shift=-20) result.putpixel((x, y), new_px) elif mortar_neighbors >= 4: new_px = shift_color(px, hue_shift=12, sat_shift=8, val_shift=-10) result.putpixel((x, y), new_px) # PASS 2: Warm highlights on brick edges for y in range(h): for x in range(w): px = img.getpixel((x, y)) if is_mortar(px): continue above = img.getpixel((x, (y - 1) % h)) left = img.getpixel(((x - 1) % w, y)) is_top_edge = is_mortar(above) is_left_edge = is_mortar(left) if is_top_edge or is_left_edge: random.seed(hash((x // 6, y // 4)) + 99) if random.random() < 0.4: # Warm yellow highlight new_px = shift_color(px, hue_shift=-15, sat_shift=5, val_shift=10) result.putpixel((x, y), new_px) random.seed(42) # PASS 3: Vary whole brick colors - MORE VISIBLE for y in range(h): for x in range(w): px = img.getpixel((x, y)) if is_mortar(px): continue brick_id = (x // 12, y // 8) random.seed(hash(brick_id) + 42) roll = random.random() if roll < 0.2: # Darker, warmer brick current = result.getpixel((x, y)) new_px = shift_color(current, hue_shift=8, sat_shift=6, val_shift=-12) result.putpixel((x, y), new_px) elif roll < 0.35: # Slightly cooler brick current = result.getpixel((x, y)) new_px = shift_color(current, hue_shift=-8, sat_shift=4, val_shift=-5) result.putpixel((x, y), new_px) random.seed(42 + noise_seed) # PASS 4: Full-height darkening gradient (rising damp / ground grime) # Modulated by Perlin noise so the damp line isn't perfectly horizontal GRADIENT_EXPONENT = 3.5 GRADIENT_MAX_DARKEN = -30 GRADIENT_MAX_HUE_COOL = 25 GRADIENT_MAX_SAT_BUMP = 12 for y in range(h): progress = y / (h - 1) curve = progress**GRADIENT_EXPONENT for x in range(w): # Noise pushes the gradient up or down per-pixel # coarse noise warps the damp line by +/- 30% of its intensity noise_mod = 0.7 + noise_coarse[y][x] * 0.6 # range: 0.7 to 1.3 modulated = min(1.0, curve * noise_mod) px = result.getpixel((x, y)) val_drop = GRADIENT_MAX_DARKEN * modulated hue_cool = GRADIENT_MAX_HUE_COOL * modulated sat_bump = GRADIENT_MAX_SAT_BUMP * modulated new_px = shift_color( px, hue_shift=hue_cool, sat_shift=sat_bump, val_shift=val_drop ) result.putpixel((x, y), new_px) # PASS 5: Stain patches - BIGGER AND MORE VISIBLE num_stains = 6 for _ in range(num_stains): cx = random.randint(0, w - 1) cy = random.randint(0, h - 1) radius = random.randint(3, 7) for dy in range(-radius, radius + 1): for dx in range(-radius, radius + 1): dist = (dx * dx + dy * dy) ** 0.5 if dist > radius: continue nx, ny = (cx + dx) % w, (cy + dy) % h if is_mortar(img.getpixel((nx, ny))): continue intensity = 1.0 - (dist / radius) current = result.getpixel((nx, ny)) new_px = shift_color( current, hue_shift=8, sat_shift=6, val_shift=-10 * intensity ) result.putpixel((nx, ny), new_px) # PASS 6: Crack line (one diagonal crack) crack_x = random.randint(w // 4, 3 * w // 4) crack_y = random.randint(0, h // 3) for i in range(12): cx = crack_x + random.choice([-1, 0, 0, 1]) cy = crack_y + i crack_x = cx if 0 <= cx < w and 0 <= cy < h: px = result.getpixel((cx, cy)) if not is_mortar(img.getpixel((cx, cy))): # Dark cool crack pixel crack_color = shift_color(px, hue_shift=20, sat_shift=10, val_shift=-25) result.putpixel((cx, cy), crack_color) # PASS 7: Moss specks in mortar - MORE OF THEM random.seed(77 + noise_seed) for y in range(h // 3, h): for x in range(w): px = img.getpixel((x, y)) if not is_mortar(px): continue depth = (y - h // 3) / (2 * h // 3) # Noise modulates where moss appears moss_chance = 0.06 * depth * (0.5 + noise_fine[y][x]) if random.random() < moss_chance: current = result.getpixel((x, y)) moss = shift_color(current, hue_shift=-80, sat_shift=20, val_shift=-10) result.putpixel((x, y), moss) # PASS 8: Perlin noise surface grime overlay # Fine noise adds subtle per-pixel value/hue variation across brick faces # This is the pass that makes each seed look distinctly different for y in range(h): for x in range(w): px = img.getpixel((x, y)) if is_mortar(px): continue # Only affect brick faces current = result.getpixel((x, y)) n = noise_fine[y][x] # 0.0 to 1.0 # Map noise to subtle shifts: center around 0 # n=0.5 means no change, n=0 means darken, n=1 means lighten slightly centered = n - 0.5 # -0.5 to 0.5 val_shift = centered * 10 # -5 to +5 value hue_shift = centered * 8 # -4 to +4 hue sat_shift = abs(centered) * 4 # darker areas get slightly more saturated new_px = shift_color( current, hue_shift=hue_shift, sat_shift=sat_shift, val_shift=val_shift ) result.putpixel((x, y), new_px) return result def main(): img = Image.open( "/home/saarsena/godot_projects/trench/textures/td_sliced/BRICK_1A.png" ).convert("RGB") w, h = img.size # Generate 4 variants with different noise seeds variants = [] for seed in range(4): variant = apply_weathering(img, noise_seed=seed) variant.save(f"./weathered_wall_v{seed}.png") variants.append(variant) print(f"Generated variant {seed}") # Create a 2x2 grid comparison of all 4 variants, zoomed 6x zoom = 6 zw, zh = w * zoom, h * zoom grid = Image.new("RGB", (zw * 2 + 4, zh * 2 + 4), (30, 30, 30)) for i, v in enumerate(variants): zoomed = v.resize((zw, zh), Image.NEAREST) col = i % 2 row = i // 2 grid.paste(zoomed, (col * (zw + 4), row * (zh + 4))) grid.save("./wall_variants_grid.png") # Also make a side-by-side original vs variant 0 orig_zoomed = img.resize((zw, zh), Image.NEAREST) weath_zoomed = variants[0].resize((zw, zh), Image.NEAREST) gap = 20 comparison = Image.new("RGB", (zw * 2 + gap, zh + 40), (30, 30, 30)) comparison.paste(orig_zoomed, (0, 40)) comparison.paste(weath_zoomed, (zw + gap, 40)) draw = ImageDraw.Draw(comparison) draw.text((zw // 2 - 30, 10), "ORIGINAL", fill=(200, 200, 200)) draw.text((zw + gap + zw // 2 - 35, 10), "WEATHERED", fill=(200, 200, 200)) comparison.save("./wall_comparison.png") print("Saved: 4 variants, grid comparison, and side-by-side") if __name__ == "__main__": main()