把训练过程做成前端能实时刷新的可视化
先澄清:项目里其实有两条"可视化"
读代码时最容易踩的坑是把 legacy 的 visualization.py 当成"前端实时刷新"的实现。实际上它是离线出图;真正支撑前端实时曲线的是另一条 API 链路。两条链路职责不同,拆开讲才看得清。
链路一:legacy 离线可视化(visualization.py)
visualization.py 461 行,技术栈是 matplotlib + Agg 非交互后端——即"无头环境出图",不弹窗口、直接画 PNG 存盘。它产出的图表覆盖训练复盘的全部需要:
| 函数 | 图表 |
|---|---|
| plot_training_loss | 步级 loss 细线 + 移动平均粗线 + 学习率双轴 |
| plot_evaluation_results | 2×2 四联图(成败柱状/成功率曲线/耗时直方/饼图) |
| plot_success_rate_comparison | 三任务成功率对比 + 加权总分 |
| plot_action_sequence | 每关节一条折线,双臂拆上下两半 |
| plot_action_heatmap | (T, action_dim) 热力图 |
| save_evaluation_report | 汇总 PNG + 文本报告 |
loss 曲线那幅图值得细看,它把"实验复盘"的体验做得很完整:
if len(losses) > 50:
window = min(100, len(losses) // 10)
moving_avg = _moving_average(losses, window)
ax1.plot(avg_steps, moving_avg, color="#F44336", linewidth=2.0, ...)
...
if lr_history and len(lr_history) == len(losses):
ax2 = ax1.twinx()
ax2.plot(steps, lr_history, color="#4CAF50", linewidth=1.5,
linestyle="--", alpha=0.7, label="Learning Rate")
- 原始 loss 画细浅线,移动平均画粗红主线——原始曲线噪声大,肉眼很难判断趋势;窗口
min(100, len//10)让窗口随数据量自适应(数据少时窗口也小,避免过度平滑)。 - 学习率用
twinx()画在次坐标轴——训练中"loss 不降"最常被问的问题是"是不是学习率出了问题",把学习率曲线和 loss 叠在一张图上,一眼就能对照"loss 拐点"和"lr 变化"的关系。这是实验工具里性价比极高的设计。
离线链路的价值不在"实时",在"复盘与留存":训练结束后把整套图存盘,形成可回溯的实验记录。它和实时链路是互补关系,不是替代关系。
链路二:真实的前端实时刷新(API + 轮询)
这才是"前端能实时刷新"的真相,由三部分组成。
数据管道(data_loader.py)
CloudRoboDataset 是对 LeRobot 数据集的薄封装,几个决策值得注意:
- 样本 = 单帧(frame),不是整条 episode——训练时按帧采样更灵活;
- 子集选择用等距取步长(
step = total_frames / num_data),不是随机采样——保证取到的帧均匀覆盖整条轨迹,避免随机采样集中在某段;这对模仿学习的"轨迹分布均匀"很重要; - 先增强、后归一化:
if self.image_aug is not None:
img = self.image_aug(img) # 先增强(0-255 域)
img = img.float() / 255.0 # 再归一化到 [0,1]
增强在整数像素域做,归一化在增强之后——因为裁剪、抖动、加噪声这些操作对 0-255 的整数域语义更直观,归一化后再做增强会破坏统计分布。顺序错了,增强效果和归一化语义都会变味。
实时采集(训练 API + 回调)
后端训练用 threading.Thread(daemon=True) 在后台跑,指标采集靠 Ultralytics 的回调钩子:
def on_fit_epoch_end(trainer):
record = {
"epoch": trainer.epoch + 1,
"loss": float(loss) if loss else 0,
"precision": float(metrics.get("metrics/precision(B)", 0)),
"recall": float(metrics.get("metrics/recall(B)", 0)),
"mAP50": float(metrics.get("metrics/mAP50(B)", 0)),
"mAP50_95": float(metrics.get("metrics/mAP50-95(B)", 0)),
}
TRAINING_STATUS["metrics_history"].append(record)
model.add_callback("on_fit_epoch_end", on_fit_epoch_end)
关键决策是把指标写进全局状态字典 TRAINING_STATUS,而不是直接推给前端。这样训练线程和 API 线程通过共享状态解耦:训练只负责"写",查询接口只负责"读",谁也不用等谁。
前端轮询
前端 ModelTraining.vue 里 pollTimer = setInterval(fetchStatus, 2000)——每 2 秒拉一次 /api/training/status 和 /api/training/metrics,拿到 metrics_history 后用 ECharts 画 loss / precision / recall / mAP50 / mAP50-95 五条曲线。
轮询 vs WebSocket:现在的取舍是合理的
全项目 grep websocket|SSE|EventSource,代码里没有任何 WebSocket/SSE,只有 blog 里写着"未来可用 WebSocket 推送指标替代轮询"。为什么现在用轮询?
- 2 秒间隔对训练场景足够——一个 epoch 通常几十秒,2 秒拉一次已经能给出"实时感",不存在聊天/游戏那种毫秒级诉求;
- 实现复杂度差一个量级——轮询 = 一个定时器 + 一个 GET;WebSocket 需要管理连接生命周期、断线重连、心跳,还要防训练线程和推送通道的耦合;
- 容错简单——浏览器刷新页面后轮询自动恢复,WebSocket 却要处理"服务端在训练中途重启"的僵死连接。
真实场景里,在数据变化慢、查询成本低的场景,轮询是比推送更工程正确的选择。什么时候该升级?当指标采集频率上升到"每步"级别、页面上需要毫秒级反馈、或者多个客户端要共享同一推送通道时,再上 WebSocket 不迟——而且升级路径是清晰的(回调里 push 而不是 append 到状态字典)。
总结
这条"实时可视化"的真相是:轮询是更朴素、更可靠的实时。离线 matplotlib 出图管复盘留存,API 链路用"回调写全局状态 + 2 秒轮询 + ECharts"管实时展示——两条链路各司其职,没有一处用到复杂的推送协议,却完整覆盖了"训练可视化"这个需求。它提醒我:实时不一定等于推送,先算清"我真需要多快",再决定用什么协议。
- 点赞
- 收藏
- 关注作者
评论(0)