diff --git a/CHANGELOG.md b/CHANGELOG.md index 86437bf1..a1134111 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ 本项目的重要变更记录在此文件中,版本标签沿用仓库现有的 `V主版本.次版本[.修订版本]` 格式。 +## [未发布] + +## [0.9.2] - 2026-09-08 + +- 修复custom_boxes因起终点编辑使同步状态持续失效而无法训练:移除训练面板起终点坐标显示与编辑,训练/调参启动时自动重新编译已应用碰撞场景,并自动选择满足净空、最小距离和连通性约束的参考点。 + ## [0.9.1] - 2026-09-08 - Go2避障训练改为每个episode从同一连通自由区域随机采样起终点并随机化初始朝向,保证障碍/边界净空和最小目标距离;固定三seed评估及浏览器部署仍使用声明的参考起终点,兼顾泛化与可比性。 diff --git a/package-lock.json b/package-lock.json index bee50c1d..5940c3a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mujoco-web-platform", - "version": "0.9.1", + "version": "0.9.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mujoco-web-platform", - "version": "0.9.1", + "version": "0.9.2", "license": "Apache-2.0", "dependencies": { "@monaco-editor/react": "^4.7.0", diff --git a/package.json b/package.json index d9c46c3d..6271485b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mujoco-web-platform", - "version": "0.9.1", + "version": "0.9.2", "description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台", "private": true, "type": "module", diff --git a/training_server/OBSTACLE_AVOIDANCE.md b/training_server/OBSTACLE_AVOIDANCE.md index c67fa0ec..57262de9 100644 --- a/training_server/OBSTACLE_AVOIDANCE.md +++ b/training_server/OBSTACLE_AVOIDANCE.md @@ -57,7 +57,7 @@ - box世界半尺寸为 `abs(R)*halfsize`;sphere、capsule、ellipsoid、cylinder使用解析精确世界AABB。**旋转box与非box转换会扩大碰撞占据区域**。mesh/hfield明确拒绝;项目地图原有include导入限制不变,已经编译模型的include变换不重新解析。 - 固定floor为中心 `[0,0,-.1]`、半尺寸 `[size/2,size/2,.1]`。仅明确命名floor/ground/flat的朝上水平z=0 plane,以及顶面z≈0的水平支撑box归并。地下/坑底/非零高度或倾斜plane拒绝,不能静默填坑。其他障碍原坐标保留。底板覆盖整个正方形范围,可能填补支撑面间空白;UI明确说明标准化,不保证与原场景几何同构。 - 最多256障碍+1底板,超量报错不截断;世界原点不变,尺寸8–24m,取覆盖应用地图/几何的正方形,不clamp或平移几何。XY必须在floor内,Z界限[-.2,12],所有半尺寸严格>0(包括拒绝负零),有限数值。摩擦必须统一为 `[f,.005,.0001]` 且f为.2–2,混合值报错要求用户先显式统一,不能静默覆盖。 -- `approximation` 对custom必须为true(AABB及标准底板转换);`actualObstacleCount` 必须等于boxes数减一。字段严格白名单,不信任声明值。floor必须标准。出生z固定.32,默认XY/四元数取成功加载时真实初始浮动基座姿态,不取跑动后的pose;目标建议为初始朝向前方3m,不自动寻找安全点。面板提供最小出生/目标XY配置,修改后需重新同步。 +- `approximation` 对custom必须为true(AABB及标准底板转换);`actualObstacleCount` 必须等于boxes数减一。字段严格白名单,不信任声明值。floor必须标准。面板不显示或编辑出生/目标坐标;场景编译器从连通自由栅格自动选取障碍/边界净空>0.55m且间距>=2m的确定性参考点,出生z固定.32,四元数取成功加载时真实初始浮动基座姿态。启动训练和打开调参时均自动重新编译当前已应用碰撞场景。 - 出生及目标中心必须距边界>=.5m;与每个非底板障碍XY AABB的最近距离必须严格>.5m(圆形安全区,包括切触拒绝),不能删除或移动障碍以修复。四元数必须有限且归一化。后端env origin、初始化姿态、目标偏移和越界中心按自定义spawn计算。 - 同步/提交均拒绝未应用草稿、训练部署替换后的场景、加载中或已过时场景;提交前再次比较当前编译布局。HTTP `MAX_REQUEST_BYTES` 与训练JSON上限均128KiB,足够257个double-precision boxes且有界;ONNX部署metadata上限仍100KB。 diff --git a/web_platform/TRAINING.md b/web_platform/TRAINING.md index 052a0cc6..f095df40 100644 --- a/web_platform/TRAINING.md +++ b/web_platform/TRAINING.md @@ -2,7 +2,7 @@ 1. 启动仓库本地训练服务,在「控制台 → 强化学习任务」输入令牌并连接。 2. 选择「前视射线避障导航」,设置地形、种子、障碍物参数、FOV、探测/安全距离及避障奖励权重,发起训练。界面显示进度、最近价值/策略/熵损失和原始日志。 -3. 「同步当前场景地图」从全部**已应用**实例的已编译MuJoCo静态碰撞几何导出权威 `custom_boxes/boxes-v1`,支持多实例、平移/旋转、工程地图路径及嵌套body。成功显示“已将视口中 N 个自定义障碍物编译为训练地图布局”。不重新运行随机预设,不同步草稿/过时场景。初始姿态取场景加载时机器人位姿,出生高度标准化为.32m;目标建议为初始朝向前方3m,不自动寻找安全点。出生/目标XY可修改,必须重新同步,并保留与所有障碍XY AABB不相交的.5m圆形安全区;绝不删除或移动障碍来避开安全区。 +3. 可选的「检查当前场景地图」从全部**已应用**实例的已编译MuJoCo静态碰撞几何导出权威 `custom_boxes/boxes-v1`,支持多实例、平移/旋转、工程地图路径及嵌套body。不重新运行随机预设,不同步未应用草稿。面板不再显示或编辑出生/目标坐标:系统从连通栅格自动选择满足障碍/边界净空0.55m且相距至少2m的参考点;训练每个episode仍独立随机采样。点击训练或打开调参时会从当前已应用碰撞场景自动重新编译并校验,不依赖旧同步快照;绝不删除或移动障碍来腾出安全区。 4. 训练完成后,先导入并加载有对应12个关节的Go2模型(Go2-W仅为非同构实验)。点击「导入策略」会校验作业配置与ONNX内嵌部署契约,在候选场景中完成真实ORT初始化、graph与机器人绑定检查后才事务替换当前**物理**地形、复位到出生点,启动策略与仿真。当前编辑器地图保留,重新加载模型恢复它。失败保留原场景、策略、物理状态及原暂停/播放状态。旧Flat无配套地形时仍按原方式加载后手动启用;新服务的默认Flat作业允许旧外部训练器无metadata导出,但真实graph必须固定47→12;显式自定义地图/避障不允许此降级。 5. 避障任务自动导航到配套目标,目标半径0.5m内停止速度指令。摄像头PiP自动打开,可单独隐藏;「显示避障射线」可切换红色命中/绿色未命中线段。 @@ -51,7 +51,7 @@ GO2_SMOKE_POLICY=/tmp/go2-obstacle-train-smoke/policy.onnx npm run test:e2e -- w 自调参工作台新建Session可选Flat或Obstacle,选择Obstacle自动绑定四个专属标量/范围和五项固定客观权重(success .4/time .2/clearance .2/smooth .1/no-fall .1)。目标速度位于`params.target_velocity`,是导航command而非奖励。Approval/Automatic及运行时切换、护栏revision保持兼容,Loss看板不变。 -从训练面板打开工作台时传递当前任务、seed、terrain/sensor(包括已验证custom_boxes快照);未同步/过时场景不发送凭据或配置。独立打开时可直接选任务,在“避障场景配置JSON”输入与训练API同构的terrain/sensor配置;服务再次严格验证。自定义地图明确为同一权威地图/起终点上的三个固定seed重复评估,不冒充三地形。Agent上下文按32/48ray实际模式描述侧后、层间与高度盲区、目标导航、擦碰/绕行/动作抖动约束。 +从训练面板打开工作台时传递当前任务、seed、terrain/sensor;custom_boxes会先从当前已应用场景自动重新编译并验证,未应用草稿不发送凭据或配置。独立打开时可直接选任务,在“避障场景配置JSON”输入与训练API同构的terrain/sensor配置;服务再次严格验证。自定义地图明确为同一权威地图/自动参考起终点上的三个固定seed重复评估,不冒充三地形。Agent上下文按32/48ray实际模式描述侧后、层间与高度盲区、目标导航、擦碰/绕行/动作抖动约束。 Obstacle最佳策略通过ONNX内嵌metadata事务导入,导航速度随部署契约应用(.3–1.2),旧81维.6m/s模型与Flat导入保持原行为。客观评分、成功/碰撞/跌倒定义及3seed串行隔离评估详见[部署契约](../training_server/OBSTACLE_AVOIDANCE.md#obstacle-deepseek-自调参obstacle-v1)。小GPU smoke只验证链路和指标,不代表学会导航。 diff --git a/web_platform/src/map/customTrainingMap.test.ts b/web_platform/src/map/customTrainingMap.test.ts index ea34e9a2..5bc72aa1 100644 --- a/web_platform/src/map/customTrainingMap.test.ts +++ b/web_platform/src/map/customTrainingMap.test.ts @@ -41,6 +41,19 @@ describe('customTrainingMap compiled collision export', () => { ).toThrow(); expect(() => validatePolicyDeployment({ ...d, terrain: undefined })).toThrow(); }); + it('未提供起终点时自动选择同一连通安全区的参考点', () => { + const layout = trainingTerrainFromCompiledScene([geometry()], pose, 6); + expect(() => validateCustomTerrain(layout)).not.toThrow(); + expect( + Math.hypot(layout.spawn[0] - layout.target[0], layout.spawn[1] - layout.target[1]), + ).toBeGreaterThanOrEqual(2); + for (const point of [layout.spawn, layout.target]) { + const box = layout.boxes[1]; + const dx = Math.max(Math.abs(point[0] - box.pos[0]) - box.size[0], 0); + const dy = Math.max(Math.abs(point[1] - box.pos[1]) - box.size[1], 0); + expect(Math.hypot(dx, dy)).toBeGreaterThan(0.55); + } + }); it('abs(R)*halfsize支持实例旋转/平移;排除机器人和装饰,所有碰撞障碍保留', () => { const c = Math.SQRT1_2, r = [c, -c, 0, c, c, 0, 0, 0, 1]; diff --git a/web_platform/src/map/trainingMap.ts b/web_platform/src/map/trainingMap.ts index 00eb0430..e8627f7e 100644 --- a/web_platform/src/map/trainingMap.ts +++ b/web_platform/src/map/trainingMap.ts @@ -100,6 +100,77 @@ export interface TrainingSceneCoordinates { } export type TrainingSceneCompiler = (coordinates?: TrainingSceneCoordinates) => TrainingTerrain; +const NAVIGATION_CLEARANCE = 0.55; +const NAVIGATION_MIN_DISTANCE = 2; +const NAVIGATION_GRID_STEP = 0.5; + +type NavigationPoint = [number, number]; + +/** Selects a deterministic safe reference pair; training itself resamples every episode. */ +function automaticNavigationPair( + boxes: TrainingTerrain['boxes'], + size: number, + preferred: NavigationPoint, +): [NavigationPoint, NavigationPoint] { + const limit = size / 2 - NAVIGATION_CLEARANCE; + const count = Math.floor((limit * 2) / NAVIGATION_GRID_STEP) + 1; + const points = new Map(); + const key = (x: number, y: number) => `${x},${y}`; + for (let iy = 0; iy < count; iy++) { + for (let ix = 0; ix < count; ix++) { + const point: NavigationPoint = [ + -limit + ix * NAVIGATION_GRID_STEP, + -limit + iy * NAVIGATION_GRID_STEP, + ]; + const safe = boxes.every((box) => { + const dx = Math.max(Math.abs(point[0] - box.pos[0]) - box.size[0], 0); + const dy = Math.max(Math.abs(point[1] - box.pos[1]) - box.size[1], 0); + return dx * dx + dy * dy > NAVIGATION_CLEARANCE ** 2; + }); + if (safe) points.set(key(ix, iy), point); + } + } + const pending = new Set(points.keys()); + let best: { pair: [NavigationPoint, NavigationPoint]; preferredDistance: number } | undefined; + while (pending.size) { + const first = pending.values().next().value as string; + pending.delete(first); + const queue = [first]; + const component: NavigationPoint[] = []; + while (queue.length) { + const current = queue.pop()!; + component.push(points.get(current)!); + const [ix, iy] = current.split(',').map(Number); + for (const neighbor of [key(ix - 1, iy), key(ix + 1, iy), key(ix, iy - 1), key(ix, iy + 1)]) { + if (!pending.delete(neighbor)) continue; + queue.push(neighbor); + } + } + let pair: [NavigationPoint, NavigationPoint] | undefined; + let maximum = 0; + for (let i = 0; i < component.length; i++) { + for (let j = i + 1; j < component.length; j++) { + const distance = Math.hypot( + component[i][0] - component[j][0], + component[i][1] - component[j][1], + ); + if (distance > maximum) { + maximum = distance; + pair = [component[i], component[j]]; + } + } + } + if (!pair || maximum < NAVIGATION_MIN_DISTANCE) continue; + const firstDistance = Math.hypot(pair[0][0] - preferred[0], pair[0][1] - preferred[1]); + const secondDistance = Math.hypot(pair[1][0] - preferred[0], pair[1][1] - preferred[1]); + if (secondDistance < firstDistance) pair = [pair[1], pair[0]]; + const preferredDistance = Math.min(firstDistance, secondDistance); + if (!best || preferredDistance < best.preferredDistance) best = { pair, preferredDistance }; + } + if (!best) throw new Error('地图没有满足0.55m净空和2m间距的连通自由区域'); + return best.pair; +} + export interface CompiledMapGeometry { name: string; type: number; @@ -181,10 +252,16 @@ export function trainingTerrainFromCompiledScene( if (friction === undefined) throw new Error('已应用地图没有可导出的静态碰撞几何'); if (extent > 12 + 1e-6) throw new Error('世界地图范围超过24m上限;不平移或裁剪几何'); const size = Math.max(8, extent * 2); - const spawn = coordinates.spawn ?? [initialPose[0], initialPose[1]]; const q = initialPose.slice(3, 7); + const preferred: NavigationPoint = [initialPose[0], initialPose[1]]; + const automatic = + coordinates.spawn || coordinates.target + ? undefined + : automaticNavigationPair(obstacles, size, preferred); + const spawn = coordinates.spawn ?? automatic?.[0] ?? preferred; const yaw = Math.atan2(2 * (q[0] * q[3] + q[1] * q[2]), 1 - 2 * (q[2] ** 2 + q[3] ** 2)); - const target = coordinates.target ?? [spawn[0] + 3 * Math.cos(yaw), spawn[1] + 3 * Math.sin(yaw)]; + const target = coordinates.target ?? + automatic?.[1] ?? [spawn[0] + 3 * Math.cos(yaw), spawn[1] + 3 * Math.sin(yaw)]; return { representation: 'boxes-v1', approximation: true, diff --git a/web_platform/src/training/LocalTrainingPanel.test.tsx b/web_platform/src/training/LocalTrainingPanel.test.tsx index 5ee27c8d..0dade5b1 100644 --- a/web_platform/src/training/LocalTrainingPanel.test.tsx +++ b/web_platform/src/training/LocalTrainingPanel.test.tsx @@ -267,7 +267,7 @@ describe('LocalTrainingPanel 自定义任务', () => { const fetchMock = customServer(); render(); await connectCustom(); - fireEvent.click(screen.getByRole('button', { name: '同步当前场景地图' })); + fireEvent.click(screen.getByRole('button', { name: '检查当前场景地图(可选)' })); expect(screen.getByRole('alert')).toHaveTextContent('没有已应用'); fireEvent.change(screen.getByLabelText('训练地形'), { target: { value: 'plane' } }); fireEvent.change(screen.getByLabelText('地图尺寸 m'), { target: { value: '100' } }); @@ -296,7 +296,7 @@ describe('LocalTrainingPanel 自定义任务', () => { }); describe('LocalTrainingPanel 配套策略交接', () => { - it('同步权威布局并上传完整boxes,不再重跑预设种子', async () => { + it('选择custom_boxes后无需手动同步,启动时自动编译并上传完整布局', async () => { const fetchMock = customServer(); render( { />, ); await connectCustom(); - fireEvent.click(screen.getByRole('button', { name: '同步当前场景地图' })); + fireEvent.change(screen.getByLabelText('训练地形'), { target: { value: 'custom_boxes' } }); expect(screen.getByLabelText('训练地形')).toHaveValue('custom_boxes'); - expect(screen.getByText('已将视口中 1 个自定义障碍物编译为训练地图布局')).toBeInTheDocument(); - expect(screen.getByLabelText('出生 X')).toHaveValue(-2); - expect(screen.getByLabelText('目标 Y')).toHaveValue(-1); + expect(screen.queryByLabelText('出生 X')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('目标 Y')).not.toBeInTheDocument(); expect(screen.getByText(/旋转障碍会膨胀/)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '发起本地训练' })); await waitFor(() => @@ -374,7 +373,7 @@ describe('LocalTrainingPanel 配套策略交接', () => { }); }); -it('custom_boxes拒绝草稿、过时编译场景和未重新同步的坐标;安全区报错不删障碍', async () => { +it('custom_boxes拒绝未应用草稿,且训练时自动重新编译最新地图而不显示起终点', async () => { const fetchMock = customServer(); const assets = [ { @@ -387,13 +386,7 @@ it('custom_boxes拒绝草稿、过时编译场景和未重新同步的坐标; }, ]; let current = validateCustomTerrain(customLayout); - const compileScene = vi.fn( - (coordinates?: import('../map/trainingMap').TrainingSceneCoordinates) => ({ - ...structuredClone(current), - ...(coordinates?.spawn ? { spawn: [...coordinates.spawn, 0.32] } : {}), - ...(coordinates?.target ? { target: [...coordinates.target] } : {}), - }), - ); + const compileScene = vi.fn(() => structuredClone(current)); const { rerender } = render( , ); await connectCustom(); - fireEvent.click(screen.getByRole('button', { name: '同步当前场景地图' })); + fireEvent.click(screen.getByRole('button', { name: '检查当前场景地图(可选)' })); expect(screen.getByRole('alert')).toHaveTextContent('草稿'); expect(compileScene).not.toHaveBeenCalled(); rerender( , ); - fireEvent.click(screen.getByRole('button', { name: '同步当前场景地图' })); - fireEvent.change(screen.getByLabelText('目标 X'), { target: { value: 1 } }); - fireEvent.change(screen.getByLabelText('目标 Y'), { target: { value: 2 } }); - fireEvent.click(screen.getByRole('button', { name: '发起本地训练' })); - await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('重新同步')); - fireEvent.click(screen.getByRole('button', { name: '同步当前场景地图' })); - expect(screen.getByRole('alert')).toHaveTextContent('安全区'); - expect(current.boxes).toHaveLength(2); - fireEvent.change(screen.getByLabelText('目标 X'), { target: { value: 2 } }); - fireEvent.change(screen.getByLabelText('目标 Y'), { target: { value: -1 } }); - fireEvent.click(screen.getByRole('button', { name: '同步当前场景地图' })); + fireEvent.click(screen.getByRole('button', { name: '检查当前场景地图(可选)' })); + expect(screen.queryByLabelText('出生 X')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('目标 X')).not.toBeInTheDocument(); current = { ...current, boxes: [current.boxes[0], { ...current.boxes[1], pos: [1, 3, 0.5] }] }; fireEvent.click(screen.getByRole('button', { name: '发起本地训练' })); - await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('过时')); - expect(fetchMock.mock.calls.some((call) => call[1]?.method === 'POST')).toBe(false); + await waitFor(() => + expect(fetchMock.mock.calls.some((call) => call[1]?.method === 'POST')).toBe(true), + ); + const request = JSON.parse( + String(fetchMock.mock.calls.find((call) => call[1]?.method === 'POST')?.[1]?.body), + ); + expect(request.customTerrainBoxes.boxes[1].pos).toEqual([1, 3, 0.5]); + expect(compileScene).toHaveBeenCalledTimes(2); }); it('显式选择multi48传入训练请求,默认与切换任务仍single32', async () => { diff --git a/web_platform/src/training/LocalTrainingPanel.tsx b/web_platform/src/training/LocalTrainingPanel.tsx index ced159ba..64dee82a 100644 --- a/web_platform/src/training/LocalTrainingPanel.tsx +++ b/web_platform/src/training/LocalTrainingPanel.tsx @@ -81,7 +81,6 @@ export function LocalTrainingPanel({ const [terrainPreset, setTerrainPreset] = useState(''); const [customTerrainBoxes, setCustomTerrainBoxes] = useState(); - const [syncedScene, setSyncedScene] = useState(); const [terrainParams, setTerrainParams] = useState>({}); const [sensorMode, setSensorMode] = useState<'single_ring_raycast' | 'multi_ring_raycast'>( 'single_ring_raycast', @@ -96,7 +95,6 @@ export function LocalTrainingPanel({ const selectTask = (id: string) => { setTaskId(id); setCustomTerrainBoxes(undefined); - setSyncedScene(undefined); setRewardPresetId(''); setTerrainParams({}); setSensorCfg({}); @@ -109,20 +107,10 @@ export function LocalTrainingPanel({ if (!compileScene || !sceneMaps.length) throw new Error('没有已应用的碰撞地图'); if (!metadata?.terrainPresets.includes('custom_boxes')) throw new Error('当前服务/任务不支持custom_boxes,请升级训练服务'); - setSyncedScene(undefined); - const layout = compileScene( - customTerrainBoxes - ? { - spawn: [customTerrainBoxes.spawn[0], customTerrainBoxes.spawn[1]], - target: [customTerrainBoxes.target[0], customTerrainBoxes.target[1]], - } - : undefined, - ); + const layout = validateCustomTerrain(compileScene()); setCustomTerrainBoxes(layout); setTerrainPreset('custom_boxes'); setTerrainParams({ size: layout.size, friction: layout.friction }); - validateCustomTerrain(layout); - setSyncedScene(JSON.stringify(sceneMaps)); setError(undefined); } catch (value) { setError(errorText(value)); @@ -235,20 +223,18 @@ export function LocalTrainingPanel({ const source = event.source as Window; if (data.type === 'mujoco-tuning-ready') { try { + let resolvedCustomTerrain = customTerrainBoxes; + let resolvedTerrainParams = terrainParams; if (taskId === OBSTACLE_TASK_ID && terrainPreset === 'custom_boxes') { - if ( - sceneDirty || - syncedScene !== JSON.stringify(sceneMaps) || - !compileScene || - !customTerrainBoxes - ) - throw new Error('自定义地图已过时,请重新同步后打开调参'); - const current = compileScene({ - spawn: [customTerrainBoxes.spawn[0], customTerrainBoxes.spawn[1]], - target: [customTerrainBoxes.target[0], customTerrainBoxes.target[1]], - }); - if (JSON.stringify(current) !== JSON.stringify(customTerrainBoxes)) - throw new Error('碰撞场景已过时,请重新同步'); + if (sceneDirty) throw new Error('请先应用地图草稿,再打开调参'); + if (!compileScene || !sceneMaps.length) throw new Error('没有已应用的碰撞地图'); + resolvedCustomTerrain = validateCustomTerrain(compileScene()); + resolvedTerrainParams = { + size: resolvedCustomTerrain.size, + friction: resolvedCustomTerrain.friction, + }; + setCustomTerrainBoxes(resolvedCustomTerrain); + setTerrainParams(resolvedTerrainParams); } source.postMessage( { @@ -263,10 +249,12 @@ export function LocalTrainingPanel({ ? { taskConfig: { terrainPreset, - terrainParams, + terrainParams: resolvedTerrainParams, sensorType: 'raycast', sensorCfg: { ...sensorCfg, sensorMode }, - ...(terrainPreset === 'custom_boxes' ? { customTerrainBoxes } : {}), + ...(terrainPreset === 'custom_boxes' + ? { customTerrainBoxes: resolvedCustomTerrain } + : {}), }, } : {}), @@ -332,7 +320,6 @@ export function LocalTrainingPanel({ sensorCfg, sensorMode, customTerrainBoxes, - syncedScene, sceneMaps, sceneDirty, compileScene, @@ -387,22 +374,18 @@ export function LocalTrainingPanel({ throw new Error('障碍物最小高度不能超过最大高度'); if ((sensorCfg.safetyDistance ?? 0.5) >= (sensorCfg.maxDistance ?? 4)) throw new Error('安全距离必须小于探测距离'); + let resolvedCustomTerrain = customTerrainBoxes; + let resolvedTerrainParams = terrainParams; if (terrainPreset === 'custom_boxes') { - if ( - sceneDirty || - !syncedScene || - syncedScene !== JSON.stringify(sceneMaps) || - !compileScene || - !customTerrainBoxes - ) - throw new Error('自定义地图未同步或场景/坐标已更改,请重新同步'); - validateCustomTerrain(customTerrainBoxes); - const current = compileScene({ - spawn: [customTerrainBoxes.spawn[0], customTerrainBoxes.spawn[1]], - target: [customTerrainBoxes.target[0], customTerrainBoxes.target[1]], - }); - if (JSON.stringify(current) !== JSON.stringify(customTerrainBoxes)) - throw new Error('已编译碰撞场景已过时,请重新同步'); + if (sceneDirty) throw new Error('请先应用地图草稿,再训练'); + if (!compileScene || !sceneMaps.length) throw new Error('没有已应用的碰撞地图'); + resolvedCustomTerrain = validateCustomTerrain(compileScene()); + resolvedTerrainParams = { + size: resolvedCustomTerrain.size, + friction: resolvedCustomTerrain.friction, + }; + setCustomTerrainBoxes(resolvedCustomTerrain); + setTerrainParams(resolvedTerrainParams); } const next = await new LocalTrainingClient(endpoint, token).start({ taskId, @@ -415,8 +398,8 @@ export function LocalTrainingPanel({ wandbMode, rewardPresetId: taskId === 'Unitree-Go2-Flat' ? rewardPresetId || undefined : undefined, ...(pretrainedSourceId ? { pretrainedSourceId } : {}), - ...(terrainPreset ? { terrainPreset, terrainParams } : {}), - ...(terrainPreset === 'custom_boxes' ? { customTerrainBoxes } : {}), + ...(terrainPreset ? { terrainPreset, terrainParams: resolvedTerrainParams } : {}), + ...(terrainPreset === 'custom_boxes' ? { customTerrainBoxes: resolvedCustomTerrain } : {}), ...(taskId === OBSTACLE_TASK_ID ? { sensorType: 'raycast' as const, sensorCfg: { ...sensorCfg, sensorMode } } : {}), @@ -572,7 +555,6 @@ export function LocalTrainingPanel({ onChange={(e) => { setTerrainPreset(e.target.value); setCustomTerrainBoxes(undefined); - setSyncedScene(undefined); setTerrainParams({}); }} > @@ -585,47 +567,16 @@ export function LocalTrainingPanel({

- 从全部已应用实例的实际碰撞几何编译世界AABB;旋转障碍会膨胀,底板标准化为z=[-0.2,0],出生高度标准化为0.32m。仅保证训练与浏览器使用相同boxes,不等于原OBB。mesh/hfield、地下结构、混合摩擦明确拒绝。 + 启动训练时会自动从全部已应用实例重新编译并校验世界AABB,无需预先同步。旋转障碍会膨胀,底板标准化为z=[-0.2,0];仅保证训练与浏览器使用相同boxes,不等于原OBB。mesh/hfield、地下结构、混合摩擦明确拒绝。

- {terrainPreset === 'custom_boxes' && customTerrainBoxes && ( - <> -
- {(['spawn', 'target'] as const).flatMap((key) => - [0, 1].map((i) => ( - { - setSyncedScene(undefined); - setCustomTerrainBoxes( - (old) => - old && { - ...old, - [key]: old[key].map((v, j) => (i === j ? value : v)), - }, - ); - }} - /> - )), - )} -
-

- 此处起终点仅用于固定评估和部署初始演示,训练会在同一连通自由区域内逐episode随机采样。参考点须保留0.5m圆形安全区;修改后请重新同步。 -

- {syncedScene === JSON.stringify(sceneMaps) && !sceneDirty && ( -

- 已将视口中 {customTerrainBoxes.actualObstacleCount}{' '} - 个自定义障碍物编译为训练地图布局 -

- )} - + {terrainPreset === 'custom_boxes' && customTerrainBoxes && !sceneDirty && ( +

+ 已读取视口中 {customTerrainBoxes.actualObstacleCount}{' '} + 个自定义障碍物;启动训练时会自动重新编译并校验地图 +

)} {terrainPreset && terrainPreset !== 'custom_boxes' && (