"""terrain_ops.py — слой ПОСТОБРАБОТКИ heightmap между CVAE и рендером. Зачем: выход CVAE гладкий/низкочастотный (пиксельный лосс + усреднение латента → размытость — известное свойство VAE). Эти операции добавляют геоморфологическую структуру (долины, гребни, осыпи, изломанные хребты) ПОВЕРХ макроформы CVAE. Концептуальная рамка (честно для защиты): CVAE задаёт управляемую МАКРОСТРУКТУРУ (где горы, какой тип, какой масштаб — это её вклад и метрики). Постобработка УСИЛИВАЕТ эту форму, а не создаёт свою (так делают StyleDEM и pro terrain-tools). Эрозия держится умеренной; проверяется, что низкочастотная форма осталась от модели (см. macroform_correlation). Всё — чистый numpy/scipy, без Blender. Каждая операция принимает heightmap [H,W] float и возвращает обработанный heightmap того же размера. Алгоритм гидравлической эрозии адаптирован из общеизвестных реализаций (Sebastian Lague / dandrino terrain-erosion-3-ways / Job Talle): droplet-based — капля катится по градиенту, набирает/осаждает осадок, прорезает русла и гребни. """ from __future__ import annotations import numpy as np from scipy.ndimage import gaussian_filter # -------------------------------------------------------------------------- def normalize01(h: np.ndarray) -> np.ndarray: h = h.astype(np.float64) lo, hi = float(h.min()), float(h.max()) return (h - lo) / (hi - lo + 1e-12) def _make_brush(radius: int, shape): """Веса кисти эрозии (распределяем эрозию по окрестности — без пик-артефактов). Возвращает (offsets [K,2] int, weights [K]) — смещения вокруг ячейки и веса (1 - dist/radius), нормированные к сумме 1. """ H, W = shape offs, wts = [], [] r = int(radius) for dy in range(-r, r + 1): for dx in range(-r, r + 1): d = np.hypot(dx, dy) if d <= radius: offs.append((dy, dx)) wts.append(1.0 - d / radius) offs = np.array(offs, np.int64) wts = np.array(wts, np.float64) wts /= wts.sum() return offs, wts def _height_and_grad(h, x, y): """Билинейная высота + градиент в точке (x, y) непрерывных координат.""" H, W = h.shape x0 = int(x); y0 = int(y) fx = x - x0; fy = y - y0 x1 = min(x0 + 1, W - 1); y1 = min(y0 + 1, H - 1) h00 = h[y0, x0]; h10 = h[y0, x1]; h01 = h[y1, x0]; h11 = h[y1, x1] # градиент (по Лагью): интерполяция разностей по краям ячейки gx = (h10 - h00) * (1 - fy) + (h11 - h01) * fy gy = (h01 - h00) * (1 - fx) + (h11 - h10) * fx height = (h00 * (1 - fx) * (1 - fy) + h10 * fx * (1 - fy) + h01 * (1 - fx) * fy + h11 * fx * fy) return height, gx, gy # -------------------------------------------------------------------------- def hydraulic_erosion( h: np.ndarray, num_droplets: int = 60000, *, seed: int = 0, lifetime: int = 30, inertia: float = 0.05, sediment_capacity: float = 4.0, min_slope: float = 0.01, erode_speed: float = 0.3, deposit_speed: float = 0.3, evaporation: float = 0.01, gravity: float = 4.0, erosion_radius: int = 3, init_speed: float = 1.0, init_water: float = 1.0, ) -> np.ndarray: """Droplet-based гидравлическая эрозия. Вход/выход — heightmap [H,W]. Нормируется в [0,1] внутри (параметры калиброваны под этот диапазон), на выходе масштабируется обратно в исходный диапазон значений входа. num_droplets ~ 30-60k на 256x256. Больше капель -> выраженнее русла/гребни. """ lo, hi = float(h.min()), float(h.max()) H, W = h.shape hm = ((h.astype(np.float64) - lo) / (hi - lo + 1e-12)).copy() # [0,1] rng = np.random.default_rng(seed) brush_off, brush_w = _make_brush(erosion_radius, (H, W)) for _ in range(num_droplets): x = rng.uniform(1, W - 2) y = rng.uniform(1, H - 2) dx = dy = 0.0 speed = init_speed water = init_water sediment = 0.0 for _step in range(lifetime): x0 = int(x); y0 = int(y) fx = x - x0; fy = y - y0 height, gx, gy = _height_and_grad(hm, x, y) # направление с инерцией dx = dx * inertia - gx * (1 - inertia) dy = dy * inertia - gy * (1 - inertia) mag = np.hypot(dx, dy) if mag < 1e-8: break dx /= mag; dy /= mag nx = x + dx; ny = y + dy if nx < 1 or nx >= W - 1 or ny < 1 or ny >= H - 1: break new_height, _, _ = _height_and_grad(hm, nx, ny) dh = new_height - height # <0 = вниз capacity = max(-dh, min_slope) * speed * water * sediment_capacity if sediment > capacity or dh > 0: # осаждение (вверх — сбрасываем; избыток осадка — осаждаем) if dh > 0: amt = min(dh, sediment) else: amt = (sediment - capacity) * deposit_speed sediment -= amt # билинейно в 4 ячейки текущей позиции hm[y0, x0] += amt * (1 - fx) * (1 - fy) hm[y0, min(x0 + 1, W - 1)] += amt * fx * (1 - fy) hm[min(y0 + 1, H - 1), x0] += amt * (1 - fx) * fy hm[min(y0 + 1, H - 1), min(x0 + 1, W - 1)] += amt * fx * fy else: # эрозия (не больше, чем перепад вниз; распределяем кистью) amt = min((capacity - sediment) * erode_speed, -dh) ys = y0 + brush_off[:, 0]; xs = x0 + brush_off[:, 1] ok = (ys >= 0) & (ys < H) & (xs >= 0) & (xs < W) take = amt * brush_w[ok] # не уносим ниже нуля take = np.minimum(take, hm[ys[ok], xs[ok]]) hm[ys[ok], xs[ok]] -= take sediment += take.sum() speed = np.sqrt(max(0.0, speed * speed + dh * gravity)) water *= (1 - evaporation) x, y = nx, ny hm = np.clip(hm, 0.0, None) hm = (hm - hm.min()) / (hm.max() - hm.min() + 1e-12) # ренормируем [0,1] return (hm * (hi - lo) + lo).astype(np.float32) # обратно в диапазон входа # --- 3. термальная эрозия (осыпи на крутых склонах) ----------------------- def thermal_erosion(h, iterations=60, talus=0.004, factor=0.5): """Где локальный перепад к соседу > угла естественного откоса (talus) — часть материала осыпается вниз. Несколько итераций -> прямые осыпные (scree) склоны. Векторизовано (8 соседей через np.roll), материал сохраняется.""" lo, hi = float(h.min()), float(h.max()) hm = ((h.astype(np.float64) - lo) / (hi - lo + 1e-12)).copy() for _ in range(iterations): out = np.zeros_like(hm) inflow = np.zeros_like(hm) for axis, sh in [(1, 1), (1, -1), (0, 1), (0, -1)]: nb = np.roll(hm, -sh, axis=axis) # сосед в направлении +sh d = hm - nb move = np.where(d > talus, (d - talus) * factor * 0.25, 0.0) out += move inflow += np.roll(move, sh, axis=axis) # материал приходит к соседу hm = hm - out + inflow hm = (hm - hm.min()) / (hm.max() - hm.min() + 1e-12) return (hm * (hi - lo) + lo).astype(np.float32) # --- 4. нелинейное усиление пиков (power transform) ----------------------- def power_transform(h, gamma=0.7): """h_norm ** gamma. gamma<1 поднимает вершины (острые пики), gamma>1 прижимает. Долины (near 0) почти не меняются, вершины резко выше.""" lo, hi = float(h.min()), float(h.max()) hn = np.clip((h.astype(np.float64) - lo) / (hi - lo + 1e-12), 0, 1) return (hn ** float(gamma) * (hi - lo) + lo).astype(np.float32) # --- 5. domain warping (изломанные гребни вместо плавных волн) ------------- def domain_warp(h, strength=7.0, noise_scale=16.0, seed=0): """Сэмплируем h по ИСКАЖЁННЫМ координатам: warped(x,y)=h(x+a*n1, y+a*n2). Гладкие волны -> изломанные гребни. strength в пикселях, noise_scale=sigma шума.""" from scipy.ndimage import map_coordinates H, W = h.shape r = np.random.default_rng(seed) ox = gaussian_filter(r.standard_normal((H, W)), noise_scale) oy = gaussian_filter(r.standard_normal((H, W)), noise_scale) ox /= (np.abs(ox).max() + 1e-9); oy /= (np.abs(oy).max() + 1e-9) yy, xx = np.mgrid[0:H, 0:W].astype(np.float64) warped = map_coordinates(h.astype(np.float64), [yy + strength * oy, xx + strength * ox], order=1, mode="reflect") return warped.astype(np.float32) # --- 6. маска деталей по уклону/кривизне (детали в логичных местах) -------- def detail_by_curvature(h, amp=0.06, detail_sigma=1.1, slope_gain=2.2, seed=0): """Высокочастотная деталь ТОЛЬКО на склонах/гребнях (маска по уклону), равнины и долины остаются гладкими -> бьёт в «шум везде как пластилин».""" lo, hi = float(h.min()), float(h.max()) hn = (h.astype(np.float64) - lo) / (hi - lo + 1e-12) gy, gx = np.gradient(hn) slope = np.hypot(gx, gy) mask = np.clip(slope / (slope.max() + 1e-9) * slope_gain, 0, 1) r = np.random.default_rng(seed) noise = gaussian_filter(r.standard_normal(h.shape), detail_sigma) noise -= noise.mean(); noise /= (np.abs(noise).max() + 1e-9) out = hn + amp * mask * noise out = (out - out.min()) / (out.max() - out.min() + 1e-12) return (out * (hi - lo) + lo).astype(np.float32) # --- комбинированный конвейер --------------------------------------------- def full_pipeline(h, *, gamma=0.8, warp_strength=6.0, droplets=70000, thermal_iters=50, detail_amp=0.05, seed=0): """Лучшая комбинация (порядок важен): warp -> power -> hydraulic -> thermal -> detail. Сначала ломаем гребни и поднимаем пики, потом режем русла, осыпаем склоны и добавляем мелкую фактуру только на склонах.""" x = domain_warp(h, strength=warp_strength, seed=seed) x = power_transform(x, gamma=gamma) x = hydraulic_erosion(x, num_droplets=droplets, seed=seed, lifetime=40, erode_speed=0.4, sediment_capacity=6.0, min_slope=0.001, erosion_radius=2, deposit_speed=0.25) x = thermal_erosion(x, iterations=thermal_iters, talus=0.005, factor=0.5) x = detail_by_curvature(x, amp=detail_amp, seed=seed) return x # --- 2. сила обработки ПО КЛАССУ (решает «типы не различаются») ------------ def process_by_class(h, terrain_type, seed=0): """Разная постобработка по типу рельефа -> типы становятся СТРУКТУРНО разными, не только по амплитуде (по мотивам Minecraft worldgen: эрозия решает плоское vs гористое). Термальная держится ЛЁГКОЙ, чтобы не съесть русла эрозии. flat — почти не трогаем (gamma прижимает в равнину, чуть эрозии); hilly — слабая эрозия + мягкая gamma -> пологие складки; mountain — warp + острые пики + сильная эрозия -> выраженные гребни/долины. """ t = terrain_type if t == "flat": x = power_transform(h, gamma=2.2) x = hydraulic_erosion(x, num_droplets=8000, seed=seed, lifetime=28, erode_speed=0.3, sediment_capacity=4.0, min_slope=0.002, erosion_radius=2) return detail_by_curvature(x, amp=0.02, seed=seed) if t == "hilly": x = domain_warp(h, strength=5.0, noise_scale=16.0, seed=seed) x = power_transform(x, gamma=1.4) x = hydraulic_erosion(x, num_droplets=35000, seed=seed, lifetime=35, erode_speed=0.35, sediment_capacity=5.0, min_slope=0.0015, erosion_radius=2) x = thermal_erosion(x, iterations=6, talus=0.006, factor=0.5) return detail_by_curvature(x, amp=0.035, seed=seed) # mountain x = domain_warp(h, strength=7.0, noise_scale=13.0, seed=seed) x = power_transform(x, gamma=0.72) x = hydraulic_erosion(x, num_droplets=85000, seed=seed, lifetime=42, erode_speed=0.45, sediment_capacity=7.0, min_slope=0.001, erosion_radius=2, deposit_speed=0.25) x = thermal_erosion(x, iterations=6, talus=0.007, factor=0.5) # очень лёгкая: не съесть русла return detail_by_curvature(x, amp=0.05, seed=seed) # -------------------------------------------------------------------------- def macroform_correlation(h_raw: np.ndarray, h_proc: np.ndarray, sigma: float = 8.0) -> float: """Корреляция НИЗКОЧАСТОТНЫХ форм raw и обработанного (контроль: эрозия усилила, а не подменила макроформу CVAE). Близко к 1 = крупная форма сохранена.""" a = gaussian_filter(normalize01(h_raw), sigma) b = gaussian_filter(normalize01(h_proc), sigma) a = a - a.mean(); b = b - b.mean() denom = np.sqrt((a * a).sum() * (b * b).sum()) + 1e-12 return float((a * b).sum() / denom) # -------------------------------------------------------------------------- def quick_stats(h: np.ndarray) -> dict: """Грубые метрики для быстрой проверки (полный набор — в terrain_metrics.py).""" hn = normalize01(h) gy, gx = np.gradient(hn) slope = np.hypot(gx, gy) return { "roughness_grad": float(slope.mean()), "slope_p90": float(np.quantile(slope, 0.90)), "std_local": float(hn.std()), }