294 lines
10 KiB
Python
294 lines
10 KiB
Python
|
|
#!/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()
|