Spaces:
Running
Running
File size: 19,576 Bytes
bb59211 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 | /** 「新建训练」页签 —— 五段式向导。
*
* ① 训练数据集(审核页导出后留下的成品,见 state/datasets/)
* ② 选择模型(ACT)+ 训练框架(LeRobot,只读)
* ③ 训练位置(私有算力 + 勾选参与训练的 GPU)
* ④ 训练参数(每项带一句说明)
* ⑤ 从已有训练继续(可选)
*
* **本期只做前端 —— 所以这一页是纯表单。**
*
* 这里曾经有一个禁用的「创建训练任务」按钮、外加一条生成好的 `lerobot-train`
* 命令行(带复制 / 下载 .sh)。已经删掉了:后端执行接上之前,那两样要么是假的
* 按钮,要么是绕路把命令当交付物。等真接上后端时,直接加一个**能点的**按钮,
* 把下面这些字段 POST 出去即可 —— 表单本身就已经是接口形状了。
*
* ★ 相应地,现在这一页的选择(数据集 / GPU / 参数 / 续训)**都不驱动任何输出**。
* 它们是为将来后端准备的状态,不是装饰;但也别指望现在改一下会有什么反应。
*
* 只有 ACT —— 其它策略(π0 / SmolVLA / diffusion…)都是 LeRobot 内置、跑得通,
* 但没在这套硬件上实测过,所以不摆一排点不动的卡片。
*/
import { el, clear } from '../shared/dom.js';
import { bytes, dateTime, thousands } from '../shared/format.js';
import { tr } from '../shared/text.js';
import { listDatasets, listGpus, listReadyProjects, listRuns } from './api.js';
/** 默认超参 —— 取自**实测那一次**的真实 train_config.json
* (checkpoints/020000),不是参考实现的示例值。
*
* 参考实现里 batch_size=2、steps=10000 是给它那个 8GB 的 pi0 用的;我们这次
* 198MB 的 ACT 在这台机器上 batch_size=16 / steps=20000 是跑通过的。首次训练
* 用跑通过的更稳。 */
const DEFAULTS = {
job_name: 'act',
batch_size: 16,
steps: 20000,
seed: 1000,
num_workers: 4,
eval_freq: 1000,
log_freq: 100,
save_checkpoint: true,
save_freq: 5000,
};
/** 参数说明 —— 直接对应参考实现每项右侧那句话,首次训练的人看得懂。 */
const PARAM_HELP = {
job_name: ['train_help_job_name', 'Name of this run. It becomes the checkpoint folder name, so keep it short and unique.'],
batch_size: ['train_help_batch', 'Larger batches train more stably but need more VRAM; smaller ones are faster but less stable. Suggested 1-32.'],
steps: ['train_help_steps', 'More steps usually improve the policy but risk overfitting. Start at 10000 and adjust from validation.'],
seed: ['train_help_seed', 'A fixed seed reproduces the same run, which makes debugging and comparison possible. 1000 or 42.'],
num_workers: ['train_help_workers', 'More workers load data faster but use more memory. About 1/2 to 1x the CPU core count.'],
eval_freq: ['train_help_eval_freq', 'How often to run an evaluation, in steps. About 10% of total steps.'],
log_freq: ['train_help_log_freq', 'How often to refresh progress, in steps. 10-100 works well.'],
save_checkpoint: ['train_help_save_ckpt', 'Save checkpoints while training so a run can be resumed or deployed.'],
save_freq: ['train_help_save_freq', 'How often to save a checkpoint, in steps. About 30% of total steps.'],
};
const INPUT_CLASS = 'bg-gray-800 text-xs rounded px-2 py-1 border border-gray-700 '
+ 'focus:border-blue-500 focus:outline-none font-mono';
const BTN = 'bg-gray-800 hover:bg-gray-700 text-gray-300 text-xs px-2.5 py-1 '
+ 'rounded border border-gray-700';
const LIVE = 'bg-green-900/50 text-green-300';
const PENDING = 'bg-amber-900/50 text-amber-300';
function section(titleKey, fallback, badge, children) {
return el('div', { class: 'bg-gray-900 rounded-lg border border-gray-800 p-4 mb-4' }, [
el('div', { class: 'flex items-center gap-3 mb-3' }, [
el('div', { class: 'text-sm font-semibold text-gray-200', text: tr(titleKey, fallback) }),
badge ? el('span', { class: `text-[10px] px-1.5 py-0.5 rounded ${badge.cls}`, text: badge.text }) : null,
]),
...children,
]);
}
function hint(key, fallback) {
return el('div', { class: 'text-xs text-gray-600 mt-3', text: tr(key, fallback) });
}
/** 一行「标签 + 控件」,失败/加载中都用它占位。 */
function labeled(label, control, labelClass = 'text-gray-500 shrink-0 w-32') {
return el('div', { class: 'flex items-center gap-3 text-xs' }, [
el('span', { class: labelClass, text: label }),
control,
]);
}
function loadingRow(text) {
return el('div', { class: 'text-xs text-gray-600 py-3', text });
}
function errorRow(text) {
return el('div', { class: 'text-xs text-red-400 py-3', text });
}
export function renderNewRun(container) {
clear(container);
const state = { gpus: new Set(), dataset: null, resume: null };
// ── ① 训练位置 ──────────────────────────────────────
const gpuHost = el('div', {});
const locationCard = el('div', {
class: 'inline-block border-2 border-blue-500 bg-blue-950/30 rounded-lg px-4 py-3 min-w-[220px]',
}, [
el('div', { class: 'text-sm font-semibold text-gray-100', text: tr('train_loc_local', 'Local GPU server') }),
el('div', { class: 'text-xs text-gray-400 mt-1', text: tr('train_loc_local_desc', 'GPUs on this machine') }),
]);
const sectionLocation = section('train_sec_location', '1. Training Location', null, [
locationCard,
el('div', { class: 'mt-4' }, [gpuHost]),
]);
function renderGpus(payload) {
clear(gpuHost);
if (!payload.available) {
// 驱动没起来时**必须区别显示** —— 渲染成"利用率 0%"会让人以为显卡闲着。
gpuHost.appendChild(el('div', {
class: 'text-xs text-amber-300 bg-amber-950/40 border border-amber-900 rounded px-3 py-2',
}, [
el('div', { text: tr('train_gpu_unavailable', 'Cannot read the GPUs') }),
el('div', { class: 'text-amber-500/80 mt-1 font-mono break-all', text: payload.error }),
el('div', { class: 'text-amber-500/80 mt-1', text: tr('train_gpu_unavailable_hint', 'Fix the driver before training — training cannot start without it.') }),
]));
return;
}
if (!payload.gpus.length) {
gpuHost.appendChild(errorRow(tr('train_gpu_none', 'nvidia-smi reported no GPUs.')));
return;
}
gpuHost.appendChild(el('div', { class: 'flex gap-3 flex-wrap' },
payload.gpus.map((gpu) => {
const usedRatio = gpu.memory_total_mb
? Math.round((gpu.memory_used_mb / gpu.memory_total_mb) * 100) : 0;
const busy = gpu.utilization > 5 && gpu.index !== 0;
const checkbox = el('input', {
type: 'checkbox', class: 'accent-blue-500',
checked: state.gpus.has(gpu.index),
});
checkbox.addEventListener('change', () => {
if (checkbox.checked) state.gpus.add(gpu.index);
else state.gpus.delete(gpu.index);
});
return el('div', { class: 'bg-gray-900 border border-gray-800 rounded-lg p-3 w-64' }, [
el('div', { class: 'flex items-center gap-2' }, [
el('span', { class: 'text-sm font-semibold text-gray-100', text: `GPU ${gpu.index}` }),
el('span', {
class: `ml-auto text-[10px] px-1.5 py-0.5 rounded ${busy ? 'bg-amber-900/50 text-amber-300' : 'bg-green-900/50 text-green-300'}`,
text: busy ? tr('train_gpu_busy', 'busy') : tr('train_gpu_idle', 'idle'),
}),
]),
el('div', { class: 'text-xs text-gray-400 mt-1', text: gpu.name }),
el('div', { class: 'mt-2 text-xs text-gray-500' }, [
el('div', { text: `${tr('train_gpu_mem', 'VRAM')} ${gpu.memory_used_mb} / ${gpu.memory_total_mb} MB (${usedRatio}%)` }),
el('div', { class: 'h-1.5 bg-gray-800 rounded mt-1' }, [
el('div', { class: 'h-1.5 bg-blue-500 rounded', style: `width:${usedRatio}%` }),
]),
el('div', { class: 'mt-1', text: `${tr('train_gpu_util', 'Utilization')} ${gpu.utilization}%` }),
el('div', { text: `${tr('train_gpu_temp', 'Temp')} ${gpu.temperature}°C` }),
]),
el('label', { class: 'flex items-center gap-2 mt-2 text-xs text-gray-400 cursor-pointer' }, [
checkbox,
el('span', { text: tr('train_gpu_include', 'Use for training') }),
]),
]);
})));
}
gpuHost.appendChild(loadingRow(tr('loading', 'Loading...')));
listGpus()
.then(renderGpus)
.catch((err) => { clear(gpuHost); gpuHost.appendChild(errorRow(`${tr('train_load_failed', 'Failed to load')}: ${err.message}`)); });
// ── ② 选择模型 + 训练框架 ───────────────────────────
const modelCard = el('div', {
class: 'inline-block border-2 border-blue-500 bg-blue-950/30 rounded-lg px-4 py-3 min-w-[240px]',
}, [
el('div', { class: 'flex items-center gap-2' }, [
el('span', { class: 'text-sm font-semibold text-gray-100', text: 'ACT' }),
el('span', { class: 'text-[10px] px-1.5 py-0.5 rounded bg-blue-900/60 text-blue-300', text: tr('train_new_model_tested', 'tested') }),
]),
el('div', { class: 'text-xs text-gray-400 mt-1.5', text: tr('train_new_model_act_desc', 'Transformer action-chunking imitation learning policy') }),
]);
const sectionModel = section('train_sec_model', '2. Model', null, [
el('div', { class: 'flex items-start gap-6 flex-wrap' }, [
el('div', {}, [modelCard]),
el('div', {}, [
el('div', { class: 'text-xs text-gray-500 mb-1.5', text: tr('train_framework', 'Training framework') }),
// 只读:ACT 在 LeRobot 里只有一种实现,没有可选项。做成下拉框是假的。
el('div', {
class: 'inline-block bg-gray-800 border border-gray-700 rounded px-3 py-1.5 text-xs text-gray-200 font-mono',
text: 'LeRobot',
}),
el('div', { class: 'text-xs text-gray-600 mt-1.5', text: tr('train_framework_hint', 'ACT ships as a single LeRobot implementation, so there is nothing to choose here.') }),
]),
]),
]);
// ── ③ 训练参数 ──────────────────────────────────────
const paramInputs = {};
const paramRows = Object.keys(DEFAULTS).map((name) => {
const [helpKey, helpFallback] = PARAM_HELP[name] || ['', ''];
let control;
if (name === 'save_checkpoint') {
control = el('select', { class: INPUT_CLASS + ' w-28' }, [
el('option', { value: 'true', text: 'true' }),
el('option', { value: 'false', text: 'false' }),
]);
} else {
control = el('input', { class: INPUT_CLASS + ' w-28', value: String(DEFAULTS[name]) });
}
paramInputs[name] = control;
return el('div', { class: 'flex items-start gap-4 text-xs py-1' }, [
el('span', { class: 'text-gray-400 shrink-0 w-36 font-mono pt-1.5', text: name }),
el('div', { class: 'shrink-0' }, [control]),
el('span', { class: 'text-gray-600 pt-1.5 flex items-start gap-1.5' }, [
el('iconify-icon', {
icon: 'ant-design:info-circle-outlined',
class: 'mt-0.5 shrink-0',
}),
el('span', { text: tr(helpKey, helpFallback) }),
]),
]);
});
const sectionParams = section('train_sec_params', '3. Training Parameters',
{ cls: PENDING, text: tr('train_new_pending', 'not wired') }, [
...paramRows,
el('button', {
class: 'mt-3 ' + BTN,
text: tr('train_new_reset', 'Restore measured defaults'),
on: {
click: () => {
for (const [name, control] of Object.entries(paramInputs)) control.value = String(DEFAULTS[name]);
},
},
}),
]);
// ── ④ 训练数据集 ────────────────────────────────────
//
// 来源是**在视频审核页导出后留下来的成品**(`state/datasets/<名字>`),不是
// 原始项目。原始项目里混着未审核的集,而且留出集(UMIGripper_Action_AI 的
// 52–73)就在里面 —— 按项目选只能报警、拦不住。改以导出产物为准之后,两个
// 坑都在导出那一步被排掉了。
//
// ★ 正因如此,这里**不再有留出集防线**:导出后的数据集集号会被重排成
// 0..N-1,已经认不出哪一集原本是 52–73。控制点转移到了导出那一步 ——
// 导出时别把留出集勾进去。要加硬校验的话,位置在审核/导出页而不是这里。
const datasetHost = el('div', {});
const readyHost = el('div', {});
const sectionDataset = section('train_sec_dataset', '4. Training Dataset',
{ cls: LIVE, text: tr('train_new_live', 'live') }, [
datasetHost,
readyHost,
]);
/** 「到点提醒」—— 项目已通过集数达到它自己设的采集上限时高亮出来。
*
* **只提醒,不自动导出。** 导出一次约 30 秒/集(UI 自己的文案),1000 集要跑
* 8 小时以上;而且构建好的数据集会被"固定"下来 —— 已经拿去训练的那份不能
* 悄悄变。让人点一下,换到的是"我知道现在在导什么、用哪份数据"。
*
* 没到点时也给一行「最接近的」,否则这块永远是空的,看不出进展。 */
function renderReady(payload) {
clear(readyHost);
const ready = (payload.projects || []).filter((p) => p.ready);
if (ready.length) {
readyHost.appendChild(el('div', {
class: 'mt-3 text-xs bg-green-950/40 border border-green-900 rounded px-3 py-2',
}, [
el('div', {
class: 'text-green-300 font-medium mb-2',
text: tr('train_ready_title', 'Reached its collection limit — ready to export'),
}),
...ready.map((p) => el('div', { class: 'flex items-center gap-3 py-0.5' }, [
el('span', { class: 'text-gray-200 font-mono flex-1 truncate', text: p.name }),
el('span', { class: 'text-gray-400 shrink-0', text: `${p.approved} / ${p.target}` }),
el('a', {
href: '/review?status=approved',
class: 'text-blue-400 hover:underline shrink-0',
text: tr('train_ready_go', 'Go export'),
}),
])),
]));
return;
}
// 没到点就**什么都不显示** —— 曾经在这里给一行「离采集上限最近的 X: n/N」,
// 但它每次都在,而绝大多数时候又离得很远(实测 3/100、52/10000),读起来像
// 一句永远不变的噪音。到点了自然会冒出来,没到点就安静待着。
}
function datasetEmptyState() {
return el('div', { class: 'text-center py-6' }, [
el('div', { class: 'text-xs text-gray-500 mb-3', text: tr('train_dataset_none', 'No exported dataset yet.') }),
el('a', {
href: '/review?status=approved',
class: 'inline-block bg-blue-600 hover:bg-blue-500 text-white text-xs px-3 py-1.5 rounded',
text: tr('train_dataset_goto_export', 'Go export approved episodes'),
}),
]);
}
function renderDatasets(payload) {
clear(datasetHost);
if (!payload.datasets.length) {
datasetHost.appendChild(datasetEmptyState());
return;
}
datasetHost.appendChild(el('div', { class: 'border border-gray-800 rounded divide-y divide-gray-800' },
payload.datasets.map((ds) => {
const radio = el('input', { type: 'radio', name: 'train-dataset', class: 'accent-blue-500 shrink-0' });
radio.addEventListener('change', () => {
state.dataset = ds;
});
return el('label', {
class: 'flex items-center gap-3 px-3 py-2 text-xs cursor-pointer hover:bg-gray-800/40',
}, [
radio,
el('span', { class: 'text-gray-200 font-mono w-64 truncate', text: ds.name }),
el('span', { class: 'text-gray-400 w-20 text-right', text: `${ds.total_episodes} ${tr('train_episodes', 'episodes')}` }),
el('span', { class: 'text-gray-500 w-24 text-right', text: `${thousands(ds.total_frames)} ${tr('train_new_frames', 'frames')}` }),
el('span', { class: 'text-gray-600 w-12 text-right', text: ds.codebase_version }),
el('span', { class: 'text-gray-600 ml-auto', text: ds.fps ? `${ds.fps} fps` : '' }),
]);
})));
}
datasetHost.appendChild(loadingRow(tr('loading', 'Loading...')));
listDatasets()
.then(renderDatasets)
.catch((err) => { clear(datasetHost); datasetHost.appendChild(errorRow(`${tr('train_load_failed', 'Failed to load')}: ${err.message}`)); });
// 到点提醒是**加分项**:拿不到(比如扫描超时)就整块不显示,不拦这一页 ——
// 它只是提示"该导出了",不是这一页的主功能。
listReadyProjects()
.then(renderReady)
.catch(() => clear(readyHost));
// ── ⑤ 从已有训练继续(可选)─────────────────────────
const resumeTask = el('select', { class: INPUT_CLASS + ' w-64' }, [
el('option', { value: '', text: tr('train_resume_none', '— Start fresh —') }),
]);
const resumeStep = el('select', { class: INPUT_CLASS + ' w-40', disabled: true }, [
el('option', { value: '', text: '—' }),
]);
let runsCache = [];
resumeTask.addEventListener('change', () => {
clear(resumeStep);
const run = runsCache.find((r) => r.name === resumeTask.value);
if (!run) {
resumeStep.disabled = true;
resumeStep.appendChild(el('option', { value: '', text: '—' }));
state.resume = null;
return;
}
resumeStep.disabled = false;
resumeStep.appendChild(el('option', { value: '', text: tr('train_resume_latest', '— latest —') }));
for (const c of run.checkpoints) {
resumeStep.appendChild(el('option', { value: String(c.step), text: `${c.name} (${bytes(c.total_bytes)})` }));
}
state.resume = { run: run.name, step: run.checkpoints[0]?.step ?? null };
});
resumeStep.addEventListener('change', () => {
state.resume = resumeTask.value
? { run: resumeTask.value, step: resumeStep.value ? parseInt(resumeStep.value, 10) : null }
: null;
});
const sectionResume = section('train_sec_resume', '5. Continue From a Previous Run',
{ cls: PENDING, text: tr('train_new_pending', 'not wired') }, [
el('div', { class: 'flex gap-6 flex-wrap' }, [
labeled(tr('train_resume_task', 'Source run'), resumeTask),
labeled(tr('train_resume_step', 'Checkpoint'), resumeStep),
]),
]);
listRuns()
.then((payload) => {
runsCache = payload.runs || [];
for (const run of runsCache) {
resumeTask.appendChild(el('option', { value: run.name, text: `${run.name} (${run.checkpoint_count})` }));
}
})
.catch(() => { /* 拿不到就保持"全新训练",不拦整页 */ });
// 展示顺序 ≠ 构造顺序。构造时必须先定义好各自依赖的变量(GPU 面板、数据集的
// 留出集警告、命令生成…),这里才决定它们从上到下怎么排。
//
// 顺序:数据集 → 模型 → 算力 → 参数。先定"用什么数据",再定"训什么模型",
// 再定"在哪训",最后"怎么训" —— 决策依赖就是按这个方向走的(比如数据集决定了
// 命令里的 --dataset.root)。
for (const node of [sectionDataset, sectionModel, sectionLocation, sectionParams,
sectionResume]) {
container.appendChild(node);
}
}
|