← back to Quadrille Showroom
scripts/derive-floor-maps.py
79 lines
#!/usr/bin/env python3
"""
derive-floor-maps.py — derive a tangent-space NORMAL map + a subtle ROUGHNESS map
from public/textures/floor-oak.png so the oak plank grain catches grazing light.
100% local (PIL + numpy) — $0, no API. Output is seamless-preserving: the source
SDXL oak is tileable, and Sobel + wrap-mode padding keep the derived maps tileable
too, so the floor's RepeatWrapping tiling stays seam-free.
floor-oak.png -> floor-oak-normal.png (RGB tangent-space normal)
-> floor-oak-rough.png (grayscale roughness variation)
Run: python3 scripts/derive-floor-maps.py
"""
import os, sys
import numpy as np
from PIL import Image, ImageFilter
HERE = os.path.dirname(os.path.abspath(__file__))
TEX = os.path.join(HERE, '..', 'public', 'textures')
SRC = os.path.join(TEX, 'floor-oak.png')
# --- tunables -------------------------------------------------------------
STRENGTH = 2.4 # normal-map relief strength; higher = deeper grain
BLUR = 1.0 # pre-blur the height a touch to kill single-pixel noise
ROUGH_BASE = 0.50 # matches MAT.floor roughness in showroom.js
ROUGH_VAR = 0.22 # +/- swing of roughness around base from grain darkness
# -------------------------------------------------------------------------
def wrap_sobel(h):
"""Sobel gradients with WRAP padding so the result stays seamlessly tileable."""
# np.roll gives wrap-around neighbours for free (toroidal), preserving tiling.
gx = ( np.roll(h, -1, axis=1) - np.roll(h, 1, axis=1) ) \
+ 0.5*( np.roll(np.roll(h,-1,0),-1,1) - np.roll(np.roll(h,-1,0),1,1) ) \
+ 0.5*( np.roll(np.roll(h, 1,0),-1,1) - np.roll(np.roll(h, 1,0),1,1) )
gy = ( np.roll(h, -1, axis=0) - np.roll(h, 1, axis=0) ) \
+ 0.5*( np.roll(np.roll(h,-1,1),-1,0) - np.roll(np.roll(h,-1,1),1,0) ) \
+ 0.5*( np.roll(np.roll(h, 1,1),-1,0) - np.roll(np.roll(h, 1,1),1,0) )
return gx, gy
def main():
if not os.path.exists(SRC):
print('MISSING source:', SRC); sys.exit(1)
img = Image.open(SRC).convert('RGB')
W, Hh = img.size
# Luminance heightmap. Oak grain lines are darker -> read as recessed grooves.
lum = np.asarray(img.convert('L').filter(ImageFilter.GaussianBlur(BLUR)), dtype=np.float32) / 255.0
gx, gy = wrap_sobel(lum)
gx *= STRENGTH; gy *= STRENGTH
# Tangent-space normal: N = normalize(-gx, -gy, 1). Grooves point inward.
nz = np.ones_like(gx)
nx, ny = -gx, -gy
inv = 1.0 / np.sqrt(nx*nx + ny*ny + nz*nz)
nx *= inv; ny *= inv; nz *= inv
# Encode [-1,1] -> [0,255]. +Y up convention (OpenGL / three.js default).
nrm = np.stack([ (nx*0.5+0.5), (ny*0.5+0.5), (nz*0.5+0.5) ], axis=-1)
nrm = np.clip(nrm*255.0, 0, 255).astype(np.uint8)
Image.fromarray(nrm, 'RGB').save(os.path.join(TEX, 'floor-oak-normal.png'))
# Roughness: darker grain = slightly rougher (matte groove), bright plank face =
# smoother satin sheen. Centre on ROUGH_BASE so it matches the material.
inv_lum = 1.0 - lum # dark grain -> high
inv_lum = (inv_lum - inv_lum.mean()) # zero-centre the variation
sd = inv_lum.std() + 1e-6
rough = ROUGH_BASE + (inv_lum / (3.0*sd)) * ROUGH_VAR
rough = np.clip(rough, 0.30, 0.80)
rmap = np.clip(rough*255.0, 0, 255).astype(np.uint8)
Image.fromarray(rmap, 'L').save(os.path.join(TEX, 'floor-oak-rough.png'))
print('OK floor-oak-normal.png (%dx%d, strength %.1f)' % (W, Hh, STRENGTH))
print('OK floor-oak-rough.png (base %.2f +/- %.2f)' % (ROUGH_BASE, ROUGH_VAR))
print('$0 (local PIL+numpy)')
if __name__ == '__main__':
main()