#!/usr/bin/env python3 """Derive tangent-space normal maps from albedo textures (Pillow only). python3 tools/make_normal_maps.py assets/textures/ai/game_ready/brick_facade.png ... Writes _normal.png next to each input. The height signal is the luminance, so mortar/aggregate recesses catch the light; edges wrap for tiling. """ import sys from pathlib import Path from PIL import Image STRENGTH = 2.0 def normal_map(source: Path) -> Path: image = Image.open(source).convert("L") width, height = image.size pixels = list(image.getdata()) out = bytearray(width * height * 3) def lum(x: int, y: int) -> float: return pixels[(y % height) * width + (x % width)] for y in range(height): row = y * width for x in range(width): dx = (lum(x + 1, y) - lum(x - 1, y)) / 255.0 dy = (lum(x, y + 1) - lum(x, y - 1)) / 255.0 nx = -dx * STRENGTH ny = -dy * STRENGTH nz = 1.0 length = (nx * nx + ny * ny + nz * nz) ** 0.5 offset = (row + x) * 3 out[offset] = int((nx / length * 0.5 + 0.5) * 255) out[offset + 1] = int((ny / length * 0.5 + 0.5) * 255) out[offset + 2] = int((nz / length * 0.5 + 0.5) * 255) normal = Image.frombytes("RGB", (width, height), bytes(out)) target = source.with_name(source.stem + "_normal.png") normal.save(target) print(f"wrote {target}") return target def main(argv=None) -> int: paths = [Path(p) for p in (argv if argv is not None else sys.argv[1:])] if not paths: print(__doc__, file=sys.stderr) return 2 for path in paths: normal_map(path) return 0 if __name__ == "__main__": sys.exit(main())