#!/usr/bin/env python3 """生成 DreamWaQ 10×20 纯 hfield 地形——OpenCV 绘制。 5 种地形类型 × 10 难度,全部在单张 PNG 高度图中。 楼梯用 1px riser 近垂直面(HS=0.05 时每像素 5cm)。 用法: uv run python3 scripts/gen_dreamwaq_terrain.py """ import cv2 import numpy as np import os import argparse # ═══ 参数 ═══ HS = 0.05 # 水平分辨率 [m/px] VS = 0.005 # 垂直分辨率 [m/unit] CELL_M = 8.0 NUM_ROWS = 10 NUM_COLS = 20 BORDER_M = 5.0 PROPORTIONS = [0.1, 0.1, 0.35, 0.35, 0.1] CUM = [sum(PROPORTIONS[:i + 1]) for i in range(len(PROPORTIONS))] PLATFORM_M = 3.0 _SLOPE_SCALE = 0.25 # MotrixSim-friendly slope range ROUGH_GRID_M = 0.20 # Correlate roughness over 20 cm, not each 5 cm pixel. ROUGH_BASE_M = 0.01 ROUGH_GAIN_M = 0.03 OBSTACLE_BASE_M = 0.03 OBSTACLE_GAIN_M = 0.10 CELL_PX = int(CELL_M / HS) # 160 BORDER_PX = int(BORDER_M / HS) # 100 PLATFORM_PX = int(PLATFORM_M / HS) # 60 TOT_ROWS_PX = NUM_ROWS * CELL_PX + 2 * BORDER_PX # 1800 TOT_COLS_PX = NUM_COLS * CELL_PX + 2 * BORDER_PX # 3400 TOTAL_X = TOT_COLS_PX * HS TOTAL_Y = TOT_ROWS_PX * HS # ═══ 地形绘制 ═══ def draw_slope(canvas, x0, y0, difficulty, noise=False): """平滑/粗糙斜坡——与上游 pyramid_sloped_terrain 对齐。 上游逻辑:先建金字塔(中心高→边缘低),再用平台边缘高度 clip 整个 terrain, 形成与周围地形齐平的平台(而非硬清零到 0)。 """ if difficulty <= 0: return slope = difficulty * _SLOPE_SCALE max_h = int(slope * (1.0 / VS) * (CELL_M / 2.0)) if max_h <= 0: return cx, cy = CELL_PX // 2, CELL_PX // 2 x = np.arange(0, CELL_PX) y = np.arange(0, CELL_PX) xx, yy = np.meshgrid(x, y, sparse=True) xx = (cx - np.abs(cx - xx)) / cx yy = (cy - np.abs(cy - yy)) / cy hf = (max_h * xx.reshape(CELL_PX, 1) * yy.reshape(1, CELL_PX)).astype(np.int32) p2 = PLATFORM_PX // 2 # 上游 clip: 取平台边缘高度作为上下界 edge_h = int(hf[cx - p2, cy - p2]) lo = min(edge_h, 0) hi = max(edge_h, 0) hf = np.clip(hf, lo, hi).astype(np.uint16) if noise: # Independent 5 cm samples created 10 cm jumps between adjacent # pixels. Interpolate a 20 cm grid for physically coherent roughness. coarse_px = max(2, int(round(ROUGH_GRID_M / HS))) amp = ROUGH_BASE_M + ROUGH_GAIN_M * difficulty coarse = np.random.uniform( -amp, amp, (coarse_px, coarse_px)).astype(np.float32) n = cv2.resize(coarse, (CELL_PX, CELL_PX), interpolation=cv2.INTER_LINEAR) n[cx - p2:cx + p2, cy - p2:cy + p2] = 0 hf = np.clip(hf.astype(np.float32) * VS + n, 0, None) hf = np.rint(hf / VS).astype(np.uint16) canvas[y0:y0 + CELL_PX, x0:x0 + CELL_PX] += hf def draw_pyramid_stairs(canvas, x0, y0, difficulty, concave=False): """金字塔楼梯——OpenCV 同心矩形(近垂直 riser)。 每级台阶 2px 宽(10cm tread),高度缩放保持 z_scale < 0.54。 """ if difficulty <= 0: return # 上游公式:step_height = 0.05 + 0.18 * difficulty [m] step_h_m = 0.05 + 0.18 * difficulty step_h = max(1, int(step_h_m / VS)) cx = x0 + CELL_PX // 2 cy = y0 + CELL_PX // 2 p2 = PLATFORM_PX // 2 # 上游踏面 31cm → 6px (HS=0.05), 最多约 8 级 tread_px = max(1, int(0.31 / HS)) n_steps = min(8, (CELL_PX // 2 - p2) // tread_px) if concave: base_h = step_h * n_steps cv2.rectangle(canvas, (x0, y0), (x0 + CELL_PX, y0 + CELL_PX), int(base_h), -1) for i in range(n_steps + 1): half = p2 + (n_steps - i) * tread_px h = int(base_h - step_h * i) cv2.rectangle(canvas, (cx - half, cy - half), (cx + half, cy + half), h, -1) else: for i in range(n_steps + 1): half = p2 + (n_steps - i) * tread_px h = int(step_h * i) cv2.rectangle(canvas, (cx - half, cy - half), (cx + half, cy + half), h, -1) def draw_obstacles(canvas, x0, y0, difficulty): """离散障碍物(随机矩形块)。""" if difficulty <= 0: return max_h = int((OBSTACLE_BASE_M + OBSTACLE_GAIN_M * difficulty) / VS) if max_h <= 0: return p2 = PLATFORM_PX // 2 # 上游: min_size=1.0m, max_size=2.0m, 20 个矩形 min_sz = int(1.0 / HS); max_sz = int(2.0 / HS) for _ in range(20): w = np.random.randint(min_sz, max_sz + 1) ln = np.random.randint(min_sz, max_sz + 1) si = np.random.randint(0, CELL_PX - w) sj = np.random.randint(0, CELL_PX - ln) cv2.rectangle(canvas, (x0 + si, y0 + sj), (x0 + si + w, y0 + sj + ln), int(np.random.choice([max_h // 2, max_h])), -1) cx, cy = x0 + CELL_PX // 2, y0 + CELL_PX // 2 cv2.rectangle(canvas, (cx - p2, cy - p2), (cx + p2, cy + p2), 0, -1) # ═══ 主流程 ═══ def main(): p = argparse.ArgumentParser() p.add_argument("--flat-only", action="store_true") p.add_argument("--max-level", type=int, default=None) args = p.parse_args() max_row = NUM_ROWS if args.max_level is None else min(args.max_level + 1, NUM_ROWS) print(f"DreamWaQ 纯 hfield ({max_row}×{NUM_COLS}) {TOT_COLS_PX}×{TOT_ROWS_PX}px") canvas = np.zeros((TOT_ROWS_PX, TOT_COLS_PX), dtype=np.uint16) for row in range(max_row): difficulty = row / NUM_ROWS for col in range(NUM_COLS): if args.flat_only or difficulty == 0: continue x0 = BORDER_PX + col * CELL_PX y0 = BORDER_PX + row * CELL_PX choice = col / NUM_COLS + 0.001 if choice < CUM[0]: draw_slope(canvas, x0, y0, difficulty) elif choice < CUM[1]: draw_slope(canvas, x0, y0, difficulty, noise=True) elif choice < CUM[2]: draw_pyramid_stairs(canvas, x0, y0, difficulty, concave=True) elif choice < CUM[3]: draw_pyramid_stairs(canvas, x0, y0, difficulty, concave=False) else: draw_obstacles(canvas, x0, y0, difficulty) hf_m = canvas.astype(np.float32) * VS z_min, z_max = float(hf_m.min()), float(hf_m.max()) z_range = max(z_max - z_min, 0.001) print(f" 高度范围: [{z_min:.3f}, {z_max:.3f}]m z_scale={z_range:.3f}") if z_range > 0.54: print(f" ⚠ z_scale={z_range:.3f} > 0.54!") out_d = os.path.join(os.path.dirname(__file__), "..", "motrix_envs", "src", "motrix_envs", "locomotion", "go1", "xmls", "assets") os.makedirs(out_d, exist_ok=True) png = ((hf_m - z_min) / z_range * 65535.0).astype(np.uint16) cv2.imwrite(os.path.join(out_d, "dreamwaq_terrain.png"), png) # XML xml = f""" """ xml_dir = os.path.join(os.path.dirname(__file__), "..", "motrix_envs", "src", "motrix_envs", "locomotion", "go1", "xmls") with open(os.path.join(xml_dir, "scene_dreamwaq_terrain.xml"), "w") as f: f.write(xml) half_x = TOTAL_X / 2 half_y = TOTAL_Y / 2 print(f" XML: size=\"{half_x:.1f} {half_y:.1f} {z_range:.3f} {max(z_min, 0.001):.3f}\"") print(f" 楼梯: 1px tread (5cm), 1px riser → 近垂直面") if __name__ == "__main__": np.random.seed(42) main()