feat(training): release V0.9.1 避障训练与基础策略迁移
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 10:50:13 +08:00
parent fa5485049a
commit 438e56bcc8
113 changed files with 15027 additions and 539 deletions
+451 -23
View File
@@ -1,4 +1,17 @@
import { useEffect, useState, type ReactNode } from 'react';
import { PretrainedIdentity, PretrainedSourceSelect } from './PretrainedSourceSelect';
import { pretrainedSelectionError } from './pretrainedSelection';
import { TrainingMetricsPanel } from './TrainingMetricsPanel';
import { trainingLosses } from './trainingLosses';
import type { PolicyDeployment, TrainingTerrain } from '../rl/deployment';
import {
OBSTACLE_TASK_ID,
validatePolicyDeployment,
validateCustomTerrain,
readPolicyDeployment,
} from '../rl/deployment';
import type { TrainingSceneCompiler } from '../map/trainingMap';
import type { PlacedMapAsset } from '../map/types';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Download, ExternalLink, Link, Play, Server, Square } from 'lucide-react';
import { Badge, Button, ProgressBar, PropertyRow, Select } from '../components/ui';
import { LocalTrainingClient } from './LocalTrainingClient';
@@ -33,7 +46,17 @@ function stateLabel(state: TrainingJob['state']): string {
}[state];
}
export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File): void }) {
export function LocalTrainingPanel({
onPolicyReady,
compileScene,
sceneMaps = [],
sceneDirty = false,
}: {
onPolicyReady(file: File, deployment?: PolicyDeployment): void | Promise<void>;
compileScene?: TrainingSceneCompiler;
sceneMaps?: readonly PlacedMapAsset[];
sceneDirty?: boolean;
}) {
const [endpoint, setEndpoint] = useState(() =>
localStored(TRAINING_ENDPOINT_KEY, DEFAULT_TRAINING_ENDPOINT),
);
@@ -42,6 +65,9 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
const [job, setJob] = useState<TrainingJob>();
const [presets, setPresets] = useState<RewardPreset[]>([]);
const [rewardPresetId, setRewardPresetId] = useState('');
const [pretrainedSourceId, setPretrainedSourceId] = useState('');
const [uploading, setUploading] = useState(false);
const [connectionRevision, setConnectionRevision] = useState(0);
const [busy, setBusy] = useState(false),
[error, setError] = useState<string>();
const [taskId, setTaskId] = useState('Unitree-Go2-Flat'),
@@ -53,15 +79,85 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
[gpuIds, setGpuIds] = useState('0'),
[wandbMode, setWandbMode] = useState<WandbMode>('offline');
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',
);
const [sensorCfg, setSensorCfg] = useState<Record<string, number>>({});
const metadata = server?.taskMetadata?.find((item) => item.id === taskId);
const sourceSelectionError = pretrainedSelectionError(
server?.pretrainedSources,
taskId,
pretrainedSourceId,
);
const selectTask = (id: string) => {
setTaskId(id);
setCustomTerrainBoxes(undefined);
setSyncedScene(undefined);
setRewardPresetId('');
setTerrainParams({});
setSensorCfg({});
setSensorMode('single_ring_raycast');
setTerrainPreset(id === OBSTACLE_TASK_ID ? 'discrete_obstacles' : '');
};
const syncMap = () => {
try {
if (sceneDirty) throw new Error('请先应用地图草稿,再同步训练地图');
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,
);
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));
}
};
const connectionEpoch = useRef(0);
const connected = () => {
connectionEpoch.current += 1;
setConnectionRevision(connectionEpoch.current);
setServer(undefined);
setJob(undefined);
setPresets([]);
setRewardPresetId('');
setError(undefined);
};
useEffect(
() => () => {
connectionEpoch.current += 1;
},
[],
);
const connect = async () => {
setBusy(true);
setError(undefined);
const epoch = ++connectionEpoch.current;
setConnectionRevision(epoch);
try {
const client = new LocalTrainingClient(endpoint, token),
info = await client.health();
if (epoch !== connectionEpoch.current) return;
setServer(info);
try {
setPresets(await client.presets());
const nextPresets = await client.presets();
if (epoch !== connectionEpoch.current) return;
setPresets(nextPresets);
} catch {
setPresets([]);
}
@@ -70,11 +166,12 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
} catch {
/* 当前会话仍可连接 */
}
if (info.tasks.length && !info.tasks.includes(taskId)) setTaskId(info.tasks[0]);
if (info.tasks.length && !info.tasks.includes(taskId)) selectTask(info.tasks[0]);
const remembered = info.activeJobId ?? localStored(TRAINING_JOB_KEY);
if (remembered) {
try {
const recovered = await client.job(remembered);
if (epoch !== connectionEpoch.current) return;
setJob(recovered);
try {
localStorage.setItem(TRAINING_JOB_KEY, recovered.id);
@@ -129,10 +226,57 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
typeof event.data !== 'object'
)
return;
const data = event.data as { type?: string; sessionId?: string; policy?: unknown };
const data = event.data as {
type?: string;
sessionId?: string;
policy?: unknown;
taskId?: string;
};
const source = event.source as Window;
if (data.type === 'mujoco-tuning-ready') {
source.postMessage({ type: 'mujoco-tuning-credentials', endpoint, token }, event.origin);
try {
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('碰撞场景已过时,请重新同步');
}
source.postMessage(
{
type: 'mujoco-tuning-credentials',
endpoint,
token,
trainingContext: {
taskId: taskId === OBSTACLE_TASK_ID ? taskId : 'Unitree-Go2-Flat',
seed,
...(pretrainedSourceId ? { pretrainedSourceId } : {}),
...(taskId === OBSTACLE_TASK_ID
? {
taskConfig: {
terrainPreset,
terrainParams,
sensorType: 'raycast',
sensorCfg: { ...sensorCfg, sensorMode },
...(terrainPreset === 'custom_boxes' ? { customTerrainBoxes } : {}),
},
}
: {}),
},
},
event.origin,
);
} catch (value) {
setError(errorText(value));
}
}
if (data.type === 'mujoco-tuning-import-policy' && data.sessionId) {
const reply = (ok: boolean, message?: string) => {
@@ -159,7 +303,12 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
if (!(policy instanceof File) || !/\.onnx$/i.test(policy.name))
throw new Error('调参工作台返回的 ONNX 策略无效');
if (policy.size > 64 * 1024 * 1024) throw new Error('ONNX 策略不能超过 64 MiB');
onPolicyReady(policy);
if (data.taskId === OBSTACLE_TASK_ID) {
const deployment = readPolicyDeployment(new Uint8Array(await policy.arrayBuffer()));
if (deployment?.taskId !== OBSTACLE_TASK_ID)
throw new Error('避障最佳策略缺少匹配部署契约');
await onPolicyReady(policy, deployment);
} else await onPolicyReady(policy);
reply(true);
} catch (value) {
const message = errorText(value);
@@ -171,7 +320,23 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
};
window.addEventListener('message', receive);
return () => window.removeEventListener('message', receive);
}, [endpoint, onPolicyReady, token]);
}, [
endpoint,
pretrainedSourceId,
onPolicyReady,
token,
taskId,
seed,
terrainPreset,
terrainParams,
sensorCfg,
sensorMode,
customTerrainBoxes,
syncedScene,
sceneMaps,
sceneDirty,
compileScene,
]);
const openTuningDashboard = () => {
rememberTrainingConnection(endpoint, token);
@@ -179,9 +344,20 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
};
const start = async () => {
if (uploading) return;
if (sourceSelectionError) {
setError(sourceSelectionError);
return;
}
setBusy(true);
setError(undefined);
try {
if (
taskId === OBSTACLE_TASK_ID &&
sensorMode === 'multi_ring_raycast' &&
!metadata?.sensorModes?.includes(sensorMode)
)
throw new Error('训练服务不支持multi_ring_raycast,请升级服务');
const ids =
device === 'gpu'
? gpuIds
@@ -191,6 +367,43 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
: [];
if (ids.some((id) => !Number.isInteger(id) || id < 0))
throw new Error('GPU 编号必须是非负整数');
for (const [values, schema] of [
[terrainParams, metadata?.terrainParameters],
[sensorCfg, metadata?.sensorParameters],
] as const) {
for (const [key, value] of Object.entries(values)) {
const bounds = schema?.[key];
if (
!bounds ||
!Number.isFinite(value) ||
value < bounds.min ||
value > bounds.max ||
(bounds.integer && !Number.isInteger(value))
)
throw new Error(`参数 ${key} 超出允许范围`);
}
}
if ((terrainParams.obstacle_height_min ?? 0.2) > (terrainParams.obstacle_height_max ?? 0.6))
throw new Error('障碍物最小高度不能超过最大高度');
if ((sensorCfg.safetyDistance ?? 0.5) >= (sensorCfg.maxDistance ?? 4))
throw new Error('安全距离必须小于探测距离');
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('已编译碰撞场景已过时,请重新同步');
}
const next = await new LocalTrainingClient(endpoint, token).start({
taskId,
numEnvs,
@@ -200,7 +413,13 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
device,
gpuIds: ids,
wandbMode,
rewardPresetId: rewardPresetId || undefined,
rewardPresetId: taskId === 'Unitree-Go2-Flat' ? rewardPresetId || undefined : undefined,
...(pretrainedSourceId ? { pretrainedSourceId } : {}),
...(terrainPreset ? { terrainPreset, terrainParams } : {}),
...(terrainPreset === 'custom_boxes' ? { customTerrainBoxes } : {}),
...(taskId === OBSTACLE_TASK_ID
? { sensorType: 'raycast' as const, sensorCfg: { ...sensorCfg, sensorMode } }
: {}),
});
setJob(next);
try {
@@ -231,7 +450,11 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
setBusy(true);
setError(undefined);
try {
onPolicyReady(await new LocalTrainingClient(endpoint, token).downloadPolicy(job.id));
if (job.taskId !== 'Unitree-Go2-Flat' && !job.deployment)
throw new Error('该任务缺少浏览器部署契约');
const deployment = job.deployment ? validatePolicyDeployment(job.deployment) : undefined;
const file = await new LocalTrainingClient(endpoint, token).downloadPolicy(job.id);
await onPolicyReady(file, deployment);
} catch (value) {
setError(errorText(value));
} finally {
@@ -249,7 +472,12 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
aria-label="本地训练服务地址"
className="field h-7 min-w-0 flex-1 px-2 text-xs text-text-primary"
value={endpoint}
onChange={(event) => setEndpoint(event.target.value)}
disabled={busy}
onChange={(event) => {
if (busy) return;
connected();
setEndpoint(event.target.value);
}}
/>
<Button
icon={<Link className="h-3.5 w-3.5" />}
@@ -268,7 +496,12 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
autoComplete="off"
className="field h-7 w-full px-2 text-xs text-text-primary"
value={token}
onChange={(event) => setToken(event.target.value)}
disabled={busy}
onChange={(event) => {
if (busy) return;
connected();
setToken(event.target.value);
}}
/>
</label>
<div className="mt-2 flex items-center justify-between rounded-md border border-border bg-surface px-2 py-1.5 text-[10px] text-text-tertiary">
@@ -288,21 +521,166 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
打开自调参 Agent 工作台
</Button>
{server?.ready && !job && (
<div className="mt-3 space-y-2">
<fieldset disabled={busy} className="mt-3 space-y-2">
<Field label="训练任务">
<Select
aria-label="训练任务"
className="w-full"
value={taskId}
onChange={(event) => setTaskId(event.target.value)}
onChange={(event) => selectTask(event.target.value)}
>
{server.tasks.map((task) => (
<option key={task} value={task}>
{task}
{server.taskMetadata?.find((item) => item.id === task)?.name ?? task}
</option>
))}
</Select>
</Field>
<PretrainedSourceSelect
sources={server.pretrainedSources}
taskId={taskId}
value={pretrainedSourceId}
onChange={setPretrainedSourceId}
disabled={busy || uploading}
upload={{
endpoint,
token,
revision: connectionRevision,
enabled: Boolean(server.pretrainedUpload?.enabled),
onBusyChange: setUploading,
onUploaded: (source) => {
setServer(
(current) =>
current && {
...current,
pretrainedSources: [
...(current.pretrainedSources ?? []).filter((s) => s.id !== source.id),
source,
],
},
);
setPretrainedSourceId(source.id);
},
}}
/>
{metadata && (
<>
<Field label="训练地形">
<Select
aria-label="训练地形"
value={terrainPreset}
onChange={(e) => {
setTerrainPreset(e.target.value);
setCustomTerrainBoxes(undefined);
setSyncedScene(undefined);
setTerrainParams({});
}}
>
{taskId !== OBSTACLE_TASK_ID && <option value="">原任务默认地形</option>}
{metadata.terrainPresets.map((preset) => (
<option key={preset} value={preset}>
{TERRAIN_LABELS[preset] ?? preset}
</option>
))}
</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、地下结构、混合摩擦明确拒绝。
</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 && terrainPreset !== 'custom_boxes' && (
<div className="grid grid-cols-2 gap-2">
{Object.entries(metadata.terrainParameters).map(([key, bounds]) => (
<NumberField
key={key}
label={PARAMETER_LABELS[key] ?? key}
value={terrainParams[key] ?? bounds.default}
min={bounds.min}
max={bounds.max}
step={bounds.integer ? 1 : 0.01}
onChange={(value) => setTerrainParams((old) => ({ ...old, [key]: value }))}
/>
))}
</div>
)}
{['rough', 'wave', 'pyramid_stairs'].includes(terrainPreset) && (
<p>训练专用 box 离散近似布局,不等于编辑器高度场。</p>
)}
{taskId === OBSTACLE_TASK_ID && (
<details open>
<summary>避障传感器高级设置</summary>
<Field label="传感器模式">
<Select
aria-label="传感器模式"
value={sensorMode}
onChange={(event) => setSensorMode(event.target.value as typeof sensorMode)}
>
<option value="single_ring_raycast">水平32射线 / 81维(默认)</option>
<option
value="multi_ring_raycast"
disabled={!metadata?.sensorModes?.includes('multi_ring_raycast')}
>
三层48射线 / 97维(非高程图)
</option>
</Select>
</Field>
{Object.entries(metadata.sensorParameters).map(([key, bounds]) => (
<NumberField
key={key}
label={PARAMETER_LABELS[key] ?? key}
value={sensorCfg[key] ?? bounds.default}
min={bounds.min}
max={bounds.max}
step={0.01}
onChange={(value) => setSensorCfg((old) => ({ ...old, [key]: value }))}
/>
))}
</details>
)}
{!metadata.browserCompatible && (
<p>此任务可训练,但浏览器不支持其观测契约,不能一键部署。</p>
)}
</>
)}
<div className="grid grid-cols-2 gap-2">
<NumberField
label="并行环境"
@@ -358,17 +736,20 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
</div>
<Field label="奖励配置">
<Select
disabled={taskId !== 'Unitree-Go2-Flat'}
aria-label="奖励配置 preset"
className="w-full"
value={rewardPresetId}
onChange={(event) => setRewardPresetId(event.target.value)}
>
<option value="">仓库默认奖励</option>
{presets.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name}
</option>
))}
{presets
.filter((preset) => preset.taskId === 'Unitree-Go2-Flat')
.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name}
</option>
))}
</Select>
</Field>
<Field label="实验记录">
@@ -387,7 +768,7 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
variant="primary"
className="w-full"
icon={<Play className="h-3.5 w-3.5" />}
disabled={busy}
disabled={busy || uploading || Boolean(sourceSelectionError)}
onClick={() => void start()}
>
发起本地训练
@@ -396,7 +777,7 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
训练使用本地 mjlab
任务资产,不会把浏览器中的模型上传到网络。服务一次只运行一个训练任务。
</p>
</div>
</fieldset>
)}
{job && (
<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
@@ -416,11 +797,26 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
{stateLabel(job.state)}
</Badge>
</div>
<PretrainedIdentity source={job.pretrained} />
{job.taskId === 'Unitree-Go2-Rough' && (
<p>234维 Rough 策略仅支持后端评测,浏览器不可加载。</p>
)}
{job.deployment?.terrain && (
<p className="text-[10px] text-text-tertiary">
导入将替换当前物理地图并启动配套策略;
{job.deployment.terrain.approximation ? '训练专用近似布局' : '配套碰撞布局'}
。请先保存场景。
</p>
)}
<ProgressBar value={job.progress} label="训练进度" />
<div className="mt-2">
<PropertyRow label="迭代" value={`${job.iteration} / ${job.maxIterations}`} />
<PropertyRow label="状态" value={job.message} />
{trainingLosses(job.logs).map(({ label, value }) => (
<PropertyRow key={label} label={label} value={value} />
))}
</div>
<TrainingMetricsPanel key={job.id} jobId={job.id} logs={job.logs} />
{job.logs.length > 0 && (
<details className="mt-2">
<summary className="cursor-pointer text-[10px] text-text-secondary">最近日志</summary>
@@ -443,7 +839,12 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
) : (
<>
<Button
disabled={busy || !job.artifactReady}
disabled={
busy ||
!job.artifactReady ||
job.taskId === 'Unitree-Go2-Rough' ||
(job.deployment && !job.deployment.browserCompatible)
}
icon={<Download className="h-3.5 w-3.5" />}
onClick={() => void importResult()}
>
@@ -492,11 +893,13 @@ function NumberField({
min,
max,
onChange,
step = 1,
}: {
label: string;
value: number;
min: number;
max: number;
step?: number;
onChange(value: number): void;
}) {
return (
@@ -504,6 +907,7 @@ function NumberField({
<input
aria-label={label}
type="number"
step={step}
className="field h-7 w-full px-2 text-xs text-text-primary"
value={value}
min={min}
@@ -513,3 +917,27 @@ function NumberField({
</Field>
);
}
const TERRAIN_LABELS: Record<string, string> = {
custom_boxes: '自定义场景碰撞布局(AABB近似)',
plane: '平地',
discrete_obstacles: '离散障碍物',
rough: '崎岖地面',
pyramid_stairs: '金字塔台阶',
wave: '波浪地形',
};
const PARAMETER_LABELS: Record<string, string> = {
size: '地图尺寸 m',
obstacle_count: '障碍物数量',
obstacle_height_min: '最小障碍高度 m',
obstacle_height_max: '最大障碍高度 m',
spacing: '障碍物间距 m',
friction: '地面摩擦',
roughness: '崎岖高度 m',
step_height: '台阶高度 m',
wave_amplitude: '波浪幅度 m',
fov: '感知角 FOV',
maxDistance: '探测距离 m',
safetyDistance: '安全距离 m',
avoidanceWeight: '避障权重',
};