"""Validated, dependency-free browser training/deployment contract (version 1).""" from __future__ import annotations import copy import math import random from typing import Any FLAT_TASK = "Unitree-Go2-Flat" ROUGH_TASK = "Unitree-Go2-Rough" OBSTACLE_TASK = "Unitree-Go2-ObstacleAvoidance" TERRAIN_PRESETS = ("plane", "discrete_obstacles", "rough", "pyramid_stairs", "wave", "custom_boxes") # Range metadata is also the sole validation source for browser configuration. TERRAIN_PARAMETERS = { "size": {"min": 8, "max": 24, "default": 12}, "obstacle_count": {"min": 1, "max": 100, "default": 24, "integer": True}, "obstacle_height_min": {"min": 0.05, "max": 1.5, "default": 0.2}, "obstacle_height_max": {"min": 0.05, "max": 1.5, "default": 0.6}, "spacing": {"min": 0.6, "max": 3, "default": 1.2}, "friction": {"min": 0.2, "max": 2, "default": 0.8}, "roughness": {"min": 0.01, "max": 0.2, "default": 0.06}, "step_height": {"min": 0.03, "max": 0.2, "default": 0.08}, "wave_amplitude": {"min": 0.01, "max": 0.2, "default": 0.08}, } SENSOR_PARAMETERS = { "fov": {"min": 30, "max": 120, "default": 90}, "maxDistance": {"min": 1, "max": 5, "default": 4}, "safetyDistance": {"min": 0.1, "max": 1, "default": 0.5}, "avoidanceWeight": {"min": 0, "max": 10, "default": 2}, } JOINT_NAMES = [ f"{leg}_{joint}_joint" for leg in ("FL", "FR", "RL", "RR") for joint in ("hip", "thigh", "calf") ] DEFAULT_JOINT_POSITION = [-0.1, 0.9, -1.8, 0.1, 0.9, -1.8] * 2 class TaskConfigError(ValueError): pass def _parameters(value: Any, schema: dict, label: str) -> dict: if not isinstance(value, dict) or value.keys() - schema.keys(): raise TaskConfigError(f"{label} 包含未知参数或不是对象") result = {} for name, bounds in schema.items(): number = value.get(name, bounds["default"]) if ( isinstance(number, bool) or not isinstance(number, (int, float)) or not bounds["min"] <= number <= bounds["max"] or not math.isfinite(number) or (bounds.get("integer") and not isinstance(number, int)) ): raise TaskConfigError(f"{label}.{name} 超出允许的有限数值范围") result[name] = number return result def sensor_pattern(mode: str, fov: float) -> dict: if mode not in ("single_ring_raycast", "multi_ring_raycast"): raise TaskConfigError("不支持的 sensorMode") multi = mode == "multi_ring_raycast" count = 16 if multi else 32 return { "sensorMode": mode, "rayCount": 48 if multi else 32, "pitchAngles": [0, -20, -45] if multi else [0], "yawCount": count, "yawAngles": [-fov / 2 + i * fov / (count - 1) for i in range(count)], "angleUnit": "deg", "rayOrder": "layer-major", } def validate_sensor_config(raw: dict) -> dict: pattern_keys = set(sensor_pattern("single_ring_raycast", 90)) sensor = _parameters( {k: v for k, v in raw.items() if k not in pattern_keys | {"type"}}, SENSOR_PARAMETERS, "sensorCfg", ) pattern = sensor_pattern(raw.get("sensorMode", "single_ring_raycast"), sensor["fov"]) for key, expected in pattern.items(): if key not in raw: continue actual = raw[key] if isinstance(expected, list): valid = ( isinstance(actual, list) and len(actual) == len(expected) and all( not isinstance(a, bool) and isinstance(a, (int, float)) and math.isfinite(a) and abs(a - b) <= 1e-10 for a, b in zip(actual, expected, strict=True) ) ) else: valid = type(actual) is type(expected) and actual == expected if not valid: raise TaskConfigError(f"sensorCfg.{key} 与mode/FOV矛盾") return {**sensor, "type": "raycast", **pattern} def validate_custom_terrain(value: Any) -> dict: """Untrusted full layout: exact schema, no RNG, no geometry repair or clipping.""" fields = { "representation", "approximation", "size", "friction", "boxes", "spawn", "spawnQuaternion", "target", "actualObstacleCount", } if not isinstance(value, dict) or set(value) != fields: raise TaskConfigError("customTerrainBoxes 字段缺失或未知(不接受路径/MJCF)") def number(v, lo, hi): if ( isinstance(v, bool) or not isinstance(v, (int, float)) or not lo <= v <= hi or not math.isfinite(v) ): raise TaskConfigError("customTerrainBoxes 必须使用范围内有限数值") return v def vector(v, n, lo=-12, hi=12): if not isinstance(v, list) or len(v) != n: raise TaskConfigError("customTerrainBoxes 向量长度无效") return [number(x, lo, hi) for x in v] size = number(value["size"], 8, 24) number(value["friction"], 0.2, 2) if value["representation"] != "boxes-v1" or value["approximation"] is not True: raise TaskConfigError( "custom_boxes 必须声明 boxes-v1 和 approximation=true(AABB/底板标准化)" ) boxes = value["boxes"] if not isinstance(boxes, list) or not 1 <= len(boxes) <= 257: raise TaskConfigError("customTerrainBoxes 只能包含底板及最多256障碍") for box in boxes: if not isinstance(box, dict) or set(box) != {"pos", "size", "yaw"}: raise TaskConfigError("box 字段缺失或未知") center = vector(box["pos"], 3) half = vector(box["size"], 3, 0, 12) number(box["yaw"], 0, 0) if any(x <= 0 for x in half): raise TaskConfigError("box 半尺寸必须严格大于零") if any(abs(center[i]) + half[i] > size / 2 + 1e-6 for i in range(2)): raise TaskConfigError("box 超出世界地图边界") if center[2] - half[2] < -0.2 - 1e-6 or center[2] + half[2] > 12: raise TaskConfigError("box 高度超出边界") if boxes[0] != {"pos": [0, 0, -0.1], "size": [size / 2, size / 2, 0.1], "yaw": 0}: raise TaskConfigError("必须使用标准 floor z=[-0.2,0]") count = value["actualObstacleCount"] if isinstance(count, bool) or not isinstance(count, int) or count != len(boxes) - 1: raise TaskConfigError("actualObstacleCount 与布局不一致") spawn = vector(value["spawn"], 3) target = vector(value["target"], 2) if spawn[2] != 0.32: raise TaskConfigError("出生高度必须为0.32") quaternion = vector(value["spawnQuaternion"], 4, -1, 1) if abs(sum(x * x for x in quaternion) - 1) > 1e-6: raise TaskConfigError("出生四元数必须归一化") for point in (spawn, target): if any(abs(point[i]) > size / 2 - 0.5 for i in range(2)): raise TaskConfigError("起终点0.5m安全区超出地图") for box in boxes[1:]: distance_sq = sum( max(abs(point[i] - box["pos"][i]) - box["size"][i], 0) ** 2 for i in range(2) ) if distance_sq <= 0.5**2: raise TaskConfigError("障碍物侵占起终点0.5m圆形安全区;请修改坐标,不会清除障碍") return copy.deepcopy(value) def validate_task_config(task_id: str, payload: dict, seed: int) -> dict | None: """Validate preset parameters or an authoritative boxes-v1 layout, never paths/XML.""" custom_keys = { "terrainPreset", "terrainParams", "sensorCfg", "sensorType", "customTerrainBoxes", } if task_id != OBSTACLE_TASK and not custom_keys.intersection(payload): return None if task_id not in (FLAT_TASK, ROUGH_TASK, OBSTACLE_TASK): raise TaskConfigError("该任务不支持自定义地形") preset = payload.get( "terrainPreset", "discrete_obstacles" if task_id == OBSTACLE_TASK else "plane" ) if not isinstance(preset, str) or preset not in TERRAIN_PRESETS: raise TaskConfigError("不支持的 terrainPreset") layout = None if preset == "custom_boxes": layout = validate_custom_terrain(payload.get("customTerrainBoxes")) expected = {"size": layout["size"], "friction": layout["friction"]} if "terrainParams" in payload and ( not isinstance(payload["terrainParams"], dict) or payload["terrainParams"] != expected or any( isinstance(v, bool) or not isinstance(v, (int, float)) for v in payload["terrainParams"].values() ) ): raise TaskConfigError("custom_boxes terrainParams 必须与布局size/friction完全一致") terrain = expected else: if "customTerrainBoxes" in payload: raise TaskConfigError("customTerrainBoxes 仅允许 custom_boxes,不能降级预设") terrain = _parameters(payload.get("terrainParams", {}), TERRAIN_PARAMETERS, "terrainParams") if not layout and terrain["obstacle_height_min"] > terrain["obstacle_height_max"]: raise TaskConfigError("障碍物最小高度不能超过最大高度") raw_sensor = payload.get("sensorCfg", {}) if not isinstance(raw_sensor, dict): raise TaskConfigError("sensorCfg 必须是对象") sensor_type = payload.get("sensorType", raw_sensor.get("type", "raycast")) if sensor_type != "raycast" or raw_sensor.get("type", "raycast") != "raycast": raise TaskConfigError("首版只支持 raycast,未实现 camera_depth") if task_id != OBSTACLE_TASK and (raw_sensor or "sensorType" in payload): raise TaskConfigError("只有避障任务支持 sensorCfg") sensor = None if task_id == OBSTACLE_TASK: sensor = validate_sensor_config(raw_sensor) sensor["type"] = "raycast" if sensor["safetyDistance"] >= sensor["maxDistance"]: raise TaskConfigError("安全距离必须小于探测距离") return { "terrainPreset": preset, "terrainParams": terrain, "sensorCfg": sensor, "seed": seed, **({"customTerrainBoxes": layout} if layout else {}), } def navigation_candidates( layout: dict, spacing: float = 0.5, clearance: float = 0.55, min_distance: float = 2.0 ) -> dict: """Build connected, collision-free point-goal samples for episode resets. Obstacles are inflated by ``clearance`` and the map is sampled on a regular grid. Only components containing a pair at least ``min_distance`` apart are retained, so runtime sampling cannot place a goal across an impassable wall. """ size = layout["size"] lo, hi = -size / 2 + clearance, size / 2 - clearance count = max(1, int(math.floor((hi - lo) / spacing)) + 1) axis = [lo + i * (hi - lo) / max(count - 1, 1) for i in range(count)] obstacles = layout["boxes"][1:] def free(x: float, y: float) -> bool: return all( math.hypot( max(abs(x - box["pos"][0]) - box["size"][0], 0), max(abs(y - box["pos"][1]) - box["size"][1], 0), ) > clearance for box in obstacles ) cells = {(i, j) for i, x in enumerate(axis) for j, y in enumerate(axis) if free(x, y)} components = [] while cells: pending = [cells.pop()] component = [] while pending: cell = pending.pop() component.append(cell) i, j = cell for neighbour in ((i - 1, j), (i + 1, j), (i, j - 1), (i, j + 1)): if neighbour in cells: cells.remove(neighbour) pending.append(neighbour) points = [(axis[i], axis[j]) for i, j in sorted(component)] extrema = [ ( min(points, key=lambda point: point[axis_index]), max(points, key=lambda point: point[axis_index]), ) for axis_index in (0, 1) ] first, second = max(extrema, key=lambda pair: math.dist(*pair)) if math.dist(first, second) >= min_distance: components.append((points, first, second)) if not components: raise TaskConfigError( f"地图没有可用于随机起终点的连通自由区域(至少需要{min_distance:g}m间距)" ) flattened = [] starts = [] counts = [] fallbacks = [] for points, first, second in components: starts.append(len(flattened)) counts.append(len(points)) flattened.extend(points) fallbacks.append((first, second)) return { "points": flattened, "componentStarts": starts, "componentCounts": counts, "fallbackPairs": fallbacks, "minDistance": min_distance, "clearance": clearance, "spacing": spacing, } def build_terrain_layout(config: dict) -> dict: """Emit exact world-centered boxes; consumers do not reimplement RNG/terrain presets. size values are MuJoCo half-extents, pos is the center, yaw is always zero. TerrainGenerator uses one patch, shifted back to these coordinates. All parallel environments are independent worlds with the same map and spawn, not a grid. """ if config["terrainPreset"] == "custom_boxes": return validate_custom_terrain(config.get("customTerrainBoxes")) p = config["terrainParams"] size = p["size"] rng = random.Random(config["seed"]) boxes = [{"pos": [0, 0, -0.1], "size": [size / 2, size / 2, 0.1], "yaw": 0}] preset = config["terrainPreset"] def box(x: float, y: float, sx: float, sy: float, height: float) -> None: boxes.append({"pos": [x, y, height / 2], "size": [sx, sy, height / 2], "yaw": 0}) # Leave two flat end strips for the spawn and goal; no rejection sampling. if preset == "discrete_obstacles": spacing = p["spacing"] nx = max(1, int((size - 4) / spacing)) ny = max(1, int((size - 2) / spacing)) cells = [ ( -size / 2 + 2 + (i + 0.5) * (size - 4) / nx, -size / 2 + 1 + (j + 0.5) * (size - 2) / ny, ) for i in range(nx) for j in range(ny) ] rng.shuffle(cells) for x, y in cells[: p["obstacle_count"]]: box(x, y, 0.2, 0.2, rng.uniform(p["obstacle_height_min"], p["obstacle_height_max"])) elif preset in ("rough", "wave"): # 16x16 bounded box approximation, deliberately not the editor heightfield. nx = ny = 16 sx, sy = (size - 4) / nx, size / ny for i in range(nx): for j in range(ny): height = ( rng.uniform(0.005, p["roughness"]) if preset == "rough" else 0.005 + p["wave_amplitude"] * (1 + math.sin(i * math.pi / 4)) / 2 ) box( -size / 2 + 2 + (i + 0.5) * sx, -size / 2 + (j + 0.5) * sy, sx / 2, sy / 2, height, ) elif preset == "pyramid_stairs": for i in range(4): half = (size - 4) / 2 - i * (size - 4) / 10 box(0, 0, half, half, p["step_height"] * (i + 1)) return { "representation": "boxes-v1", "approximation": preset in ("rough", "wave", "pyramid_stairs"), "size": size, "friction": p["friction"], "boxes": boxes, "spawn": [-size / 2 + 1, 0, 0.32], "spawnQuaternion": [1, 0, 0, 0], "target": [size / 2 - 1, 0], "actualObstacleCount": len(boxes) - 1, } def deployment_metadata(task_id: str, config: dict | None, seed: int) -> dict: obstacle = task_id == OBSTACLE_TASK metadata = { "version": 1, "taskId": task_id, "browserCompatible": task_id in (FLAT_TASK, OBSTACLE_TASK), "observationSize": (49 + config["sensorCfg"]["rayCount"]) if obstacle else (234 if task_id == ROUGH_TASK else 47), "actionSize": 12, "controlHz": 50, "gaitPeriod": 0.6, "jointNames": JOINT_NAMES, "defaultJointPosition": DEFAULT_JOINT_POSITION, "actionScale": [0.25] * 12, "stiffness": [20, 20, 40] * 4, "damping": [1, 1, 2] * 4, "effortLimits": [23.5, 23.5, 45] * 4, "observationTerms": [ "base_ang_vel", "projected_gravity", "command", "phase", "joint_pos", "joint_vel", "actions", ] + ( ["forward_depth", "target_error"] if obstacle else (["height_scan"] if task_id == ROUGH_TASK else []) ), "seed": seed, } if task_id == ROUGH_TASK: metadata["incompatibilityReason"] = "旧 Rough actor 含向下高度扫描;浏览器未实现此部署契约" if config is not None: metadata.update( { "terrainPreset": config["terrainPreset"], "terrainParams": config["terrainParams"], "terrain": build_terrain_layout(config), } ) if obstacle: assert config is not None metadata["sensorCfg"] = { **config["sensorCfg"], "offset": [0.3, 0, 0.05], "alignment": "base", "terrainOnly": True, "includeGround": True, } metadata["navigation"] = { "speed": 0.6, "arrivalRadius": 0.5, "distanceScale": config["terrainParams"]["size"], "headingScale": math.pi, "yawGain": 1.0, "maxYawRate": 1.0, "episodeSeconds": 20, "onArrival": "stop", "onReset": "respawn", "trainingReset": "random-connected-free-pair", "trainingMinGoalDistance": 2.0, "trainingClearance": 0.55, } return metadata def task_metadata(tasks: tuple[str, ...]) -> list[dict]: names = { FLAT_TASK: "平地速度控制", ROUGH_TASK: "地形自适应(仅后端)", OBSTACLE_TASK: "前视射线避障导航", } return [ { "id": task, "name": names.get(task, task), "browserCompatible": task in (FLAT_TASK, OBSTACLE_TASK), "terrainPresets": list(TERRAIN_PRESETS) if task in names else [], "terrainParameters": TERRAIN_PARAMETERS if task in names else {}, "sensorTypes": ["raycast"] if task == OBSTACLE_TASK else [], "sensorModes": ["single_ring_raycast", "multi_ring_raycast"] if task == OBSTACLE_TASK else [], "sensorParameters": SENSOR_PARAMETERS if task == OBSTACLE_TASK else {}, "mapSyncScope": ( "已应用静态碰撞场景→custom_boxes(boxes-v1);AABB与标准底板近似;不支持mesh/hfield" ), } for task in tasks ]