"""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="= 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)