fix(training): release V0.9.2 自定义地图自动同步
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled

This commit is contained in:
2026-09-08 13:28:34 +08:00
parent 438e56bcc8
commit 831d0b95bb
9 changed files with 159 additions and 121 deletions
@@ -267,7 +267,7 @@ describe('LocalTrainingPanel 自定义任务', () => {
const fetchMock = customServer();
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
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(
<LocalTrainingPanel
@@ -322,11 +322,10 @@ describe('LocalTrainingPanel 配套策略交接', () => {
/>,
);
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(
<LocalTrainingPanel
onPolicyReady={vi.fn()}
@@ -403,27 +396,25 @@ it('custom_boxes拒绝草稿、过时编译场景和未重新同步的坐标;
/>,
);
await connectCustom();
fireEvent.click(screen.getByRole('button', { name: '同步当前场景地图' }));
fireEvent.click(screen.getByRole('button', { name: '检查当前场景地图(可选)' }));
expect(screen.getByRole('alert')).toHaveTextContent('草稿');
expect(compileScene).not.toHaveBeenCalled();
rerender(
<LocalTrainingPanel onPolicyReady={vi.fn()} sceneMaps={assets} compileScene={compileScene} />,
);
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 () => {
@@ -81,7 +81,6 @@ export function LocalTrainingPanel({
const [terrainPreset, setTerrainPreset] = useState('');
const [customTerrainBoxes, setCustomTerrainBoxes] = useState<TrainingTerrain>();
const [syncedScene, setSyncedScene] = useState<string>();
const [terrainParams, setTerrainParams] = useState<Record<string, number>>({});
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({
</Select>
</Field>
<Button disabled={busy} onClick={syncMap}>
同步当前场景地图
检查当前场景地图(可选)
</Button>
<p className="text-[10px] text-text-tertiary">
从全部已应用实例的实际碰撞几何编译世界AABB;旋转障碍会膨胀,底板标准化为z=[-0.2,0],出生高度标准化为0.32m。仅保证训练与浏览器使用相同boxes,不等于原OBB。mesh/hfield、地下结构、混合摩擦明确拒绝。
启动训练时会自动从全部已应用实例重新编译并校验世界AABB,无需预先同步。旋转障碍会膨胀,底板标准化为z=[-0.2,0];仅保证训练与浏览器使用相同boxes,不等于原OBB。mesh/hfield、地下结构、混合摩擦明确拒绝。
</p>
{terrainPreset === 'custom_boxes' && customTerrainBoxes && (
<>
<div className="grid grid-cols-2 gap-2">
{(['spawn', 'target'] as const).flatMap((key) =>
[0, 1].map((i) => (
<NumberField
key={`${key}${i}`}
label={`${key === 'spawn' ? '出生' : '目标'} ${i === 0 ? 'X' : 'Y'}`}
value={customTerrainBoxes[key][i]}
min={-12}
max={12}
step={0.1}
onChange={(value) => {
setSyncedScene(undefined);
setCustomTerrainBoxes(
(old) =>
old && {
...old,
[key]: old[key].map((v, j) => (i === j ? value : v)),
},
);
}}
/>
)),
)}
</div>
<p>
此处起终点仅用于固定评估和部署初始演示,训练会在同一连通自由区域内逐episode随机采样。参考点须保留0.5m圆形安全区;修改后请重新同步。
</p>
{syncedScene === JSON.stringify(sceneMaps) && !sceneDirty && (
<p role="status">
已将视口中 {customTerrainBoxes.actualObstacleCount}{' '}
个自定义障碍物编译为训练地图布局
</p>
)}
</>
{terrainPreset === 'custom_boxes' && customTerrainBoxes && !sceneDirty && (
<p role="status">
已读取视口中 {customTerrainBoxes.actualObstacleCount}{' '}
个自定义障碍物;启动训练时会自动重新编译并校验地图
</p>
)}
{terrainPreset && terrainPreset !== 'custom_boxes' && (
<div className="grid grid-cols-2 gap-2">