AI Engineer in robotic系列:从地图导入到机器人执行:导览平台 MVP 技术设计与落地

1 阅读15分钟

这篇文章记录一个机器人导览平台 MVP 的技术设计与实现过程。

平台要解决的不是单一页面问题,而是一条完整链路:

导入机器人地图包
→ 在 Web 端编辑点位和区域
→ 编排导览流程
→ 生成机器人可执行的任务链
→ 上传并触发机器人执行
→ 实时展示导览进度

早期设计中混合过几种不同方案,例如由平台主动连接机器人、由机器人主动连接平台,以及不同的状态管理方式。最终实现时,我把地图数据、业务流程和机器人协议拆成了三个相对独立的层次,避免页面逻辑直接依赖机器人通信细节。

一、整体架构选型

前端

  • 核心框架:React 19 + TypeScript
    前后端都使用 TypeScript,便于统一接口模型和动作类型。

  • UI 组件库:Ant Design
    适合管理后台和复杂表单,可以快速搭建地图列表、流程编排、机器人档案等页面。

  • 地图渲染:Konva.js / react-konva
    Canvas 2D 更适合地图编辑器。底图、路径、点位和区域可以分层渲染,并支持缩放、平移、框选与拖拽。

  • 本地编辑状态:Redux Toolkit
    用于保存地图编辑中的点位、区域、选择状态和交互模式。

  • 服务端状态:TanStack Query
    负责接口请求、缓存、刷新和 Mutation 状态,避免把服务端数据重复塞进本地 Store。

  • 路由与构建:React Router + Vite

后端

  • 框架:NestJS + TypeScript
    模块边界清晰,也方便同时承载 REST API、原生 WebSocket 和 Socket.IO。

  • ORM:Prisma

  • 数据库:PostgreSQL
    地图、点位、流程、步骤和执行记录之间存在明确的关系,关系型数据库更适合维护一致性。

分层后的职责

前端编辑器
  └─ 负责交互和可视化

平台业务后端
  ├─ 地图与流程数据
  ├─ 任务链生成
  └─ 执行状态管理

机器人协议适配层
  ├─ SSH / SFTP
  ├─ HTTP 控制命令
  └─ 原生 WebSocket 状态

这样做的好处是:即使后续更换机器人协议,地图编辑和流程编排也不需要整体重写。

二、地图包导入

1. 输入不是一张图片,而是一个地图包

机器人地图通常以 ZIP 包交付,里面至少包含:

  • 路网描述文件;
  • 一张或多张定位底图;
  • 与底图对应的 YAML 元数据;
  • 路网节点和路径信息。

因此,导入流程不是简单地上传 PNG,而是:

选择 ZIP
→ 后端解压
→ 找到路网描述文件
→ 确定默认定位地图
→ 找到对应 PNG 和 YAML
→ 读取底图尺寸、resolution 和 origin
→ 导入节点与路径
→ 保存地图元数据

如果地图包中有多个定位地图,优先使用调用方指定的定位地图;没有指定时,使用默认项,再兜底到第一项。

伪代码如下:

function resolveLocationMapFiles(
  packageRoot: string,
  roadnet: RoadNet,
  locationMapId?: string,
) {
  const info = locationMapId
    ? roadnet.locationMaps.find(item => item.id === locationMapId)
    : roadnet.locationMaps.find(item => item.isDefault)
      ?? roadnet.locationMaps[0]

  return {
    imagePath: resolve(packageRoot, info.image),
    yamlPath: resolve(packageRoot, info.yaml),
    locationMapId: info.id,
  }
}

2. 地图元数据

YAML 中真正影响平台显示和坐标换算的核心信息只有:

  • resolution:一个像素对应现实世界中的多少米;
  • origin:地图原点在世界坐标系中的偏移;
  • 图片真实宽高。

平台将这些信息保存为地图元数据:

{
  "resolution": 0.03,
  "origin": [-3.5, 0, 0],
  "widthPx": 1433,
  "heightPx": 1218,
  "locationMapId": "default-location-map"
}

底图文件放入后端静态资源目录,前端通过地图详情接口获取 imageUrl。

3. 节点坐标转换

路网中的节点通常是世界坐标,而 Web 画布使用图片像素坐标。导入时必须统一转换:

pixelX = (worldX - originX) / resolution
pixelY = imageHeight - (worldY - originY) / resolution

Y 轴需要翻转,因为机器人世界坐标通常向上为正,而图片坐标向下为正。

所有点位、路径控制点和后续区域编辑都必须复用同一套坐标转换函数。坐标换算如果散落在不同组件中,后续很容易出现路径偏移、点位对不上底图等问题。

4. 路径导入

导入一条路径时,需要:

  1. 找到起点和终点对应的节点;
  2. 选择与当前定位地图匹配的节点坐标;
  3. 将起点、终点和控制点统一转换为像素坐标;
  4. 保存路径类型和控制点。

三次贝塞尔曲线可以直接保存:

[起点, 控制点 1, 控制点 2, 终点]

前端读取路径类型后,用 Konva 的线或曲线组件叠加到底图上。

三、地图编辑器

地图编辑页面采用左右布局:

  • 左侧是地图画布;
  • 右侧是点位或区域的编辑面板。

画布按以下层次渲染:

Stage
  ├─ 底图层
  ├─ 路径层
  ├─ 区域层
  └─ 点位层

1. 缩放和平移

Konva 的 Stage 负责整体缩放和平移。滚轮缩放时,要保证鼠标指向的地图位置保持不变:

const mousePointTo = {
  x: (pointer.x - stage.x()) / oldScale,
  y: (pointer.y - stage.y()) / oldScale,
}

stage.scale({ x: newScale, y: newScale })
stage.position({
  x: pointer.x - mousePointTo.x * newScale,
  y: pointer.y - mousePointTo.y * newScale,
})

当用户点击画布时,还要把屏幕坐标反算为底图原始像素坐标:

pixelX = (screenX - stageX) / scaleX
pixelY = (screenY - stageY) / scaleY

最终结果需要裁剪在底图范围内,避免在地图外创建点位。

2. 区域框选

区域使用一维坐标数组保存:

[x1, y1, x2, y2, x3, y3, x4, y4]

矩形框选过程为:

  1. mousedown 记录起点;
  2. mousemove 根据当前坐标绘制临时矩形;
  3. mouseup 固定四个角点;
  4. 写入前端编辑状态;
  5. 保存时统一提交后端。

选中区域后,拖动区域主体可以整体平移。实现时不单独移动某个顶点,而是把拖拽偏移量应用到所有坐标。

3. 点位配置

点击点位后,右侧面板可以编辑:

  • 点位名称;
  • 主点或辅助点;
  • 默认讲解文本。

点位中的默认讲解文本属于地图业务数据,不属于某一条具体流程。流程步骤可以直接复用,也可以在编排时覆盖。

4. 保存与导出

保存地图时,前端一次提交点位和区域:

{
  "points": [
    {
      "nodeId": "example-point",
      "name": "入口迎宾",
      "type": "main",
      "script": "欢迎各位来宾"
    }
  ],
  "areas": [
    {
      "id": "area-1",
      "name": "迎宾区",
      "points": [40, 220, 160, 220, 160, 320, 40, 320]
    }
  ]
}

平台同时保存数据库数据和地图包旁的业务配置文件。导出时:

  1. 用数据库中的最新数据刷新业务配置文件;
  2. 保留原始路网文件,不进行重写;
  3. 将原地图目录、底图、YAML 和业务配置重新打包为 ZIP。

这样既能在平台中编辑,也能让地图包继续作为独立交付物使用。

四、流程编排

地图编辑解决“有哪些点”,流程编排解决“按什么顺序去这些点,以及到点后做什么”。

流程编排独立于地图编辑页面,采用左右两栏:

  • 左侧:流程列表;
  • 右侧:步骤列表和动作配置。

1. 流程和步骤

核心关系如下:

Map
  └─ Flow
       └─ FlowPoint
            └─ Point

Flow 保存流程名称、所属地图和任务链标识。

FlowPoint 保存:

  • 所属流程;
  • 对应点位;
  • 执行顺序 sequence;
  • 动作配置;
  • 是否已完成配置。

2. 添加与排序

添加点位时,不直接在地图上点击,而是打开点位选择弹窗:

  1. 展示当前地图的全部点位;
  2. 已经加入流程的点位禁用;
  3. 用户勾选点位;
  4. 按选择顺序追加到步骤末尾。

同一个点位在同一流程中只允许出现一次。

调整步骤顺序后,后端重新写入连续的 sequence:

调整前:P1=1, P3=2, P5=3
将 P5 上移后:P1=1, P5=2, P3=3

序号连续比保留间隔值更简单,也便于机器人状态映射回当前步骤。

3. 动作配置

每个步骤卡片直接配置动作,MVP 支持:

  • 迎宾播报;
  • 讲解播报;
  • 肢体动作;
  • 固定等待。

动作使用 JSON 保存:

{
  "actionKind": "lecture",
  "text": "这里是产品展示区"
}

固定等待:

{
  "actionKind": "wait",
  "seconds": 5
}

讲解动作未填写文本时,默认使用点位上的讲解文本;步骤中填写了文本时,以步骤配置为准。

五、从流程生成机器人任务链

流程数据是面向业务人员的,机器人执行文件是面向协议的。两者不应该共用同一种数据结构。

因此,平台在导出或启动导览时,把流程“编译”为机器人任务链。

1. 展开规则

每个流程步骤展开为:

移动到点位
→ 执行该点位配置的动作

例如:

步骤 1:点位 A + 迎宾
步骤 2:点位 B + 讲解
步骤 3:点位 C + 等待

会生成:

N1 move → A
N2 action → 迎宾
N3 move → B
N4 action → 讲解
N5 move → C
N6 sleep → 等待

2. 移动节点

移动节点使用地图路网中的原始节点 ID:

{
  "nodeType": "move_action",
  "nodeIdStr": "N1",
  "nodes": [
    { "nodeId": "example-roadnet-point" }
  ]
}

平台 UUID 只用于数据库关联,不能直接下发给机器人。

3. 动作节点

播报和肢体动作转换为脚本执行节点。固定等待可以转换为机器人支持的原生等待节点;如果需要外部跳过,则转换为可被外部停止的等待脚本。

动作映射集中维护:

迎宾播报 → 迎宾脚本 + 文本参数
讲解播报 → 讲解脚本 + 文本参数
肢体动作 → 动作脚本 + 动作资产
固定等待 → 原生等待或可中断等待脚本

不要在页面组件中拼接机器人协议 JSON。前端只保存业务动作,协议转换统一放在后端。

4. 任务链树

全部节点按顺序挂在一个 sequence 下:

root
  └─ sequence
       ├─ N1 move
       ├─ N2 action
       ├─ N3 move
       ├─ N4 action
       └─ ...

核心伪代码:

function exportTaskChain(flow: FlowWithSteps) {
  const nodes = {}
  const children: string[] = []
  let index = 1

  for (const step of flow.steps.sort(bySequence)) {
    const moveId = `N${index++}`
    const actionId = `N${index++}`

    nodes[moveId] = buildMoveNode(moveId, step.point.nodeId)
    nodes[actionId] = buildActionNode(actionId, step.action, step.point.script)

    children.push(moveId, actionId)
  }

  return buildSequenceTaskChain(flow, children, nodes)
}

六、机器人接入与任务下发

平台预先维护机器人档案,包括:

  • 机器人名称和地址;
  • SSH / SFTP 连接信息;
  • 机器人端任务链目录;
  • 控制服务配置;
  • 设备唯一标识;
  • 当前地图和运行状态。

启动导览时,后端按顺序执行:

校验机器人状态
→ 原子占用机器人
→ 生成任务链
→ 创建执行记录
→ SFTP 上传任务链
→ 通知机器人重新加载任务链目录
→ 触发指定任务链
→ 返回 executionId

1. 启动前校验

至少需要校验:

  • 机器人已启用;
  • 机器人在线;
  • 当前状态为空闲;
  • 电量达到最低要求;
  • 机器人当前地图与流程地图一致。

机器人占用必须是原子操作,不能先查询再更新,否则两个并发请求可能同时启动同一台机器人。

2. 两种通信方向

平台与机器人之间有两类通信:

平台主动调用机器人

  • SFTP 上传任务链;
  • HTTP 发送重新加载和执行命令。

机器人主动连接平台

  • 原生 WebSocket 鉴权;
  • 心跳;
  • 状态上报。

这两个方向不能混在一起理解。HTTP 控制成功只表示命令已被接受,机器人真实状态仍应以后续状态上报为准。

3. 失败回滚边界

在触发任务链之前失败,平台可以安全地:

  • 将执行记录标记为失败;
  • 把机器人状态恢复为空闲。

一旦已经尝试触发机器人,就不能简单回滚为“未执行”。因为网络超时并不代表机器人没有收到命令,机器人端可能已经开始运行。

七、实时执行进度

实时状态链路分成两段:

机器人
  └─ 原生 WebSocket
       └─ 平台状态解析
            └─ 更新执行记录
                 └─ Socket.IO
                      └─ 浏览器页面

1. 机器人到平台

机器人主动连接平台的协议端点,并持续发送:

  • 鉴权信息;
  • 心跳;
  • 电量与运行状态;
  • 当前或最近经过的路网节点;
  • 动作执行状态。

平台把连接与机器人档案绑定,并更新在线状态和最后活跃时间。

2. 路网节点映射为流程步骤

机器人上报的是路网节点 ID,而前端需要的是“当前第几步”。

映射过程为:

lastNodeId
→ 在当前地图中找到 Point
→ 在当前 Flow 中找到 FlowPoint
→ 取得 sequence
→ 更新 TourExecution.currentSequence

3. 平台到浏览器

浏览器拿到 executionId 后,订阅对应房间:

socket.emit('execution:subscribe', { executionId })

后端将连接加入:

execution:<executionId>

订阅成功后立即返回一次当前快照,而不是等待下一条机器人状态。这样即使页面刷新或订阅较晚,也能恢复当前进度。

快照可以保持很小:

{
  "type": "execution_state",
  "executionId": "example-execution",
  "status": "running",
  "lastNodeId": "example-node",
  "currentSequence": 2,
  "updatedAt": "2026-01-01T10:20:35Z"
}

前端收到后,只需要根据 currentSequence 高亮对应步骤。

4. 导览完成

机器人上报任务完成后,后端:

  1. 将执行记录更新为 completed;
  2. 写入结束时间;
  3. 将机器人恢复为空闲;
  4. 推送最后一条执行快照;
  5. 前端停止高亮动画并显示完成状态。

八、核心数据模型

Map

保存:

  • 地图名称;
  • 地图业务 ID;
  • 原始包与解压目录;
  • 底图 URL;
  • 分辨率、原点、图片尺寸等元数据。

Point

保存:

  • 平台 UUID;
  • 所属地图;
  • 路网原始节点 ID;
  • 像素坐标;
  • 点位名称、类型和默认讲解文本。

MapPath

保存:

  • 路网原始边 ID;
  • 起点和终点;
  • 直线或曲线类型;
  • 像素坐标下的控制点。

Area

保存:

  • 所属地图;
  • 区域名称;
  • 多边形坐标;
  • 可选显示颜色。

Flow

保存:

  • 所属地图;
  • 流程名称和描述;
  • 任务链标识;
  • 流程状态。

FlowPoint

保存:

  • 所属流程;
  • 对应点位;
  • 连续的步骤序号;
  • 动作 JSON;
  • 配置状态。

TourExecution

保存:

  • 机器人和流程;
  • 任务链标识;
  • 当前状态;
  • 最近节点;
  • 当前步骤;
  • 开始、更新时间和结束时间。

九、主要接口边界

对外接口按业务能力划分,而不是按页面划分:

地图
POST   /maps/upload
GET    /maps
GET    /maps/{id}
PUT    /maps/{id}/tour
GET    /maps/{id}/export

流程
POST   /flows
POST   /flows/{id}/points
PUT    /flows/{id}/reorder
PUT    /flows/{id}/steps/{stepId}
POST   /flows/{id}/export-taskchain

机器人
POST   /robots
POST   /robots/{id}/request-connection
POST   /robots/start-tour

执行
GET    /executions/{id}
WebSocket subscribe execution:{executionId}

具体部署路径、主机地址和端口都通过环境配置提供,不写死在前端或业务代码中。

十、实现过程中最重要的几个决策

1. 地图原始数据与业务数据分离

路网文件描述机器人可行走的基础地图,点位名称、讲解文本和区域属于导览业务配置。平台不重写原始路网,只增加独立的业务配置文件。

2. 流程动作与机器人协议分离

流程保存的是 lecture / arm / wait 这类业务动作,后端在导出时再转换为机器人协议。这样流程编辑器不需要理解复杂的协议字段。

3. 统一坐标转换

节点、路径控制点、鼠标点击和区域编辑必须使用同一套世界坐标与像素坐标转换。坐标转换应作为共享工具,而不是散落在组件里。

4. 状态上报才是真实状态

HTTP 命令返回成功不等于机器人已经执行完成。平台可以记录“命令已下发”,但运行中、完成或失败应以后续状态帧为准。

5. 订阅后立即发快照

实时系统不能只依赖增量消息。新订阅者先拿当前快照,再接收后续更新,才能正确处理刷新、断线重连和晚订阅。

6. 控制链路和展示链路分开

机器人通过原生 WebSocket 与平台通信,浏览器通过 Socket.IO 订阅平台状态。平台负责协议翻译,浏览器不直接连接机器人。

十一、MVP 范围与后续方向

MVP 先完成以下闭环:

  • 地图包导入与解析;
  • 点位、路径和区域展示;
  • 点位与区域编辑;
  • 流程创建、添加点位和排序;
  • 步骤动作配置;
  • 任务链生成与上传;
  • 机器人启动与状态跟踪;
  • 前端实时高亮当前步骤。

后续可以继续扩展:

  • 动作资产缩略图与预览;
  • 可被外部跳过的等待动作;
  • 有限或无限循环导览;
  • 未连接机器人时的流程仿真;
  • 对话服务随导览自动启停;
  • 更完整的失败恢复和状态轮询兜底;
  • 多机器人调度。

总结

这个 MVP 最难的地方并不是某一个页面,而是让四种数据在同一条链路中保持一致:

机器人路网坐标
→ Web 地图像素坐标
→ 业务流程步骤
→ 机器人任务链节点

最终架构中,地图负责空间数据,流程负责业务顺序,任务链生成器负责协议转换,执行模块负责机器人状态。把这几个边界拆清楚后,地图编辑、流程编排和机器人接入才能独立演进。

部分协议字段和真机行为仍需要以目标机器人实际版本为准,尤其是注册鉴权、动作完成状态和异常恢复边界。对这类外部协议,代码中的抽象应保持稳定,具体适配则集中在机器人协议层。