Added: Initial world generation tool
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
"""Reading, writing and resampling heightmaps with nothing but numpy, so the authoring scripts run on the
|
||||
engine's own Python (which has no PIL). Greyscale PNG in 8 or 16 bit, raw 16-bit little-endian (.r16 / .raw,
|
||||
what World Machine, Gaea and the engine's own exporter write), bilinear resampling and a centred square crop:
|
||||
enough to take a real heightmap from any of the usual sources and put it on the landscape.
|
||||
"""
|
||||
import math
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
import numpy as np
|
||||
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
def write_png(path, data):
|
||||
"""Greyscale PNG, 8 or 16 bit from the array dtype. Row filter 0, one zlib stream."""
|
||||
if data.dtype == np.uint16:
|
||||
depth, payload = 16, data.astype(">u2")
|
||||
else:
|
||||
depth, payload = 8, data.astype(np.uint8)
|
||||
height, width = data.shape
|
||||
raw = b"".join(b"\x00" + payload[y].tobytes() for y in range(height))
|
||||
|
||||
def chunk(kind, body):
|
||||
return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF)
|
||||
|
||||
ihdr = struct.pack(">IIBBBBB", width, height, depth, 0, 0, 0, 0)
|
||||
with open(path, "wb") as f:
|
||||
f.write(PNG_SIGNATURE + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b""))
|
||||
|
||||
|
||||
def read_png_header(path):
|
||||
"""(width, height, bit_depth, colour_type) without decoding the image."""
|
||||
with open(path, "rb") as f:
|
||||
head = f.read(8 + 8 + 13)
|
||||
if head[:8] != PNG_SIGNATURE or head[12:16] != b"IHDR":
|
||||
raise ValueError(f"{path}: not a PNG")
|
||||
width, height, depth, colour_type = struct.unpack(">IIBB", head[16:26])
|
||||
return width, height, depth, colour_type
|
||||
|
||||
|
||||
def _unfilter_sequential(filter_type, row, prev, bpp):
|
||||
"""Average and Paeth depend on the byte just decoded, so they go pixel by pixel. Rare in practice; a
|
||||
4081x4081 16-bit file with every row Paeth-filtered takes some tens of seconds, once, on import."""
|
||||
out = bytearray(row)
|
||||
n = len(out)
|
||||
if filter_type == 3:
|
||||
for i in range(n):
|
||||
left = out[i - bpp] if i >= bpp else 0
|
||||
out[i] = (out[i] + ((left + prev[i]) >> 1)) & 0xFF
|
||||
else:
|
||||
for i in range(n):
|
||||
if i >= bpp:
|
||||
a, c = out[i - bpp], prev[i - bpp]
|
||||
else:
|
||||
a, c = 0, 0
|
||||
b = prev[i]
|
||||
p = a + b - c
|
||||
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
|
||||
if pa <= pb and pa <= pc:
|
||||
predictor = a
|
||||
elif pb <= pc:
|
||||
predictor = b
|
||||
else:
|
||||
predictor = c
|
||||
out[i] = (out[i] + predictor) & 0xFF
|
||||
return out
|
||||
|
||||
|
||||
def read_png(path):
|
||||
"""The first channel of a non-interlaced PNG as a 2D uint8 or uint16 array (greyscale, grey+alpha, RGB
|
||||
and RGBA are accepted; palette and interlaced files are not)."""
|
||||
with open(path, "rb") as f:
|
||||
blob = f.read()
|
||||
if blob[:8] != PNG_SIGNATURE:
|
||||
raise ValueError(f"{path}: not a PNG")
|
||||
pos, idat, ihdr = 8, [], None
|
||||
while pos + 8 <= len(blob):
|
||||
length, kind = struct.unpack(">I4s", blob[pos:pos + 8])
|
||||
body = blob[pos + 8:pos + 8 + length]
|
||||
pos += 12 + length
|
||||
if kind == b"IHDR":
|
||||
ihdr = struct.unpack(">IIBBBBB", body)
|
||||
elif kind == b"IDAT":
|
||||
idat.append(body)
|
||||
elif kind == b"IEND":
|
||||
break
|
||||
if ihdr is None:
|
||||
raise ValueError(f"{path}: no IHDR")
|
||||
width, height, depth, colour_type, _, _, interlace = ihdr
|
||||
channels = {0: 1, 2: 3, 4: 2, 6: 4}.get(colour_type)
|
||||
if channels is None or depth not in (8, 16) or interlace != 0:
|
||||
raise ValueError(f"{path}: unsupported PNG (colour type {colour_type}, {depth} bit, interlace {interlace}); "
|
||||
"use a non-interlaced 8 or 16 bit greyscale or RGB file")
|
||||
bytes_per_sample = depth // 8
|
||||
bpp = channels * bytes_per_sample
|
||||
stride = width * bpp
|
||||
data = zlib.decompress(b"".join(idat))
|
||||
if len(data) != height * (stride + 1):
|
||||
raise ValueError(f"{path}: PNG data is {len(data)} bytes, expected {height * (stride + 1)}")
|
||||
|
||||
rows = np.empty((height, stride), dtype=np.uint8)
|
||||
prev = np.zeros(stride, dtype=np.uint8)
|
||||
for y in range(height):
|
||||
start = y * (stride + 1)
|
||||
filter_type = data[start]
|
||||
row = np.frombuffer(data, dtype=np.uint8, count=stride, offset=start + 1)
|
||||
if filter_type == 0:
|
||||
out = row.copy()
|
||||
elif filter_type == 1:
|
||||
out = (np.cumsum(row.reshape(width, bpp), axis=0, dtype=np.uint64) & 0xFF).astype(np.uint8).reshape(stride)
|
||||
elif filter_type == 2:
|
||||
out = ((row.astype(np.uint16) + prev) & 0xFF).astype(np.uint8)
|
||||
elif filter_type in (3, 4):
|
||||
out = np.frombuffer(bytes(_unfilter_sequential(filter_type, bytes(row), bytes(prev), bpp)), dtype=np.uint8)
|
||||
else:
|
||||
raise ValueError(f"{path}: bad PNG filter {filter_type} on row {y}")
|
||||
rows[y] = out
|
||||
prev = rows[y]
|
||||
|
||||
dtype = ">u2" if depth == 16 else np.uint8
|
||||
samples = rows.reshape(height, width * channels * bytes_per_sample).view(dtype).reshape(height, width, channels)
|
||||
first = samples[:, :, 0]
|
||||
return first.astype(np.uint16) if depth == 16 else first.astype(np.uint8)
|
||||
|
||||
|
||||
def read_r16(path, width=None):
|
||||
"""Raw 16-bit little-endian samples, square unless a width is given."""
|
||||
values = np.fromfile(path, dtype="<u2")
|
||||
if width is None:
|
||||
width = math.isqrt(len(values))
|
||||
if width * width != len(values):
|
||||
raise ValueError(f"{path}: {len(values)} samples is not a square; give the width in the manifest")
|
||||
if len(values) % width != 0:
|
||||
raise ValueError(f"{path}: {len(values)} samples do not divide by width {width}")
|
||||
return values.reshape(len(values) // width, width).astype(np.uint16)
|
||||
|
||||
|
||||
def read_heightmap(path, width=None):
|
||||
"""Any supported file as a 2D uint16 array with the full 0..65535 range (8-bit files are widened)."""
|
||||
lower = path.lower()
|
||||
if lower.endswith(".png"):
|
||||
values = read_png(path)
|
||||
return values.astype(np.uint16) * 257 if values.dtype == np.uint8 else values
|
||||
if lower.endswith((".r16", ".raw")):
|
||||
return read_r16(path, width)
|
||||
raise ValueError(f"{path}: unknown heightmap format; use 16-bit PNG or raw .r16")
|
||||
|
||||
|
||||
def center_crop_square(values):
|
||||
height, width = values.shape
|
||||
side = min(height, width)
|
||||
y0, x0 = (height - side) // 2, (width - side) // 2
|
||||
return values[y0:y0 + side, x0:x0 + side]
|
||||
|
||||
|
||||
def block_mean(values, factor):
|
||||
"""Downsample by an integer factor with a box filter, trimming the edge that does not divide."""
|
||||
height, width = values.shape
|
||||
height, width = height // factor * factor, width // factor * factor
|
||||
trimmed = values[:height, :width].astype(np.float32)
|
||||
return trimmed.reshape(height // factor, factor, width // factor, factor).mean(axis=(1, 3))
|
||||
|
||||
|
||||
def resample(values, size):
|
||||
"""Bilinear resample of a 2D array to size x size, box-filtered first when shrinking by 2x or more."""
|
||||
source = values.astype(np.float32)
|
||||
factor = min(source.shape) // size
|
||||
if factor >= 2:
|
||||
source = block_mean(source, factor)
|
||||
src_h, src_w = source.shape
|
||||
if (src_h, src_w) == (size, size):
|
||||
return source
|
||||
ys = np.linspace(0.0, src_h - 1, size, dtype=np.float32)
|
||||
xs = np.linspace(0.0, src_w - 1, size, dtype=np.float32)
|
||||
y0 = np.floor(ys).astype(np.int64)
|
||||
x0 = np.floor(xs).astype(np.int64)
|
||||
y1 = np.minimum(y0 + 1, src_h - 1)
|
||||
x1 = np.minimum(x0 + 1, src_w - 1)
|
||||
ty = (ys - y0)[:, None]
|
||||
tx = (xs - x0)[None, :]
|
||||
top = source[np.ix_(y0, x0)] * (1 - tx) + source[np.ix_(y0, x1)] * tx
|
||||
bottom = source[np.ix_(y1, x0)] * (1 - tx) + source[np.ix_(y1, x1)] * tx
|
||||
return (top * (1 - ty) + bottom * ty).astype(np.float32)
|
||||
Reference in New Issue
Block a user