分类: 知识储备

  • WebDAV(Shell)

    WebDAV(Web Distributed Authoring and Versioning)是由 IETF 标准化的 HTTP 扩展协议,初版规范为 RFC 2518(1999 年),现行版本为 RFC 4918(2007 年)。协议在 HTTP/1.1 的方法集、头字段与状态码体系之上引入集合(collection)、属性(property)与锁(locking)三类模型,使 Web 服务器从单向的内容分发系统扩展为具备完整读写能力的分布式文档仓库。由于扩展严格保持与既有 HTTP 语义兼容,WebDAV 复用 80/443 端口及现有代理、CDN 基础设施即可部署,网络层面无需引入任何专用通道。本文先给出协议的系统性描述,随后在 Ubuntu 环境下以 Apache httpd 作为服务端实现,配合 curl、cadaver、davfs2、rclone 四个命令行工具,覆盖协议调试、交互式管理、文件系统挂载与自动化同步四类使用形态。

    一、协议扩展机制与核心方法

    HTTP/1.1 的原生方法仅覆盖资源获取与有限的状态变更,缺少目录层级语义、元数据查询与并发控制能力。WebDAV 在此基础上新增七个方法:MKCOL 创建集合,即目录节点;COPY 与 MOVE 实现资源复制与移动,目标 URI 通过 Destination 请求头传递,Overwrite 请求头(取值 T 或 F,默认 T)控制目标资源已存在时的覆盖行为,Overwrite 为 F 且目标存在时返回 412 Precondition Failed;PROPFIND 检索资源属性,Depth 请求头(0、1 或 infinity)控制遍历深度,是协议内使用频率最高的方法;PROPPATCH 修改属性;LOCK 与 UNLOCK 管理锁资源。方法集的设计目标是以最小扩展覆盖本地文件系统的基本操作语义。
    全部扩展交互以 XML 作为编码载体。PROPFIND 的响应采用专用状态码 207 Multi-Status,响应体为 multistatus 文档,可在单次响应中承载目录树内多个资源的状态与属性,突破了 HTTP 一个响应对应单一资源的限制。WebDAV 相关的专用状态码还包括:422 Unprocessable Entity(请求体结构或语义非法)、423 Locked(目标资源处于锁定状态)、424 Failed Dependency(多状态请求中部分子操作失败)、507 Insufficient Storage(服务器存储耗尽)。对不存在的中间路径执行 MKCOL 将返回 409 Conflict,客户端需逐级创建。

    二、属性模型与并发控制语义

    属性分为活属性(live property)与死属性(dead property)。活属性由服务器强制维护并保证语义一致性,典型包括 creationdate、displayname、getcontentlength、getcontenttype、getetag、getlastmodified、resourcetype、supportedlock 与 lockdiscovery,客户端不可直接改写;死属性为客户端经 PROPPATCH 附加的任意名值对,服务器仅承担存储职责。PROPFIND 在不带请求体时按协议规定等同于 allprop,亦可通过请求体显式指定 allprop、propname 或具体属性名列表(prop),请求体根元素为 propfind,属于 DAV: 命名空间。

    并发控制包含乐观与悲观两种路径。乐观路径依赖强 ETag 与条件请求头:客户端在 PUT 时携带 If-Match 前次读取的 ETag,服务器在资源版本不匹配时返回 412 并拒绝写入,构成比较并交换(CAS)语义。悲观路径由 LOCK 实现:锁类型分 write 独占锁与共享锁,锁令牌为 opaquelocktoken 形式的 URI,Timeout 请求头声明锁租期(如 Second-600),客户端通过携带 If 头的重复 LOCK 请求续租,UNLOCK 携带 Lock-Token 头释放资源;锁冲突时服务器返回 423 Locked。服务器在 OPTIONS 响应的 DAV 头中声明能力等级,1 表示支持基本 WebDAV,2 表示叠加锁支持,即 DAV: 1, 2。工程实践中部分服务端实现为规避锁管理的复杂度而未启用 2 级能力,客户端应据此降级。

    # 查询服务器能力等级
    curl -i -X OPTIONS http://10.0.0.5/dav/
    # 响应头:DAV: 1, 2  Allow: OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK
     
    # 对资源加 600 秒独占写锁
    curl -u alice:pass -X LOCK -H "Timeout: Second-600" \
      --data '<?xml version="1.0"?><D:lockinfo xmlns:D="DAV:"><D:locktype><D:write/></D:locktype><D:lockscope><D:exclusive/></D:lockscope><D:owner>alice</D:owner></D:lockinfo>' \
      http://10.0.0.5/dav/docs/report.txt

    在此协议族之上,IETF 继续构建了多层扩展:DeltaV(RFC 3253)引入版本控制方法集(VERSION-CONTROL、CHECKOUT、CHECKIN、REPORT 等),Subversion 的 HTTP 访问层即基于 DeltaV 实现(mod_dav_svn);CalDAV(RFC 4791)将 iCalendar 日历对象建模为 WebDAV 资源并扩展查询语义;CardDAV(RFC 6352)对 vCard 通讯录做同等抽象。三者均以 RFC 4918 为基础层,这一继承关系是 WebDAV 长期存在于主流操作系统与协作软件中的结构性原因。

    三、与 FTP、SMB、NFS 的技术对比及适用域

    FTP 采用控制连接与数据连接分离的双通道模型,存在主动与被动两种数据建立模式,在 NAT 与防火墙环境中需要动态端口放行;协议自身不提供通道机密性,须叠加 TLS(FTPS)或经 SSH 隧道改造。WebDAV 的传输层与 HTTP 完全同构:单通道、无状态、认证复用 HTTP 认证框架(可插拔 Basic、Digest、客户端证书或应用层令牌),机密性由 TLS 统一提供,这是其在广域网与零信任环境中的结构性优势。
    SMB 与 NFS 是面向局域网的会话型文件系统协议,具备打开句柄复用、字节范围锁与 oplock/委托等细粒度缓存一致性机制,吞吐与延迟特性显著优于 WebDAV。WebDAV 的每次操作均为独立 HTTP 请求,不存在会话与句柄复用,代价体现为海量小文件场景下的每请求固定开销与大目录枚举时的 multistatus 文档体积;变更检测依赖 getlastmodified 与 getetag,无校验和语义,断点续传不在协议规范之内,取决于客户端实现。因此 WebDAV 的适用域为文档同步、备份归档、跨网内容分发与轻量挂载,而非数据库文件、虚拟机镜像等高随机 I/O 负载,后者应使用块存储或网络文件系统。

    四、服务端实现:Ubuntu 下的 Apache httpd mod_dav

    Ubuntu 仓库中的 Apache httpd 由两个模块构成完整服务端:mod_dav 实现协议引擎,mod_dav_fs 作为文件系统后端提供资源仓库,二者通过 DAV provider 接口衔接。启用锁支持时必须有锁数据库,由 DavLockDB 指令指定;该指令必须位于服务器级配置而非 Directory 容器内,且锁数据库所在文件系统须支持 fcntl 锁,禁止置于 NFS 等网络文件系统之上。Debian/Ubuntu 的软件包已随 dav_fs 模块附带 /etc/apache2/mods-enabled/dav_fs.conf,其中配置了 DavLockDB /var/lock/apache2/DAVLock,无需重复定义。

    sudo apt update && sudo apt install -y apache2
    sudo a2enmod dav dav_fs auth_digest

    资源仓库目录的权限模型需要明确:mod_dav 以 MPM 运行用户(Ubuntu 默认 www-data)执行全部文件系统写入,所有通过 WebDAV 认证的用户共享该 OS 级身份,文件属主同质化是该实现的固有约束。按用户隔离需划分独立 Directory 树并分别授权,或改用具备用户映射能力的服务端实现。

    sudo mkdir -p /var/www/webdav
    sudo chown -R www-data:www-data /var/www/webdav

    认证选用 mod_auth_digest 提供的 HTTP Digest。口令文件由 htdigest 生成,存储格式为 user:realm:MD5(user:realm:password),其中 realm 必须与配置中的 AuthName 严格一致,否则认证必然失败;-c 参数仅在首次创建口令文件时使用,追加用户时省略以免覆盖。

    sudo htdigest -c /etc/apache2/webdav.passwd "WebDAV" alice

    站点配置如下。Alias 建立 URL 命名空间与文件系统路径的映射;Dav On 在 Directory 容器内启用 WebDAV 处理;AuthDigestDomain 声明保护空间覆盖的 URI 范围;AuthDigestProvider file 指定凭据来源;Require valid-user 表示口令文件内全部用户均可通过认证。Apache 默认未限制请求体体积(LimitRequestBody 为 0),如有需要可在该容器内显式收紧。

    Alias /dav /var/www/webdav
     
    <Directory /var/www/webdav>
        Dav On
        AuthType Digest
        AuthName "WebDAV"
        AuthDigestDomain /dav
        AuthDigestProvider file
        AuthUserFile /etc/apache2/webdav.passwd
        Require valid-user
    </Directory>

    启用配置后执行 reload,httpd 以优雅重启(graceful)方式加载,不中断存量连接。验证序列如下:OPTIONS 响应头出现 DAV: 1, 2 确认协议与锁能力就绪;PROPFIND Depth 0 请求返回 207 及根集合属性;若服务面向公网,同步放行防火墙端口,并按第九节启用 TLS。

    sudo a2enconf webdav
    sudo systemctl reload apache2
    curl -i -X OPTIONS http://127.0.0.1/dav/
    curl -i -u alice:pass -X PROPFIND -H "Depth: 0" http://127.0.0.1/dav/
    sudo ufw allow 80,443/tcp

    五、协议级客户端操作:curl

    WebDAV 即 HTTP,curl 可直接驱动全部协议方法,是调试与脚本化操作的基础工具。集合 URI 按惯例以斜杠结尾;PUT 对应上传(curl 的 -T 参数),GET 对应下载,MOVE 与 COPY 仅在是否保留源资源上存在语义差异。标准操作序列如下:

    # 创建集合
    curl -u alice:pass -X MKCOL http://10.0.0.5/dav/docs/
    # 上传(PUT),成功返回 201 Created
    curl -u alice:pass -T report.txt http://10.0.0.5/dav/docs/report.txt
    # 枚举集合(PROPFIND Depth: 1),成功返回 207
    curl -u alice:pass -X PROPFIND -H "Depth: 1" http://10.0.0.5/dav/docs/
    # 下载(GET)
    curl -u alice:pass -O http://10.0.0.5/dav/docs/report.txt
    # 移动/重命名(MOVE),目标位于 Destination 头
    curl -u alice:pass -X MOVE -H "Destination: http://10.0.0.5/dav/docs/2026-report.txt" http://10.0.0.5/dav/docs/report.txt
    # 复制(COPY),目标存在且禁止覆盖时返回 412
    curl -u alice:pass -X COPY -H "Overwrite: F" -H "Destination: http://10.0.0.5/dav/docs/report.bak.txt" http://10.0.0.5/dav/docs/report.txt
    # 删除,成功返回 204 No Content
    curl -u alice:pass -X DELETE http://10.0.0.5/dav/docs/report.bak.txt

    生产脚本中应使用条件写入避免覆盖他人更新:先经 GET 或 PROPFIND 取得 getetag,再以 If-Match 头执行 PUT,服务器在 ETag 失配时返回 412。PROPFIND 亦可显式指定属性集合以减小响应体,请求体示例如下:

    curl -u alice:pass -X PROPFIND -H "Depth: 1" -H "Content-Type: application/xml" \
      --data '<?xml version="1.0"?><D:propfind xmlns:D="DAV:"><D:prop><D:getcontentlength/><D:getlastmodified/></D:prop></D:propfind>' \
      http://10.0.0.5/dav/docs/
     
    # 条件写入(CAS 语义)
    curl -u alice:pass -T report.txt -H 'If-Match: "a1b2c3-4d5"' http://10.0.0.5/dav/docs/report.txt

    六、交互式客户端:cadaver

    cadaver 是基于 neon 库的命令行 WebDAV 客户端,交互模型与 FTP shell 同构,支持 TLS 与 2 级锁能力,适合运维现场的交互式排查。会话内可用命令覆盖完整协议面:ls、cd、pwd 操作远端目录,lcd、lpwd 切换本地工作目录,get、put 与 mget、mput 承担单文件与批量传输,mkcol、delete、copy、move 对应集合与资源的组织操作,quit 退出。凭据可通过 ~/.netrc 持久化,字段格式为 machine 主机、login 用户名、password 密码,文件权限必须设为 0600。

    sudo apt install -y cadaver
    cadaver http://10.0.0.5/dav/
     
    # ~/.netrc 免密配置
    machine 10.0.0.5
    login alice
    password pass

    七、文件系统级集成:davfs2

    davfs2 基于 FUSE 实现,将 WebDAV 资源呈现为 POSIX 文件系统视图,经 setuid 挂载助手 mount.davfs 执行挂载。本地缓存默认位于 /var/cache/davfs2,承担元数据与文件内容的缓存职责;凭据写入 /etc/davfs2/secrets,格式为“URL 用户名 密码”一行,权限 0600;持久化挂载经 fstab 声明,_netdev 保证网络就绪后执行,user 允许非特权用户挂载与卸载。

    sudo apt install -y davfs2
    sudo mkdir -p /mnt/webdav
    sudo mount -t davfs http://10.0.0.5/dav/ /mnt/webdav
    # /etc/davfs2/secrets
    http://10.0.0.5/dav alice pass
     
    # /etc/fstab
    http://10.0.0.5/dav/ /mnt/webdav davfs rw,_netdev,user 0 0

    davfs2 的缓存不具备多客户端一致性协议,多台主机并发挂载同一仓库并高强度写入将产生陈旧读与写冲突;服务器锁实现不严格时,可在 /etc/davfs2/davfs2.conf 中设置 use_locks 0 关闭锁机制以规避 423 问题,代价是失去写互斥保护。该实现定位于文档与备份类轻负载,高频随机 I/O 场景不在其设计目标之内。

    八、rclone:同步与用户态挂载

    rclone 将 WebDAV 实现为内置存储后端,配置参数包括 url、vendor(标准实现选 other,Nextcloud、owncloud、SharePoint 等有专门适配以兼容其行为差异)、user 与 pass,配置持久化于 ~/.config/rclone/rclone.conf。需要指出,WebDAV 后端不提供校验和能力,rclone 的变更判定依据 size 与 modtime;当服务器 modtime 不可靠时可改用 –size-only 比对策略。

    sudo apt install -y rclone
    rclone config
    rclone listremotes
    rclone lsd webdav:

    copy 执行增量复制,sync 使目标与源严格一致(源端删除会传播到目标),两者均建议先以 –dry-run 预演;check 用于双向差异校验;–bwlimit 以令牌桶算法限速,–transfers 与 –checkers 控制并发度;需要保留历史版本时,–backup-dir 将被删除与被覆盖的文件移入归档目录而非直接丢弃。

    rclone copy /home/alice/documents webdav:backup/documents -P
    rclone sync /home/alice/documents webdav:backup/documents --dry-run -P
    rclone sync /home/alice/documents webdav:backup/documents -P --bwlimit 10M --backup-dir webdav:archive/$(date +%F)

    调度由 cron 承担。rclone 同时提供用户态挂载 rclone mount(依赖 FUSE),–vfs-cache-mode 取值 off、minimal、writes、full 逐级扩展缓存范围,writes 与 full 可保证写语义正确,–dir-cache-time 控制目录元数据缓存周期,行为较 davfs2 更接近同步盘。此外 rclone serve webdav 可将任意本地目录或 rclone 远端直接发布为 WebDAV 服务,支持 –user/–pass 认证与 –cert/–key 证书,是无依赖场景下的轻量服务端选项。

    # crontab:每日 03:00 同步并记录日志
    0 3 * * * /usr/bin/rclone sync /home/alice/documents webdav:backup/documents >> /var/log/rclone-webdav.log 2>&1
     
    # 用户态挂载
    rclone mount webdav: /mnt/cloud --daemon --vfs-cache-mode writes --dir-cache-time 60s
     
    # 轻量服务端
    rclone serve webdav /srv/dav --addr 0.0.0.0:8080 --user alice --pass pass

    九、传输安全与认证方案选择

    推荐基线为 TLS 加 Basic 认证。HTTP Digest 的默认摘要函数为 MD5(RFC 7616 引入 SHA-256 变体),质询-响应对一旦被捕获即可用于离线口令猜测,抗攻击强度显著弱于 TLS 通道加密;客户端兼容性方面,Windows WebClient 对 Digest 存在实现缺陷,macOS Finder 同样存在兼容性问题,而 Basic 在全平台客户端中一致性最好,在 TLS 保护下其安全性短板不再成立。Ubuntu 上经 certbot 签发 Let’s Encrypt 证书并可自动改写 Apache 配置与重定向规则,续期由 systemd timer 自动完成;建议叠加 HSTS 响应头强制 HTTPS。

    sudo apt install -y certbot python3-certbot-apache
    sudo certbot --apache -d dav.example.com

    十、故障模式与定位

    高频故障的根因分布如下。403 Forbidden 优先核查文件系统权限:www-data 对仓库目录需具备写权限,且路径各级目录须允许执行位(x)穿越;405 Method Not Allowed 通常源于 Dav On 未作用于请求所命中的 Directory,或模块未启用,应核对 Alias 映射与容器作用域;423 Locked 由未释放的锁残留引起,锁租期到期后自动失效,或重启 httpd 清空锁数据库;500 错误需检查 DavLockDB 配置及其所在文件系统是否支持 fcntl 锁;无限深度枚举被 Apache 默认的 DavDepthInfinity off 策略拒绝,属预期行为。客户端侧,Windows WebClient 存在单文件 50MB 上限与 Basic 认证通道限制,分别对应注册表项 WebClient\Parameters\FileSizeLimitInMB 与 BasicAuthLevel;nginx 自带的 ngx_http_dav_module 仅实现 PUT、DELETE、MKCOL、COPY、MOVE,PROPFIND 与 LOCK 依赖第三方 dav_ext 模块,工程上更常见的选择是容器化服务端(Nextcloud 等)或 rclone serve webdav。

    综上,WebDAV 以七个方法、一个属性模型与一套锁机制的代价,将通用读写能力纳入 HTTP 体系,并在此基础上支撑了 DeltaV、CalDAV、CardDAV 等上层协议。Ubuntu 环境下,Apache mod_dav 提供成熟的服务端实现,curl 与 cadaver 覆盖协议级调试与交互式管理,davfs2 与 rclone 分别对应文件系统挂载与自动化同步两种消费形态;配合 TLS 与定时调度,即可在生产环境中构建稳定、可审计的网络存储链路。

  • 从分割掩码到相机系 3D 位姿:关键点定位与朝向角 (x, y, z, yaw) 的实现

    核心问题:如何从一张 RGB 图像出发,输出目标物体上某个关键点在【相机坐标系】下的 {x, y, z, yaw} 四元组——其中 (x, y, z) 是关键点相对相机的三维位置,yaw 是目标在该点的水平朝向角(主轴方向)。

    整套方案基于「分割掩码 + 深度图 + 相机内参」:先用 YOLO 分割出目标区域,再从掩码中提取关键点像素坐标,结合深度与内参投影到相机系,最后用两点法(底部→顶部)估算朝向。目前深度相机尚未到手,当前为模拟深度阶段的预研代码。本文记录完整链路、实现细节、踩过的坑与真机改造点,可复用到各类「视觉引导定位 / 视觉伺服」任务。

    一、背景与目标

    • 输入:RGB 图像(已能用 YOLO 分割出目标物体及其相邻参考区域)
    • 目标:输出目标关键点在【相机坐标系】下的 {x, y, z, yaw} 四元组
    • 下游:机械臂 / 执行机构据此移动到位,并按朝向角旋转工具对准目标
    • 相机:深度相机(RGB-D)

    二、完整链路

    RGB 图
      → ① YOLO 分割(目标 / 参考区域 mask)
      → ② 从掩码提取目标关键点 (u, v)
      → ③ 取该像素深度 d
      → ④ 像素 + 深度 + 内参 → 相机系 3D 点 (x, y, z)
      → ⑤ 两点法(底部→顶部)算 yaw
      → ⑥ 输出 JSON {x, y, z, yaw} + 渲染图

    三、各步骤详解

    1、YOLO 分割

    • 模型:自训练分割权重 best.pt
    • 置信度:CONF_THRESH = 0.3
    • 类别:目标物体(0) / 参考区域A(1) / 参考区域B(2)(按任务自行定义)
    • 推理:mode「代码结构」(原项目目录树)→ 整体替换为「核心流程(伪代码)」:单帧主流程 1~6 步l.predict(orig_frame, conf=conf)(默认 imgsz=640)

    2、提取目标关键点(三重交叉区域法)

    核心思路:在「目标 × 参考区域A × 参考区域B」的三重交叉区域中,取离目标质心最近的点作为底部关键点(anchor)。

    伪代码:提取底部关键点
    for 每个目标掩码 T:
        候选 = []
        for 参考区域A × 参考区域B 的每个组合:
            overlap = 交集(T, A, B)          # 三重交叉区域
            if overlap 非空:
                p = overlap 中离 T 质心最近的点
                候选.append(p)
        anchor = 候选里离质心最近的点
        if anchor 为空:                       # fallback
            anchor = T 底部 20% 区域的质心

    fallback:无三重交叉 anchor 时,用目标掩码底部 20% 区域质心作为关键点(保证链路可验证)。

    顶部点:目标掩码顶部 20% 区域质心(与底部对称),用于 yaw 计算。

    关键坑(已修复):mask 必须用 result.masks.xy(ultralytics 已映射回原图的多边形)经 fillPoly 生成。不要用 cv2.resize(mask, (w,h))——imgsz=640 时 mask 是 640×480,原图 3072×4096,非等比拉伸(宽4.8x / 高8.5x)导致 mask 形状严重变形,描线/标记偏移真实物体。

    补充说明 1:为什么用「区域质心」而不是极值点?

    若取”最远点/极值点”:容易被掩码边角的尖刺、缺口带偏;区域质心 = 对该区域内所有像素取平均,天然抗噪、稳定。

    3、取像素深度 d(真机改造点)

    • 模拟版simulate_depth() 生成假深度图(uint16 毫米)
      • 目标区域基准深度 SIM_BASE_DEPTH_MM = 800.0
      • 沿目标底部→顶部方向线性渐变(顶部近 SIM_SLOPE=5%
      • 渐变让两点深度不同 → yaw 计算有物理意义(见补充说明 2)
    • 真机版depth_frame.get_data()[v,u] * get_depth_scale()
      • 需 D2C 对齐(彩色图与深度图逐像素对应)
      • 深度空洞处理(d<=0 时取邻域均值 / 标记失败)

    补充说明 2:为什么模拟深度需要渐变?

    情况1:深度恒定(无渐变)
      底部点与顶部点 z 相同 → Δz = 0
      yaw = atan2(Δx, 0) 退化为只看水平像素偏移,
      且与深度无关,无法验证"像素+深度→3D"这条链路。
    
    情况2:深度线性渐变(本实现)
      底部 z_A ≠ 顶部 z_B → Δz ≠ 0
      yaw 同时综合了水平偏移与深度差,
      更接近真机(物体表面本来就有深度变化),链路更有验证价值。

    4、像素 + 深度 + 内参 → 3D 点(手写投影)

    相机内参(★真机用 SDK 出厂标定):

    fx = (W/2) / tan(FOV_x/2)     # 水平视场角 FOV_x
    fy = (H/2) / tan(FOV_y/2)     # 垂直视场角 FOV_y,模拟阶段取图片实际尺寸
    cx = W/2, cy = H/2            # 主点居中

    投影公式:

    x = (u - cx) * d / fx
    y = (v - cy) * d / fy
    z = d

    单位:毫米。直观理解:像素相对主点的偏移量(像素)× 深度 ÷ 焦距 = 物理偏移量(毫米)

    补充说明 3:相机坐标系约定(重要)

    
    约定来源:图像坐标系原点在左上角、行号 v 向下增长
    取 Y 朝下可让 3D 坐标与像素行方向一致,避免符号翻转混乱。
    (该约定与 OpenCV / ROS 相机模型一致)

    5、两点法算 yaw

    特征点 A = 目标底部关键点(anchor)
    特征点 B = 目标顶部点(掩码顶部 20% 质心)
    
    yaw = atan2(x_B - x_A, z_B - z_A)    # 度,范围 -180°~180°

    yaw = 目标主轴(底部→顶部方向)在 XZ 平面的水平夹角,下游执行机构据此旋转工具对准目标朝向。

    补充说明 4:yaw 的几何含义(俯视图)

    俯视 XZ 平面(从 Y 轴正上方往下看):
    
    yaw = atan2(Δx, Δz),其中 Δx = x_B - x_A,Δz = z_B - z_A
      若目标偏向右侧:yaw > 0;偏向左侧:yaw < 0。
    
      说明:这里只取 XZ 平面算水平朝向角(通常最关心的一项);
      若需要完整姿态(俯仰/翻滚角),可扩展为三点法,
      或对掩码点云做 PCA 取主轴向量。

    6、输出

    {
      "image": "frame_0001.jpg",
      "targets": [
        {"u": 1666, "v": 495, "x_mm": 63.14, "y_mm": -315.8, "z_mm": 800.0, "yaw_deg": 132.0}
      ]
    }

    渲染图标注:黄十字 = 底部关键点,蓝十字 = 顶部点,绿线 = 目标主轴方向,文本 = xyz/yaw 信息。

    四、坐标系与输出字段

    字段含义符号约定
    x_mm左右偏移光心右正左负
    y_mm上下偏移光心下正上负(Y 朝下)
    z_mm深度距离恒正(沿 Z 轴)
    yaw_deg水平朝向角-180° ~ 180°

    五、核心流程(伪代码)

    伪代码:单帧主流程
    1. RGB 帧 → YOLO 分割 → 掩码列表(目标 + 参考区域)
    2. 提取关键点:
       anchor = 三重交叉区域中离目标质心最近的点(无则取底部 20% 质心)
       top    = 目标掩码顶部 20% 区域的质心
    3. 取深度:
       d_bottom = 深度图[anchor]      # 模拟版:读模拟深度图
       d_top    = 深度图[top]         # 真机版:SDK 深度帧(注意 D2C 对齐与空洞处理)
    4. 投影到相机系:
       A = pixel_to_xyz(anchor, d_bottom)
       B = pixel_to_xyz(top, d_top)
    5. 算朝向:yaw = atan2(x_B - x_A, z_B - z_A)
    6. 输出 {x, y, z, yaw} + 渲染标注图

    六、真机改造点汇总(★)

    位置模拟版真机版
    simulate_depth假深度(800mm+渐变)SDK depth_frame.get_data()
    get_depth_at查模拟深度图depth[v,u] * get_depth_scale()
    fx/fy/cx/cyFOV 反推 + 主点居中SDK 出厂标定内参
    图片分辨率实际图片尺寸相机 RGB 流分辨率
    输出JSON 打印封装函数供下游调用

    七、关键经验(踩过的坑)

    1. mask 非等比拉伸cv2.resize 到原图会变形,必须用 result.masks.xy(原图坐标多边形)
    2. 顶部点不用最远点:最远点会跑到掩码角落尖角,应用顶部区域质心
    3. 模拟深度需渐变:让两点深度不同,yaw 才有物理意义
    4. fallback 保证链路:无三重交叉 anchor 时用目标底部中心,确保模拟阶段可验证
    5. 坐标系约定要统一:Y 朝下与像素 v 方向一致,避免后续执行机构换算时符号出错

    八、工作逻辑

    # 处理单张图片或文件夹(模拟阶段可先跑少量样本)
    python <入口脚本> <图片路径/文件夹>
    
    # 结果输出到自动递增的目录:predict → predict2 → predict3 ...

    九、验收标准(对应方案文档)

    1. 输出 (x, y, z, yaw) 连续 100 帧 yaw 波动 < 5°
    2. 深度误差 < 2%(1m 处 ±2cm)
    3. 单帧处理满足下游需求(30fps 或按需触发)
  • FAST-LIVO(docker)

    一、FAST-LIVO是什么?

    FAST-LIVO(Fast LiDAR-Inertial-Visual Odometry)是香港大学MARS实验室提出的激光-惯性-视觉紧耦合里程计算法。它的核心目标是用激光雷达、IMU和相机三种传感器的数据,实时估计机器人在三维空间中的位姿,并拼接出全局一致的点云地图。

    FAST-LIVO发表于IEEE T-RO 2024(论文: FAST-LIVO2: Fast, Direct LiDAR-Inertial-Visual Odometry),是FAST-LIO的视觉增强版。相比FAST-LIO只使用LiDAR + IMU,FAST-LIVO增加了相机通道,能够在激光退化场景(长走廊、隧道、空旷大厅等)下依靠视觉特征继续稳定建图。

    FAST-LIVO的核心改进包括:

    • 视觉通道:增加相机传感器,提供视觉重投影约束,在激光退化时补充纹理信息
    • VoxelOctoTree:地图存储升级为八叉树体素地图,内存更紧凑,查询更高效
    • 退化场景检测:自动检测几何特征单一的环境,动态调整激光和视觉的权重
    • 曝光补偿:在线估计相机曝光时间,提升视觉特征提取的鲁棒性
    • 多雷达支持:Livox AVIA / Mid-360 / Mid-70 / Velodyne / Hesai / RoboSense等

    二、算法原理详解

    2.1整体架构概览

    FAST-LIVO的数据流可以概括为:

    LiDAR点云 ──┐
                  ├── 点云预处理 ── 特征提取 ──┐
    IMU数据 ─────┤                           ├── ESKF更新 ── 位姿估计 ── 地图更新
                  ├── IMU预积分 ────────────┘
    相机图像 ─────┴── 视觉特征提取 ─────────┘

    整个系统运行在一个ROS节点中(fastlio_mapping),订阅LiDAR、IMU和相机话题,发布里程计和地图。系统支持三种工作模式:

    • LIVO模式:LiDAR + IMU + Camera三传感器融合(默认模式)
    • LIO模式:仅LiDAR + IMU(相机数据不可用时自动切换)
    • LO模式:仅LiDAR(IMU和相机都不可用时)

    2.2三传感器紧耦合框架

    FAST-LIVO的核心创新是将LiDAR、IMU和Camera三个传感器的数据在误差状态卡尔曼滤波(ESKF)层面进行紧耦合融合。每个传感器提供不同类型的观测约束:

    • IMU:提供高频的姿态和速度预测(200-1000Hz),短时间内的相对运动约束
    • LiDAR:提供精确的几何相对位姿约束,对平移和旋转都有良好观测
    • Camera:提供视觉重投影约束,在几何退化场景下补充纹理信息

    在ESKF的更新步骤中,FAST-LIVO根据当前环境动态调整各传感器的权重。当激光点云正常时,主要依赖LiDAR观测;当检测到激光退化时,自动增加视觉观测的权重。这种自适应机制使得系统在多种环境下都能保持鲁棒性。

    FAST-LIVO的状态向量在FAST-LIO的基础上增加了视觉相关状态:

    x = [p, v, R, b_a, b_g, g, T_cam_imu, inv_expo]
        p: 位置 (3D)
        v: 速度 (3D)
        R: 旋转 (四元数)
        b_a: 加速度计偏置 (3D)
        b_g: 陀螺仪偏置 (3D)
        g: 重力向量 (3D)
        T_cam_imu: 相机到IMU的外参 (6D)
        inv_expo: 逆曝光时间 (1D)

    2.3视觉特征融合

    FAST-LIVO的视觉通道采用基于稀疏直接法的轻量级方案。与传统的特征点匹配不同,FAST-LIVO使用光度误差(视觉重投影误差)作为观测残差,直接融合到ESKF中。

    视觉处理的具体流程:

    • 特征提取:将图像划分为网格(grid),在每个网格中提取最亮的角点作为特征点
    • 特征跟踪:通过逆合成光流法(Inverse Compositional Optical Flow)在连续帧之间跟踪特征点
    • 重投影:将地图中的3D特征点投影到当前图像平面,计算光度误差(像素灰度差)
    • ESKF更新:将光度误差作为观测残差,更新状态估计

    FAST-LIVO的视觉特征提取策略非常高效。它不需要对每帧图像进行完整的特征提取,而是只在需要时从体素地图中检索视觉特征点。这大大降低了计算开销,使得视觉通道可以在普通CPU上实时运行。

    视觉通道的关键参数:

    visual:
        enable: true                    # 是否启用视觉通道

    image_topic: "/camera/image_raw" # 相机话题名

    max_iterations: 5               # 视觉ESKF最大迭代次数
    img_point_cov: 100              # 视觉点协方差

    normal_en: true                 # 是否启用法向量约束

    raycast_en: false               # 是否启用光线投射

    exposure_estimate_en: true      # 是否在线估计曝光时间

    grid_size: 5                    # 特征提取网格大小

    grid_n_height: 17               # 特征提取网格高度

    patch_pyrimid_level: 3          # 图像金字塔层数

    patch_size: 8                   # 图像补丁大小

    outlier_threshold: 1000         # 外点阈值

    2.4退化场景检测

    激光SLAM在退化场景中容易失败。典型的退化场景包括:

    • 长直走廊:点云在走廊方向缺乏几何多样性,无法约束横向平移
    • 隧道:与走廊类似,且通常没有视觉纹理
    • 空旷大平层:大面积平面,缺乏角点和平面特征的组合
    • 重复结构:多个相似的环境,容易产生误匹配

    FAST-LIVO通过分析点云的特征值来判断是否退化。具体方法:

    • 对当前帧点云的协方差矩阵进行特征值分解
    • 如果最小特征值小于阈值,说明当前环境在某个方向缺乏几何约束
    • 系统会自动降低该方向的观测权重,同时增加视觉通道的权重

    退化检测的阈值可以通过参数调整。阈值设置过小会导致退化检测不敏感,阈值设置过大则可能误判正常场景为退化。

    2.5 VoxelOctoTree八叉树体素地图

    FAST-LIVO使用VoxelOctoTree(八叉树体素地图)作为全局地图的数据结构。八叉树是一种空间划分数据结构,它将三维空间递归地划分为8个子立方体,每个节点有8个子节点。

    八叉树的工作原理:

    • 根节点代表整个三维空间
    • 每个节点如果包含太多点,就分裂为8个子节点
    • 每个子节点代表父节点空间的1/8
    • 当节点内的点数低于阈值时,可以合并子节点

    相比FAST-LIO的ikd-Tree,VoxelOctoTree的优势:

    • 内存更紧凑:不需要存储分割平面信息,只需要存储体素中心点和子节点指针
    • 查询更快:层次化的结构使得近邻搜索更高效,时间复杂度接近O(log n)
    • 动态更新更灵活:可以方便地增删体素节点,不需要重新平衡树结构
    • 空间划分更均匀:八叉树的规则划分避免了k-d树在数据分布不均时的性能退化

    FAST-LIVO的VoxelOctoTree还维护了每个体素的平面信息(法向量和协方差),用于激光点云的特征匹配和视觉特征的重投影。地图的降采样分辨率由voxel_size参数控制(通常0.5m)。

    2.6曝光补偿

    FAST-LIVO的一个独特设计是在线曝光补偿。相机在自动曝光模式下,曝光时间会随环境光变化而变化,这会影响视觉特征提取的稳定性。FAST-LIVO将逆曝光时间(inverse exposure time)加入状态向量,在ESKF中在线估计:

    • 当环境光变暗时,自动增加曝光时间,保持图像亮度稳定
    • 当环境光变亮时,自动减少曝光时间,避免过曝
    • 曝光估计的协方差由inv_expo_cov参数控制

    曝光补偿使得FAST-LIVO在光照变化较大的环境中(如室内外切换、隧道出入口)也能保持稳定的视觉特征跟踪。

    三、ROS使用指南

    3.1话题与数据流

    FAST-LIVO相比FAST-LIO增加了相机话题:

    话题名消息类型方向说明
    /livox/lidarlivox_ros_driver2/CustomMsg订阅Livox雷达原始数据
    /livox/imusensor_msgs/Imu订阅Livox IMU数据
    /velodyne_pointssensor_msgs/PointCloud2订阅Velodyne雷达数据
    /imu/datasensor_msgs/Imu订阅外部IMU数据
    /camera/image_rawsensor_msgs/Image订阅相机图像数据
    /camera/camera_infosensor_msgs/CameraInfo订阅相机内参数据
    /Odometrynav_msgs/Odometry发布里程计(高频位姿)
    /cloud_registeredsensor_msgs/PointCloud2发布注册到世界坐标系的点云
    /cloud_effectedsensor_msgs/PointCloud2发布有效约束点云
    /pathnav_msgs/Path发布运动轨迹
    /Laser_mapsensor_msgs/PointCloud2发布局部地图点云
    /rgb_imgsensor_msgs/Image发布RGB图像(用于可视化)

    3.2 Launch文件与参数配置

    FAST-LIVO使用ROS的launch文件启动。不同雷达型号对应不同的launch文件:

    Launch文件适用雷达说明
    mapping_avia.launchLivox AVIALivox非重复扫描雷达
    mapping_velodyne.launchVelodyne 16/32/64Velodyne机械旋转雷达
    mapping_mid360.launchLivox Mid-360Livox中距雷达
    mapping_mid70.launchLivox Mid-70Livox短距雷达

    3.3参数配置详解

    FAST-LIVO的参数文件相比FAST-LIO增加了视觉相关配置。以下是完整的参数说明:

    common:
    lid_topic:  "/livox/lidar"      # LiDAR话题名
    imu_topic:  "/livox/imu"        # IMU话题名
    img_topic:  "/camera/image_raw"  # 相机话题名
    img_en: 1                       # 是否启用相机: 1=启用, 0=禁用
    lidar_en: 1                     # 是否启用LiDAR: 1=启用, 0=禁用
    preprocess:
        lidar_type: 1                   # 雷达类型: 1=AVIA, 2=Velodyne
        scan_line: 6                    # 扫描线数: AVIA=6, Velodyne=16/32/64
        blind: 0.5                      # 盲区距离 (m)
    
    vio:
        normal_en: true                 # 是否启用法向量约束
    
    inverse_composition_en: false   # 是否使用逆合成光流法
    
    max_iterations: 5               # 视觉ESKF最大迭代次数
    
    img_point_cov: 100              # 视觉点协方差
    
    raycast_en: false               # 是否启用光线投射
    
    exposure_estimate_en: true      # 是否在线估计曝光时间
    
    inv_expo_cov: 0.2               # 逆曝光时间协方差
    
    grid_size: 5                    # 特征提取网格大小
    
    grid_n_height: 17               # 特征提取网格高度
    
    patch_pyrimid_level: 3          # 图像金字塔层数
    
    patch_size: 8                   # 图像补丁大小
    
    outlier_threshold: 1000         # 外点阈值
    
    time_offset:
    exposure_time_init: 0.0         # 初始曝光时间
    
    img_time_offset: 0.0            # 图像时间偏移
    
    imu_time_offset: 0.0            # IMU时间偏移
    
    lidar_time_offset: 0.0          # LiDAR时间偏移extrin_calib:
        extrinsic_T: [0, 0, 0]          # LiDAR到IMU的平移外参
    
    extrinsic_R: [1,0,0, 0,1,0, 0,0,1]  # LiDAR到IMU的旋转外参
    Pcl: [0, 0, 0]                  # 相机到LiDAR的平移外参
    
    Rcl: [1,0,0, 0,1,0, 0,0,1]      # 相机到LiDAR的旋转外参
    mapping:
        filter_size_surf: 0.5           # 平面特征降采样分辨率 (m)
        max_iteration: 3                # ESKF最大迭代次数
    
    acc_cov: 0.1                    # 加速度噪声协方差
    
    gyr_cov: 0.1                    # 陀螺仪噪声协方差b_acc_cov: 0.0001               # 加速度计偏置噪声
    b_gyr_cov: 0.0001               # 陀螺仪偏置噪声fov_degree: 360                 # 视场角 (度)
        det_range: 100.0                # 最大探测距离 (m)
    pcd_save:
        pcd_save_en: true               # 是否保存PCD
        interval: -1                    # 保存间隔: -1=结束时保存, >0=每N帧保存

    视觉相关参数调优建议:

    • grid_size:特征提取网格大小。越小提取的特征点越多,但计算量越大。建议3-8
    • patch_size:图像补丁大小。越大特征越稳定,但计算量越大。建议6-10
    • patch_pyrimid_level:图像金字塔层数。越多对大运动越鲁棒,但计算量越大。建议2-4
    • exposure_estimate_en:是否在线估计曝光时间。光照变化大的场景建议开启
    • img_point_cov:视觉点协方差。越小视觉约束越强。建议50-200

    3.4相机内参与外参标定

    FAST-LIVO需要准确的相机内参和相机-外参。推荐使用Kalibr工具包进行标定:

    # 安装Kalibr
    sudo apt-get install ros-noetic-kalibr

    # 录制标定数据(多棋盘格标定板)rosbag record /camera/image_raw /camera/camera_info /livox/imu

    # 标定相机内参kalibr_calibrate_cameras --cameras cam0 --target aprilgrid.yaml \
      --bag calibration.bag

    # 标定相机-IMU外参kalibr_calibrate_imu_camera --cam cam0 --imu imu0 \
      --target aprilgrid.yaml --bag calibration.bag --bag-from-to 5 50

    标定完成后,将得到的内参填入相机驱动节点,外参填入YAML配置文件的extrin_calib部分。FAST-LIVO也推荐使用FAST-Calib工具包进行LiDAR-Camera联合标定,其输出参数可以直接填入YAML文件。

    如果标定精度不够,FAST-LIVO也支持在线估计相机-IMU外参,但初始值不能偏差太大,否则会收敛到错误值。

    3.5多雷达型号适配

    FAST-LIVO支持的雷达型号与FAST-LIO相同:

    雷达型号lidar_typescan_line话题名消息类型
    Livox AVIA16/livox/lidarCustomMsg
    Velodyne 16/32/64216/32/64/velodyne_pointsPointCloud2
    Livox Mid-36024/livox/lidarCustomMsg
    Livox Mid-7034/livox/lidarCustomMsg
    Livox Horizon46/livox/lidarCustomMsg
    Livox Tele51/livox/lidarCustomMsg

    3.6使用rosbag离线建图

    FAST-LIVO的离线建图流程与FAST-LIO类似,但需要额外提供相机数据:

    # 终端1: 启动FAST-LIVO
    roslaunch fast_livo mapping_avia.launch rviz:=false

    # 终端2: 播放rosbag(包含LiDAR + IMU + Camera数据)rosbag play -r 10 your_bag_file.bag

    # 播完后按Ctrl+C保存PCD

    注意:如果bag包中不包含相机数据,FAST-LIVO会自动退化为LIO模式,只使用LiDAR和IMU进行建图。如果连IMU数据也没有,则会退化为LO模式。

    四、FAST-LIO vs FAST-LIVO对比

    特性FAST-LIOFAST-LIVO
    传感器LiDAR + IMULiDAR + IMU + Camera
    地图结构ikd-TreeVoxelOctoTree(八叉树)
    退化检测有,自动切换到视觉通道
    曝光补偿有,在线估计逆曝光时间
    运行频率100Hz100Hz(激光为主)
    内存占用较高较低(八叉树更紧凑)
    适用场景一般环境含退化场景的复杂环境
    ROS话题LiDAR + IMULiDAR + IMU + Camera
    参数复杂度较低较高(增加视觉参数)
    工作模式LIOLIVO / LIO / LO(自动切换)

    五、常见问题与排查

    5.1视觉通道不工作

    检查以下几点:

    • 确认bag包中包含相机数据(rostopic list检查)
    • 确认相机话题名与配置一致
    • 确认img_en设为1
    • 确认相机内参正确填写

    5.2退化检测过于敏感

    如果系统频繁切换到视觉通道,可能是退化检测阈值过高。可以调整:

    • 增大退化检测的特征值阈值
    • 降低视觉通道的权重

    5.3视觉特征跟踪丢失

    视觉特征跟踪丢失的常见原因:

    • 相机曝光时间过长,导致运动模糊
    • 环境缺乏纹理(白墙、纯色地面)
    • 相机帧率过低(建议至少20Hz)
    • 光照变化过大(开启exposure_estimate_en可缓解)

    5.4没有生成PCD

    PCD文件保存在退出钩子(Exit Hook)中,只有在按Ctrl+C正常退出时才会触发。如果直接关闭终端或kill进程,PCD不会保存。

    5.5建图漂移严重

    建图漂移通常由以下原因导致:

    • IMU和LiDAR的外参不准确,需要重新标定外参
    • 相机和LiDAR的外参不准确,需要重新标定
    • IMU噪声参数设置不合理,调整acc_cov和gyr_cov
    • 环境缺乏几何特征(长走廊、白墙),开启视觉通道可缓解

    FAST-LIVO论文: Fast and Tightly-coupled Sparse-Direct LiDAR-Inertial-Visual Odometry

  • FAST-LIO(docker)

    一、FAST-LIO是什么?

    FAST-LIO(Fast LiDAR-Inertial Odometry)是香港大学MARS实验室提出的一套激光-惯性紧耦合里程计算法。它的核心目标只有一个:用激光雷达和IMU的高频数据,实时估计机器人在三维空间中的位姿,并拼接出全局一致的点云地图。

    相比传统的滤波方案(如LOAM系列),FAST-LIO的计算效率极高。在普通CPU上就能跑到100Hz以上,同时精度不输甚至超越LOAM。这得益于它采用了基于误差状态卡尔曼滤波(Error-State Kalman Filter, ESKF)的紧耦合框架,以及增量式地图更新机制(ikd-Tree)。

    二、算法原理详解

    2.1整体架构概览

    FAST-LIO的数据流可以概括为:

    LiDAR点云 ──┐

    ├── 点云预处理 ── 特征提取 ── ESKF更新 ── 位姿估计 ── 地图更新IMU数据 ─────┘

    整个系统运行在一个ROS节点中(fastlio_mapping),订阅LiDAR和IMU话题,发布里程计和地图。核心流程如下:

    • IMU预积分:在两帧激光之间,用IMU的高频数据(200-1000Hz)推算相对运动
    • 点云预处理:运动补偿(去畸变)、特征提取(平面特征 / 角点特征)
    • ESKF更新:将激光里程计观测和IMU预测融合,估计误差状态
    • 地图更新:将当前帧点云投影到全局地图中,增量式维护ikd-Tree

    2.2 IMU预积分与运动补偿

    激光雷达的扫描频率通常只有10-20Hz(Livox AVIA是10Hz,Velodyne 32线也是10Hz),而IMU的频率可以达到200-1000Hz。在两次激光扫描之间,IMU能提供大量的高频姿态和速度信息。

    FAST-LIO使用IMU预积分(Preintegration)技术:给定上一帧激光时刻t_k的IMU状态,对t_k到t_{k+1} 之间的所有IMU测量值进行积分,得到两帧之间的相对旋转、速度和位移。这个预积分结果作为ESKF的预测步骤(Prediction)。

    同时,由于激光雷达扫描一帧需要一定时间(例如100ms),而机器人在这期间可能已经运动了相当距离,所以点云会产生运动畸变。FAST-LIO利用IMU数据对每一帧点云做运动补偿(Deskew),把每个点都投影到统一的坐标系下。这是高精度建图的关键步骤。

    2.3误差状态卡尔曼滤波(ESKF)

    FAST-LIO的核心状态估计器是误差状态卡尔曼滤波。这里需要区分两个概念:

    • 名义状态x:由IMU数据直接积分得到,不考虑噪声,包含了大的非线性运动
    • 误差状态delta_x:代表名义状态的微小偏差,是线性的,用卡尔曼滤波估计

    名义状态和误差状态组合成完整的状态向量:

    x = [p, v, R, b_a, b_g, g]
        p: 位置 (3D)
        v: 速度 (3D)
        R: 旋转 (四元数或旋转矩阵)
        b_a: 加速度计偏置 (3D)
        b_g: 陀螺仪偏置 (3D)
        g: 重力向量 (3D)

    ESKF的工作流程:

    • 预测(Predict):用IMU预积分更新名义状态,同时更新误差状态的协方差
    • 更新(Update):用激光里程计观测(当前帧与地图的匹配结果)计算残差,更新误差状态
    • 注入(Inject):将估计的误差状态注入名义状态,然后重置误差状态

    这种设计的优势在于:名义状态可以处理大的非线性运动,而误差状态始终保持线性,可以用标准的卡尔曼滤波高效求解。这也是FAST-LIO能跑到100Hz的重要原因。

    2.4点云特征提取

    FAST-LIO不需要使用全部点云进行匹配,而是提取特征点来降低计算量。特征分为两类:

    • 平面特征(Planar):位于平面上的点,法向量与激光方向接近垂直,对平移敏感
    • 角点特征(Edge):位于边缘或角上的点,对旋转敏感

    特征提取的策略是:将一帧点云按曲率排序,曲率大的作为角点特征,曲率小的作为平面特征。FAST-LIO使用局部邻域(通常取最近的25个点)计算曲率,然后根据阈值分类。

    在ESKF更新步骤中,每个平面特征点与地图中最近的平面片计算点到平面距离,每个角点特征点与地图中最近的边缘线计算点到线距离。这些距离构成观测残差,用于更新状态估计。

    2.5 ikd-Tree增量式地图

    FAST-LIO使用ikd-Tree(incremental k-d Tree)作为全局地图的数据结构。ikd-Tree是一种支持增量插入和删除的k-d树,专门为三维点云设计。

    ikd-Tree的核心优势:

    • 增量更新:新帧点云直接插入地图,不需要重建整棵树
    • 降采样:每个体素格子只保留一个点(或几个点),控制地图大小
    • 近邻搜索:支持快速的最近邻查询,用于特征匹配
    • 删除操作:可以删除旧的或不需要的点,保持地图整洁

    地图的降采样分辨率由filter_size_map参数控制(通常0.5m)。这意味着地图中每个0.5m x 0.5m x 0.5m的体素只保留一个点。这个分辨率直接影响建图精度和内存占用。

    三、ROS使用指南

    3.1话题与数据流

    FAST-LIO作为ROS节点运行,通过话题(Topic)与其他节点通信。理解这些话题是正确使用FAST-LIO的前提。

    话题名消息类型方向说明
    /livox/lidarlivox_ros_driver2/CustomMsg订阅Livox雷达原始数据
    /livox/imusensor_msgs/Imu订阅Livox IMU数据
    /velodyne_pointssensor_msgs/PointCloud2订阅Velodyne雷达数据
    /imu/datasensor_msgs/Imu订阅外部IMU数据
    /Odometrynav_msgs/Odometry发布里程计(高频位姿)
    /cloud_registeredsensor_msgs/PointCloud2发布注册到世界坐标系的点云
    /cloud_registered_bodysensor_msgs/PointCloud2发布注册到IMU坐标系的点云
    /pathnav_msgs/Path发布运动轨迹
    /LaserCloudMapsensor_msgs/PointCloud2发布局部地图点云

    注意:Livox雷达使用自定义消息类型CustomMsg,不是标准的PointCloud2。这是因为Livox雷达的点云数据包含每个点的反射强度、时间戳等额外信息,标准消息无法容纳。FAST-LIO内部会将CustomMsg转换为PointCloud2再处理。

    3.2 Launch文件与参数配置

    FAST-LIO使用ROS的launch文件启动。不同雷达型号对应不同的launch文件:

    Launch文件适用雷达说明
    mapping_avia.launchLivox AVIALivox非重复扫描雷达
    mapping_velodyne.launchVelodyne 16/32/64Velodyne机械旋转雷达
    mapping_mid360.launchLivox Mid-360Livox中距雷达
    mapping_mid70.launchLivox Mid-70Livox短距雷达
    mapping_horizon.launchLivox HorizonLivox单线雷达

    Launch文件的核心作用是加载参数配置文件(YAML)并启动fastlio_mapping节点。参数文件是配置FAST-LIO行为的关键,下面详细介绍。

    3.3参数配置详解(avia.yaml为例)

    参数文件位于config/ 目录下,以YAML格式组织。以下是各参数的含义:

    common:
        lid_topic:  "/livox/lidar"      # LiDAR话题名imu_topic:  "/livox/imu"        # IMU话题名time_sync_en: false             # 是否启用时间同步time_offset_lidar_to_imu: 0.0   # LiDAR到IMU的时间偏移preprocess:
        lidar_type: 1                   # 雷达类型: 1=AVIA, 2=Velodyne
        scan_line: 6                    # 扫描线数: AVIA=6, Velodyne=16/32/64
        scan_rate: 10                   # 扫描频率 (Hz)
        timestamp_unit: 1               # 时间戳单位: 0=秒, 1=毫秒, 2=微秒
    blind: 0.5                      # 盲区距离 (m),过滤近距离噪声mapping:
        filter_size_surf: 0.5           # 平面特征降采样分辨率 (m)
        filter_size_map: 0.5            # 全局地图降采样分辨率 (m)
        max_iteration: 3                # ESKF最大迭代次数
    acc_cov: 0.1                    # 加速度噪声协方差
    gyr_cov: 0.1                    # 陀螺仪噪声协方差
    b_acc_cov: 0.0001               # 加速度计偏置噪声
    b_gyr_cov: 0.0001               # 陀螺仪偏置噪声
    fov_degree: 360                 # 视场角 (度)
        det_range: 100.0                # 最大探测距离 (m)
        extrinsic_est_en: false         # 是否在线估计外参
    extrinsic_T: [0, 0, 0]          # LiDAR到IMU的平移外参
    extrinsic_R: [1,0,0, 0,1,0, 0,0,1]  # LiDAR到IMU的旋转外参
    publish:
        path_en: true                   # 是否发布轨迹
    scan_publish_en: true            # 是否发布注册点云
    dense_publish_en: true          # 是否发布稠密点云scan_bodyframe_pub_en: true     # 是否发布IMU坐标系点云pcd_save:
    pcd_save_en: true               # 是否保存PCD
        interval: -1                    # 保存间隔: -1=结束时保存, >0=每N帧保存

    关键参数调优建议:

    • filter_size_surf / filter_size_map:降低分辨率可以加快建图速度但会损失细节。室内场景0.3-0.5m,室外场景0.5-1.0m
    • max_iteration:ESKF迭代次数。精度要求高可以设为5,实时性要求高设为3
    • blind:盲区距离。需要过滤雷达自身和机器人车体的点,Livox AVIA设0.5m,Velodyne设2.0m
    • extrinsic_T / extrinsic_R:LiDAR和IMU之间的外参。如果安装位置精确已知,直接填入;如果不确定,可以设extrinsic_est_en: true让FAST-LIO在线估计

    3.4多雷达型号适配

    FAST-LIO支持多种雷达型号,切换雷达只需要修改YAML配置文件中的几个参数:

    雷达型号lidar_typescan_line话题名消息类型
    Livox AVIA16/livox/lidarCustomMsg
    Velodyne 16/32/64216/32/64/velodyne_pointsPointCloud2
    Livox Mid-36024/livox/lidarCustomMsg
    Livox Mid-7034/livox/lidarCustomMsg
    Livox Horizon46/livox/lidarCustomMsg
    Livox Tele51/livox/lidarCustomMsg

    注意:修改参数后不需要重新编译,FAST-LIO会在每次启动时读取最新的YAML文件。

    3.5外参标定(Extrinsic Calibration)

    LiDAR和IMU之间的外参(Translation + Rotation)对建图精度至关重要。如果外参不准确,建图会出现明显的重影或漂移。

    FAST-LIO提供两种方式处理外参:

    • 手动填入:如果已知LiDAR和IMU的相对安装位置,直接在YAML中填写extrinsic_T和extrinsic_R
    • 在线估计:设extrinsic_est_en: true,FAST-LIO会在运行过程中自动优化外参。但初始值不能偏差太大,否则会收敛到错误值

    外参的格式:

    extrinsic_T: [t_x, t_y, t_z]           # 平移 (米)
    extrinsic_R: [r11, r12, r13,        # 旋转矩阵 (3×3, 行优先)
                  r21, r22, r23,
                  r31, r32, r33]

    3.6使用rosbag离线建图

    在实际使用中,我们通常先录制rosbag包,再用FAST-LIO离线处理生成点云地图。这样做的好处是:

    • 可以反复调整参数,找到最佳配置
    • 避免实时建图时机器人运动不稳定导致的数据质量问题
    • 可以加速播放(Livox AVIA可以10倍速甚至20倍速)

    离线建图需要两个终端(或两个SSH会话):

    # 终端1: 启动FAST-LIO
    roslaunch fast_lio mapping_avia.launch rviz:=false

    # 终端2: 播放rosbag
    rosbag play -r 10 your_bag_file.bag

    当终端2显示Done. 后,切回终端1按Ctrl+C。FAST-LIO会在退出时自动保存PCD文件。

    注意:Livox AVIA数据量小,可以加速播放(-r 10甚至 -r 20),150秒的数据15秒就能播完。但Velodyne 32线数据量大,必须原速播放(-r 1),否则消息队列会爆满导致数据丢失。

    四、常见问题与排查

    4.1没有生成PCD

    PCD文件保存在退出钩子(Exit Hook)中,只有在按Ctrl+C正常退出时才会触发。如果直接关闭终端或kill进程,PCD不会保存。

    4.2报Error during open!

    这通常是PCD保存路径不存在或权限问题。检查:

    ls -ld /root/fastlio_ws/src/FAST_LIO/PCD
    # 应该显示: PCD -> /workspace (软链接)
    # 如果不是,重建软链接:
    rm -rf /root/fastlio_ws/src/FAST_LIO/PCD
    ln -s /workspace /root/fastlio_ws/src/FAST_LIO/PCD

    4.3卡在No point, skip this scan!

    这说明激光点云数据缺少必要字段。可能的原因:

    • PointCloud2缺少ring(线束ID)或time(单点时间戳)字段
    • bag包中的点云数据格式与配置不匹配
    • 雷达话题名配置错误,FAST-LIO没有接收到数据

    4.4建图漂移严重

    建图漂移通常由以下原因导致:

    • IMU和LiDAR的外参不准确,需要重新标定外参
    • IMU噪声参数设置不合理,调整acc_cov和gyr_cov
    • 环境缺乏几何特征(长走廊、白墙),增加max_iteration或降低filter_size_map
    • 运动补偿不准确,检查IMU频率是否足够高

    4.5内存占用过高

    如果建图范围很大,ikd-Tree会占用大量内存。可以:

    • 增大filter_size_map(如从0.5m改为1.0m)来减少地图点数
    • 设置pcd_save.interval为正值(如200),定期保存并清空地图
    • 限制建图范围(减小det_range参数)

    五、总结

    FAST-LIO是一套高效、实用的激光-惯性紧耦合SLAM方案。它的核心优势在于:

    • 速度快:ESKF + ikd-Tree的设计使其在普通CPU上就能跑到100Hz
    • 精度高:紧耦合框架充分利用了IMU和LiDAR的互补优势
    • 易用性:ROS接口清晰,参数配置简单,支持多种雷达型号

    在实际使用中,关键是理解参数的含义并根据场景调优。希望本文能帮助你更好地使用FAST-LIO进行机器人建图。

  • ROS(docker)

    一、什么是ROS

    ROS(Robot Operating System)是一个开源的机器人软件框架,它不是传统意义上的操作系统,而是一套运行在Linux之上的中间件和工具集。ROS为机器人软件开发提供了统一的通信机制、包管理、构建系统和调试工具。
    目前ROS有两个主要版本:ROS1(如 Noetic、Melodic、Kinetic)和ROS2(如 Humble、Iron、Jazzy)。本文以 ROS Noetic(ROS1的最后一个LTS版本)为基础进行讲解,但核心概念在 ROS 2 中同样适用。

    1.1 ROS 核心概念

    ROS的设计哲学是基于分布式架构,系统由多个独立进程(节点)组成,节点之间通过消息传递进行通信。理解 ROS 的关键是理解以下几个核心概念:

    概念说明类比
    节点(Node)一个独立运行的进程,负责特定功能一个工人
    话题(Topic)节点间异步通信的通道,发布/订阅模式广播频道
    消息(Message)在话题上传输的数据结构广播的内容
    发布(Publish)节点向话题发送消息播音
    订阅(Subscribe)节点从话题接收消息收听
    服务(Service)节点间同步通信,请求/响应模式电话呼叫
    Master节点注册和查找的中间人电话交换机
    Launch文件XML 格式的配置文件,批量启动多个节点启动脚本
    PackageROS 软件的基本组织单位一个项目/模块
    TF坐标变换系统,管理各坐标系之间的关系地图上的坐标网格

    1.2 ROS 文件系统

    ROS项目通常遵循以下目录结构:

    ├── src/                     # 源代码目录
    │   └── my_package/          # 一个ROS包
    │       ├── CMakeLists.txt   # 构建配置
    │       ├── package.xml      # 包描述
    │       ├── src/             # C++ 源文件
    │       ├── include/         # 头文件
    │       ├── scripts/         # Python 脚本
    │       ├── launch/          # Launch 文件
    │       └── config/          # 配置文件
    ├── build/                   # 编译中间产物
    ├── devel/                   # 编译产物(可执行文件、库)
    └── install/                 # 安装目录

    编译安装的通常在/opt/里面,也就是install目录文件夹。内部通常是:

    1.3 ROS 通信机制详解

    ROS 支持两种通信方式:话题(Topic)和服务(Service)。

    话题是异步的、多对多的通信机制。一个节点可以发布消息到某个话题,其他节点可以订阅这个话题接收消息。话题使用发布/订阅模型,发布者(Publisher)和订阅者(Subscriber)之间是解耦的。发布者不需要知道谁在接收,订阅者也不需要知道谁在发送。

    # 话题通信示例(Python)
    # 发布者
    import rospy
    from std_msgs.msg import String
    pub = rospy.Publisher('my_topic', String, queue_size=10)
    pub.publish("Hello ROS!")

    # 订阅者
    def callback(msg):
        rospy.loginfo("Received: %s", msg.data)
    rospy.Subscriber('my_topic', String, callback)

    服务是同步的、一对一的通信机制。客户端(Client)发送请求,服务端(Server)处理后返回响应。适用于需要等待结果的场景,比如查询机器人状态、执行一次性的动作。

    # 服务通信示例(Python)
    # 服务端
    from my_package.srv import AddTwoInts, AddTwoIntsResponse
    def handle_add(req):
        return AddTwoIntsResponse(req.a + req.b)
    rospy.Service('add_two_ints', AddTwoInts, handle_add)
    
    
    # 客户端
    rospy.wait_for_service('add_two_ints')
    add = rospy.ServiceProxy('add_two_ints', AddTwoInts)
    result = add(3, 5)

    1.4 Launch 文件

    Launch 文件是 ROS 中批量启动节点和配置参数的核心工具。使用XML格式编写,可以一次性启动多个节点、设置参数、包含其他Launch文件。

    <!-- 示例:启动两个节点 -->
    <launch>
        <!-- 设置参数 -->
        <arg name="rviz" default="true"/>

        <!-- 启动 FAST-LIO -->
        <node pkg="fast_lio" type="fastlio_mapping"
              name="laserMapping" output="screen">
            <param name="common/lid_topic" value="/livox/lidar"/>
            <param name="common/imu_topic" value="/livox/imu"/>
            <param name="pcd_save/pcd_save_en" value="true"/>
        </node>

        <!-- 启动 RViz 可视化 -->
        <node if="$(arg rviz)" pkg="rviz" type="rviz"
              name="rviz" args="-d $(find fast_lio)/rviz_cfg/loam.rviz"/>
    </launch>

    Launch 文件的关键语法:

    • <launch>:根元素,所有其他元素都包含在其中
    • <node>:启动一个节点,指定包名(pkg)、可执行文件(type)、节点名(name)
    • <param>:设置参数,会加载到ROS参数服务器
    • <arg>:定义变量,可以在运行时通过命令行传入
    • <include>:包含另一个Launch文件
    • <group>:对一组节点进行操作,可以设置命名空间

    1.5 TF 坐标变换系统

    TF(Transform)是ROS中管理坐标变换的核心系统。在机器人系统中,不同传感器、不同部件都有自己的坐标系,TF负责维护这些坐标系之间的变换关系。

    例如,一个典型的移动机器人可能包含以下坐标系:

    • map:全局地图坐标系,固定不动
    • odom:里程计坐标系,以机器人启动位置为原点
    • base_link:机器人本体坐标系,固定在机器人上
    • lidar:激光雷达坐标系,相对于 base_link 有固定偏移
    • imu:IMU 坐标系,相对于 base_link 有固定偏移

    TF树是一个有向树结构,每个坐标系只有一个父节点。通过TF可以方便地将一个坐标系下的点转换到另一个坐标系下。

    二、ROS的Docker基础

    Docker是一个开源的容器化平台,它可以将应用程序及其依赖打包到一个轻量级、可移植的容器中。相比虚拟机,Docker容器共享宿主机的内核,启动更快、资源占用更少。

    2.1 Docker核心概念

    概念说明类比
    镜像(Image)只读的模板,包含运行应用所需的一切光盘/ISO文件
    容器(Container)镜像的运行实例运行中的程序
    Dockerfile定义如何构建镜像的脚本菜谱
    仓库(Registry)存储和分发镜像的服务应用商店
    挂载(Volume)将宿主机目录映射到容器内部共享文件夹
    端口映射将容器端口映射到宿主机端口端口转发

    2.2 Docker基础命令

    以下是Docker最常用的命令:

    docker pull ubuntu:20.04 # 拉取镜像
    
    docker images  # 列出本地镜像
    
    docker rmi <image_id> # 删除镜像
    
    docker save -o my_image.tar <image>   # 导出镜像为tar文件
    
    docker load -i my_image.tar    # 从tar文件导入镜像
    
    docker run -it ubuntu:20.04 /bin/bash   # 启动交互式容器
    
    docker ps  # 列出运行中的容器
    
    docker ps -a  # 列出所有容器(含已停止)
    
    docker stop <container> # 停止容器
    
    docker rm <container>  # 删除容器
    
    docker exec -it <container> bash  # 进入运行中的容器
    
    docker run -v /host/path:/container/path <image>  # 目录挂载
    
    docker run -v my_volume:/container/path <image>   # 命名卷挂载
    
    docker run -p 8080:80 <image>    # 将容器80端口映射到宿主机8080

    2.3 Dockerfile编写

    Dockerfile是构建镜像的脚本文件,定义了镜像的每一层。以下是一个ROS Noetic的示例Dockerfile:

    # 基于ROS Noetic官方镜像FROM osrf/ros:noetic-desktop-full

    # 设置环境变量ENV DEBIAN_FRONTEND=noninteractive

    # 安装常用工具RUN apt-get update && apt-get install -y \
        git wget vim nano \
        ros-noetic-pcl-ros \
        ros-noetic-rviz \
        && rm -rf /var/lib/apt/lists/*

    # 设置工作目录WORKDIR /workspace

    # 设置自动加载ROS环境RUN echo "source /opt/ros/noetic/setup.bash" >> ~/.bashrc

    CMD ["/bin/bash"]

    Dockerfile的关键指令:

    • FROM:指定基础镜像
    • RUN:在构建时执行命令
    • COPY/ADD:将文件复制到镜像中
    • ENV:设置环境变量
    • WORKDIR:设置工作目录
    • EXPOSE:声明容器监听的端口
    • CMD/ENTRYPOINT:容器启动时执行的命令

    2.4 Docker Compose

    Docker Compose是一个用于定义和运行多容器应用的工具。通过一个YAML文件配置多个容器,一键启动整个应用栈。

    # docker-compose.yml示例version: '3'
    services:
      fastlio:
        image: fastlio_noetic:v3
        container_name: fastlio_container
        volumes:
          - ./workspace:/workspace
        network_mode: host
        stdin_open: true
        tty: true

      rviz:
        image: osrf/ros:noetic-desktop-full
        container_name: rviz_container
        environment:
          - DISPLAY=${DISPLAY}
        volumes:
          - /tmp/.X11-unix:/tmp/.X11-unix
        network_mode: host
        depends_on:
          - fastlio

    使用Docker Compose启动:

    docker-compose up -d       # 启动所有服务
    
    docker-compose down         # 停止并删除所有服务docker-compose logs -f      # 查看日志

    三、ROS + Docker

    将ROS应用打包到Docker容器中,是目前机器人开发和部署的最佳实践。它解决了“环境依赖地狱”问题。同一套代码在任何机器上都能跑。

    3.1 ROS网络配置

    ROS的节点通信依赖于网络。在Docker中运行ROS时,网络配置是关键。有三种主要方式:

    网络模式说明适用场景
    host容器共享宿主机网络栈ROS节点间通信,最常用
    bridge容器使用独立的虚拟网络隔离环境,需要端口映射
    none容器无网络离线测试

    对于ROS应用,推荐使用host模式。因为ROS节点需要在不同容器之间通过话题通信,host模式让所有容器共享同一网络,节点之间可以直接发现彼此。

    # 使用host网络模式启动ROS容器docker run -it --rm --network host \
      -v $(pwd)/workspace:/workspace \
      fastlio_noetic:v3 /bin/bash

    3.2多容器ROS通信

    在复杂的机器人系统中,通常会将不同功能拆分到不同的容器中。例如:

    • 容器A:运行FAST-LIO建图算法
    • 容器B:运行RViz可视化
    • 容器C:运行数据处理脚本
    • 容器D:运行机器人底盘驱动

    这些容器之间通过ROS话题进行通信。只要使用host网络模式,容器内的ROS节点就像在同一台机器上运行一样,可以自然地发现彼此并通信。

    # 容器1: 启动FAST-LIO
    docker exec -it fastlio_container /bin/bash
    roslaunch fast_lio mapping_avia.launch rviz:=false
    
    # 容器2: 进入同一个容器播放bag
    docker exec -it fastlio_container /bin/bash
    cd /workspace
    rosbag play data.bag
    
    # 容器3: 在另一个容器中查看话题
    docker exec -it rviz_container /bin/bash
    
    rostopic list    # 可以看到FAST-LIO发布的话题rostopic echo /Odometry    # 查看里程计数据

    3.3数据持久化

    Docker容器是临时的。删除容器后,容器内的所有数据都会丢失。因此,必须将重要数据挂载到宿主机上。

    最佳实践:

    • 代码和配置文件:通过 -v挂载到宿主机,方便修改和版本控制
    • 数据文件(bag、PCD):挂载到宿主机目录,容器只负责处理
    • 日志文件:挂载到宿主机,方便后续分析
    # 推荐的数据挂载方式
    
    docker run -it --rm \
      -v $(pwd)/workspace:/workspace \     # 数据和代码
      -v $(pwd)/bags:/bags \             # bag包
      -v $(pwd)/output:/output \         # 输出结果fastlio_noetic:v3 /bin/bash

    3.4 X11转发(GUI应用)

    ROS中很多工具是图形界面的(如RViz、rqt_graph)。在Docker中使用GUI应用需要X11转发。

    # 方法1: 使用host网络 + X11 socket挂载docker run -it --rm --network host \
      -e DISPLAY=$DISPLAY \
      -v /tmp/.X11-unix:/tmp/.X11-unix \
      -v $HOME/.Xauthority:$HOME/.Xauthority \
      osrf/ros:noetic-desktop-full /bin/bash

    # 方法2: 使用xhost允许Docker访问X11
    xhost +local:docker
    docker run -it --rm --network host \
      -e DISPLAY=$DISPLAY \
      -v /tmp/.X11-unix:/tmp/.X11-unix \
      osrf/ros:noetic-desktop-full /bin/bash

    四、实战:构建ROS SLAM容器

    下面通过一个完整的例子,展示如何从零构建一个基于ROS Noetic的激光SLAM Docker容器。

    4.1编写Dockerfile

    FROM osrf/ros:noetic-desktop-full

    ENV DEBIAN_FRONTEND=noninteractive

    # 安装PCL和Eigen
    RUN apt-get update && apt-get install -y \
        libpcl-dev \
        libeigen3-dev \
        ros-noetic-pcl-ros \
        ros-noetic-tf \
        ros-noetic-rviz \
        && rm -rf /var/lib/apt/lists/*

    # 创建工作空间RUN mkdir -p /root/fastlio_ws/src
    WORKDIR /root/fastlio_ws

    # 自动加载ROS环境RUN echo "source /opt/ros/noetic/setup.bash" >> ~/.bashrc

    WORKDIR /workspace
    CMD ["/bin/bash"]

    4.2构建和运行

    # 构建镜像docker build -t fastlio_noetic:v1 .

    # 启动容器docker run -it --rm --name fastlio_container \
      -v $(pwd)/workspace:/workspace \
      fastlio_noetic:v1 /bin/bash

    # 在容器内编译FAST-LIO(首次)cd /root/fastlio_ws/src
    git clone https://github.com/hku-mars/FAST_LIO.git
    cd /root/fastlio_ws
    catkin_make

    # 后续使用:直接启动即可,代码已内置echo "source /root/fastlio_ws/devel/setup.bash" >> ~/.bashrc

    4.3镜像版本管理

    随着项目迭代,镜像会不断演进。建议采用语义化版本管理:

    • v1.0:基础环境 + ROS Noetic + PCL

    • v1.1:加入FAST-LIO源码

    • v2.0:代码内置 + 环境自动加载 + PCD直出宿主机(开箱即用版)

    • v2.1:修复PCD保存路径问题

    # 查看镜像历史docker history fastlio_noetic:v2

    # 给镜像打标签docker tag fastlio_noetic:v2 fastlio_noetic:latest
    docker tag fastlio_noetic:v2 fastlio_noetic:20260616

    # 导出镜像用于分发docker save -o fastlio_noetic_v2.tar fastlio_noetic:v2

    # 在新机器上导入docker load -i fastlio_noetic_v2.tar

    五、常见问题与排查

    5.1容器内找不到ROS命令

    进入容器后执行roscore提示command not found。原因是没有source ROS环境。

    # 手动加载
    source /opt/ros/noetic/setup.bash
    
    # 永久生效(写入 .bashrc)
    echo "source /opt/ros/noetic/setup.bash" >> ~/.bashrc
    source ~/.bashrc

    5.2多容器间无法通信

    检查以下几点:

    • 确认所有容器使用相同的网络模式(推荐host)
    • 确认ROS_MASTER_URI指向同一个roscore
    • 确认防火墙没有阻止相关端口
    • 使用rostopic list检查话题是否可见

    5.3容器内无法显示GUI

    确保X11 socket已挂载,且宿主机已执行xhost +local:docker。如果使用SSH远程连接,需要启用X11转发:

    ssh -X user@host    # 启用X11转发

    5.4挂载目录权限问题

    Docker容器默认以root用户运行,创建的文件在宿主机上可能无法修改。解决方案:

    # 方法1: 给挂载目录开权限(简单粗暴)chmod 777 ~/workspace

    # 方法2: 在容器内创建非root用户useradd -m rosuser && su - rosuser

    # 方法3: 使用user namespace映射docker run --userns=host ...

    六、总结

    ROS和Docker的结合是机器人开发的最佳实践。ROS提供了强大的通信和工具框架,Docker提供了环境隔离和一键部署能力。两者的结合让机器人软件的开发、测试和部署变得前所未有的简单。

    关键要点:

    • ROS的核心是分布式通信。节点通过话题和服务交换数据
    • Docker的核心是容器化。将应用和依赖打包成可移植的镜像
    • ROS + Docker的关键是网络配置。使用host模式让容器间自然通信
    • 数据持久化靠挂载。重要数据一定要映射到宿主机• 镜像版本化管理。方便团队协作和部署
  • ZIP文件

    一、文件结构

    1、Local File Header — 每个压缩文件开头的本地头,包含文件名、压缩方式、CRC校验等

    2、Compressed Data — 实际的压缩数据

    3、End of Central Directory Record (EOCDR) — 最末尾的一条记录,相当于”目录索引”,记录了:
    – 这个zip里有多少个文件
    – 每个文件在zip里的偏移位置
    – 压缩前后的文件大小
    – 文件名列表


    二、解压过程

    第一步:定位EOCDR

    unzip从文件末尾往前搜索,找到End of Central Directory Record签名(PK\x05\x06)。EOCDR固定在最末尾,长度不固定但通常很小(几十到几百字节)。

    第二步:读取Central Directory

    EOCDR里记录了Central Directory(中央目录)在文件中的偏移位置和大小。unzip跳到那个位置,逐个读取每个文件的Central Directory Entry,得到:

    第三步:逐个解压文件

    对每个文件:

    • 根据偏移找到Local File Header
    • 读取压缩数据
    • 按压缩方式解压(最常用是deflate)
    • 用CRC32校验解压后的数据是否完整
    • 写入磁盘

  • Base64编码(Golang)

    Base64是一种用64个可打印字符来表示二进制数据的方法。叫64是因为用了64个字符。

    一、需求

    计算机底层是二进制(0和1),但很多传输通道只支持文本。比如:

    • 电子邮件:早期SMTP协议只支持ASCII字符,传不了二进制附件
    • URL:有些特殊字符在URL里有特殊含义(? & =),二进制数据传不了
    • HTML/CSS:在网页里直接嵌入图片,不需要额外请求
    • JSON/XML:这些文本格式不能直接塞二进制数据

    Base64就是把二进制数据”翻译”成纯文本,安全地通过这些通道。


    二、原理

    每3个字节(24位)分成4组,每组6位,查表得到4个字符。

    原始数据:Man
    二进制:01001101 01100001 01101110
    6位分组:010011 011000 010110 1110(补00)
    查表:T W F u
    结果:TWFu

    如果原始数据不是3的倍数:

    剩1个字节 → 输出2个字符 + 2个 “=” 填充

    剩2个字节 → 输出3个字符 + 1个 “=” 填充

    任何二进制数据都可以,不只是PNG
    图片:PNG、JPG、GIF、WEBP、SVG、BMP、ICO
    音频:MP3、WAV、AAC、OGG
    视频:MP4、AVI、WEBM
    文档:PDF、ZIP、EXE、DLL
    证书:PEM格式的SSL证书
    任意文件:任何你能想到的文件


    三、其他编码

    Base16(Hex):用16个字符(0-9 A-F),体积膨胀100%
    Base32:用32个字符,体积膨胀约60%
    Base64:用64个字符,体积膨胀约33%
    一张100KB的PNG图片:
    base64编码后约133KB
    解码后恢复为原始的100KB PNG

    Base85:用85个字符,体积膨胀约25%,但字符集更复杂

    Base64是体积和可读性的最佳平衡点

    四、使用示例

    package main
    
    import (
            "encoding/base64"
            "fmt"
            "os"
    )
    
    func main() {
            //1. 基础字符串编解码
            original := "Hello, 世界! "
            fmt.Printf("原始字符串: %s\n", original)
            encoded := base64.StdEncoding.EncodeToString([]byte(original))
            fmt.Printf("Base64编码: %s\n", encoded)
            decoded, err := base64.StdEncoding.DecodeString(encoded)
            if err != nil {
                    fmt.Printf("解码错误: %v\n", err)
            } else {
                    fmt.Printf("Base64解码: %s\n", string(decoded))
            }
    
            // 2. URL安全的Base64
            urlData := "user+name/test=data&key=val"
            fmt.Printf("原始数据: %s\n", urlData)
            stdEncoded := base64.StdEncoding.EncodeToString([]byte(urlData))
            urlEncoded := base64.URLEncoding.EncodeToString([]byte(urlData))
            fmt.Printf("标准Base64: %s\n", stdEncoded)
            fmt.Printf("URL Base64: %s\n", urlEncoded)
    
            // 3. 无填充的Base64
            data := "abc"
            fmt.Printf("原始数据: %s\n", data)
            fmt.Printf("带填充: %s\n", base64.StdEncoding.EncodeToString([]byte(data)))
            fmt.Printf("无填充: %s\n", base64.RawStdEncoding.EncodeToString([]byte(data)))
    
            // 4. 自定义Base64字母表
            custom := base64.NewEncoding("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~!")
            customEncoded := custom.EncodeToString([]byte("Hello World"))
            fmt.Printf("自定义编码: %s\n", customEncoded)
            customDecoded, _ := custom.DecodeString(customEncoded)
            fmt.Printf("自定义解码: %s\n", string(customDecoded))
    
            // 5. 模拟图片Base64编码
            // 创建一个假的PNG文件头(实际项目中替换为真实图片文件)
            fakePNG := []byte{
                    0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG文件头
                    0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR
            }
            imgEncoded := base64.StdEncoding.EncodeToString(fakePNG)
            fmt.Printf("图片Base64: %s\n", imgEncoded)
    
            // 模拟HTML内嵌(取前min(30, len)个字符)
            prefixLen := 30
            if len(imgEncoded) < prefixLen {
                    prefixLen = len(imgEncoded)
            }
            fmt.Printf("HTML内嵌: <img src=\"data:image/png;base64,%s...\" />\n", imgEncoded[:prefixLen])
    
            // 6. 读真实文件并编码(如果存在)
            filename := "/tmp/test_base64.txt"
            os.WriteFile(filename, []byte("这是一个测试文件内容\n用于演示Base64编码"), 0644)
            content, err := os.ReadFile(filename)
            if err != nil {
                    fmt.Printf("读文件失败: %v\n", err)
            } else {
                    fileEncoded := base64.StdEncoding.EncodeToString(content)
                    fmt.Printf("文件名: %s\n", filename)
                    fmt.Printf("原始大小: %d 字节\n", len(content))
                    fmt.Printf("编码大小: %d 字节\n", len(fileEncoded))
                    fmt.Printf("膨胀率: %.1f%%\n", float64(len(fileEncoded)-len(content))/float64(len(content))*100)
                    fmt.Printf("Base64: %s\n", fileEncoded)
            }
            os.Remove(filename)
    
            // 7. 错误处理 - 非法Base64
            invalidBase64 := "ThisIsNot@Valid#Base64!"
            _, err = base64.StdEncoding.DecodeString(invalidBase64)
            if err != nil {
                    fmt.Printf("非法Base64 '%s' 解码错误: %v\n", invalidBase64, err)
            }
    
            // 8. 严格模式
            // 标准模式允许尾部bits非零
            loose := base64.StdEncoding
            strict := base64.StdEncoding.Strict()
            testData := "SGVsbG8=" // "Hello"
            _, err1 := loose.DecodeString(testData)
            _, err2 := strict.DecodeString(testData)
            fmt.Printf("标准模式解码 '%s': %v\n", testData, err1)
            fmt.Printf("严格模式解码 '%s': %v\n", testData, err2)
    
            // 9. 编解码长度计算
            for _, n := range []int{1, 2, 3, 4, 5, 6, 10, 100} {
                    encLen := base64.StdEncoding.EncodedLen(n)
                    decLen := base64.StdEncoding.DecodedLen(encLen)
                    fmt.Printf("输入%3d字节 → 编码%4d字节 → 解码最多%3d字节\n", n, encLen, decLen)
            }
    }
    
  • Cron定时(Golang)

    Golang的日常定时任务是一种常见需求,例如定期清理缓存、发送通知或同步数据。robfig/cron是Go社区广泛使用的定时任务库,支持秒级精度的cron表达式,并提供了安全启动、停止以及任务查询的接口。本文将从cron表达式的基本原理出发,结合一段实际的生产级代码,详细说明如何封装一个可管理、可监控的定时任务模块,并分析其中关键的并发控制与生命周期管理细节。


    cron 表达式原理

    cron表达式起源于Unix系统的cron守护进程,用于定义任务的执行时间。标准格式通常为五个字段:分、时、日、月、周几,每个字段可指定具体数字、范围(如0-5)、步长(如/5)或通配符。robfig/cron扩展了标准格式,支持可选的秒级字段,即六字段表达式:秒、分、时、日、月、周几。例如”0 30 9 * * 1-5″表示每个工作日上午9点30分执行,其中秒为0。库内部通过解析表达式生成下一次触发时间,并利用定时器(time.Ticker)在精确时刻调用注册的回调函数。其核心是一个调度循环,不断计算最近的下一个执行时间,休眠到该时刻并运行任务,然后重复此过程。这种设计保证了定时任务在单进程内的可靠触发,且不依赖外部守护进程。


    代码整体结构

    # Golang
    import (
    	"net/http"
    	"sync"
    	"time"
    
    	"github.com/gin-gonic/gin"
    	"github.com/robfig/cron/v3"
    )
    
    type CronEntry struct {
    	Cron      *cron.Cron
    	EntryID   cron.EntryID
    	MU        sync.RWMutex
    	StopOnce  sync.Once
    	Remark    string
    	RunStatus string
    }
    
    var (
    	CronJobs   []*CronEntry
    	CronJobsMu sync.RWMutex
    	MyCron     = cron.New(cron.WithSeconds())
    )
    
    func NewCronEntry(spec, remark string, job func() error) (*CronEntry, error) {
    	ce := &CronEntry{
    		Cron:      MyCron,
    		Remark:    remark,
    		RunStatus: "等待中",
    	}
    	id, err := MyCron.AddFunc(spec, func() {
    		err := job()
    		ce.MU.Lock()
    		if err != nil {
    			ce.RunStatus = "失败"
    		} else {
    			ce.RunStatus = "成功"
    		}
    		ce.MU.Unlock()
    	})
    	if err != nil {
    		return nil, err
    	}
    	ce.EntryID = id
    	MyCron.Start()
    	CronJobsMu.Lock()
    	CronJobs = append(CronJobs, ce)
    	CronJobsMu.Unlock()
    	return ce, nil
    }
    func (ce *CronEntry) GetScheduleTime() (prev, next time.Time) {
    	entry := ce.Cron.Entry(ce.EntryID) // cron.Entry() 本身是并发安全的
    	return entry.Prev, entry.Next
    }
    func (ce *CronEntry) Stop() {
    	ce.StopOnce.Do(func() {
    		ce.MU.Lock()
    		defer ce.MU.Unlock()
    		if ce.Cron == nil {
    			return
    		}
    		ce.Cron.Remove(ce.EntryID) // 先移除 job
    		ctx := ce.Cron.Stop()      // 停止调度器
    		<-ctx.Done()
    		ce.Cron = nil // 标记已停
    	})
    }
    func StopAndRemoveCronEntry(ce *CronEntry) {
    	if ce == nil {
    		return
    	}
    	ce.Stop()
    	CronJobsMu.Lock()
    	defer CronJobsMu.Unlock()
    	for i, job := range CronJobs {
    		if job == ce {
    			CronJobs = append(CronJobs[:i], CronJobs[i+1:]...)
    			break
    		}
    	}
    }
    func (ce *CronEntry) GetStatus() string {
    	ce.MU.RLock()
    	defer ce.MU.RUnlock()
    	return ce.RunStatus
    }
    func GetCronJobs(c *gin.Context) {
    	type CronJobInfo struct {
    		Job       int    `json:"job_id"`
    		LastDate  string `json:"上一次执行日期"`
    		NextDate  string `json:"下一次执行日期"`
    		Remark    string `json:"备注"`
    		RunStatus string `json:"上一次执行状态"`
    	}
    	CronJobsMu.RLock()
    	var list []CronJobInfo
    	defer CronJobsMu.RUnlock()
    	for _, entry := range CronJobs {
    		prev, next := entry.GetScheduleTime()
    		prevStr := ""
    		if !prev.IsZero() { // 如果任务还没执行过,Prev 是零值
    			prevStr = prev.Format("2006-01-02 15:04:05")
    		}
    
    		nextStr := ""
    		if !next.IsZero() {
    			nextStr = next.Format("2006-01-02 15:04:05")
    		}
    
    		list = append(list, CronJobInfo{
    			Job:       int(entry.EntryID),
    			LastDate:  prevStr,
    			NextDate:  nextStr,
    			Remark:    entry.Remark,
    			RunStatus: entry.GetStatus(),
    		})
    	}
    	c.AbortWithStatusJSON(http.StatusOK, list)
    }

    代码中定义了一个CronEntry结构体,封装了单个定时任务的完整信息:指向全局调度器的Cron指针、任务在调度器中的唯一EntryID、用于同步的读写锁MU、保证只停止一次的StopOnce、备注 Remark 以及最近一次执行状态RunStatus。全局变量MyCron是使用cron.New(cron.WithSeconds())创建的调度器实例,启用了秒级支持,同时全局切片CronJobs存储所有被管理的任务指针,并由CronJobsMu读写锁保护。这种设计将任务管理与调度器解耦:调度器负责底层触发,而用户代码通过CronEntry获取状态、控制启停。

    创建任务:NewCronEntry

    NewCronEntry函数接收cron表达式(spec)、备注和实际执行函数(返回error)作为参数。函数内部首先创建一个CronEntry实例,初始化运行状态为“等待中”。然后通过MyCron.AddFunc注册任务——这是robfig/cron的核心方法,它解析表达式并在调度器中插入一个entry,返回唯一的EntryID。注册的回调函数先执行用户提供的job,然后根据返回的err通过ce.MU.Lock()安全更新RunStatus为“成功”或“失败”。注意加锁是为了防止后续GetStatus与这里形成数据竞争,因为GetStatus 使用读锁。任务注册成功后,立即启动调度器(MyCron.Start()),这意味着一旦调用NewCronEntry,调度循环就开始运行,后续添加的任务也会被已启动的调度器处理。Start()函数是幂等的,重复调用不会导致多次启动,因此放在每个新任务创建时是安全的。最后,将新entry追加到全局CronJobs切片中,并返回该 entry指针。这里存在一个潜在问题:Start()在每次添加任务时都会调用,虽然不会重复启动,但代码风格上更适合在初始化时仅调用一次。不过考虑到后续可能动态添加任务,如此实现也能工作。

    停止单个任务:Stop方法

    Stop方法使用sync.Once确保一个任务只被停止一次,防止重复调用导致panic或资源泄漏。内部先获取写锁,检查ce.Cron是否为nil(已停止状态),然后依次执行ce.Cron.Remove(ce.EntryID) 从调度器中移除该任务entry,再调用ce.Cron.Stop()停止整个调度器。这里有一个值得注意的细节:Stop()返回一个 channel ctx,调用者需要等待<-ctx.Done()以确保调度器完全停止并清空内部计时器。但问题在于,Stop()是全局行为,它会停止所有任务,而不仅仅是当前 entry。因此,如果一个模块只想停用自己管理的单个任务,使用MyCron.Stop() 会中断其他仍在运行的任务。这应当是设计上的简化,实际项目中往往需要更细粒度的控制,例如使用cron.Cron.Remove(entryID)移除条目后,其他任务仍能继续调度。这里先移除条目再停止整个调度器,可能意味着使用者期望在stop后不再有任何任务执行。如果后续需要单独停止单个任务而不影响其他,更好的做法是在Stop中只调用Remove,而保留调度器运行。但当前代码体现了作者对整体生命周期控制的考量——当某个关键任务停止时,整个调度器也同步停止,避免因剩余任务不再被管理而产生混乱。

    状态查询与调度时间获取

    GetScheduleTime方法通过ce.Cron.Entry(ce.EntryID)获取cron库内部维护的entry 信息,其中包括Prev和Next两个time.Time值,分别表示上一次和下一次执行时间。Cron.Entry()本身是并发安全的,因此无需额外加锁。如果任务尚未执行过,Prev为零值,代码在GetCronJobs中通过IsZero()判断并格式化为空字符串,这样 HTTP响应中就不会显示不存在的日期。GetStatus使用读锁返回当前RunStatus,与回调中写锁对应,保证了数据一致性。

    全局停止与移除:StopAndRemoveCronEntry

    该函数接收一个*CronEntry,先调用其Stop方法,然后从全局CronJobs切片中移除该指针。切片删除采用“先查找再重新拼接”的方式,这种线性搜索在任务数不多时没有问题。注意这里对CronJobsMu加写锁,与NewCronEntry中添加时的锁一致,避免了并发读写切片的风险。

    HTTP 接口:GetCronJobs

    这是一个Gin处理函数,用于返回所有定时任务的信息。它首先通过读锁读取CronJobs切片,遍历每个entry,获取其调度时间和状态,组装成JSON结构。响应中包含了job_id(即EntryID)、上一次执行日期、下一次执行日期、备注和上一次执行状态。日期格式使用config.TimeNowFormat,这应是项目中预定义的时间格式字符串,例如”2006-01-02 15:04:05″。函数最后使用c.AbortWithStatusJSON返回状态码200和序列化后的列表。注意这里使用了AbortWithStatusJSON而不是c.JSON,意味着该函数可能会作为Gin的中间件调用,或者作者希望确保后续中间件不再处理。按照Gin的约定,如果是路由处理函数,更常见的写法是c.JSON,但AbortWithStatusJSON也能正常工作,只是它会设置Abort标志,阻止之后的所有中间件执行。如果该函数是路由的最后一个处理器,两者效果一致;如果有后续中间件,则会导致它们被跳过。这点需要结合具体路由设置来判断。


    总结

    在Go中基于robfig/cron封装一个可管理的定时任务模块。通过CronEntry结构体整合了任务的生命周期(创建、启动、停止、移除)和运行状态,利用读写锁和sync.Once保证了并发安全。同时,提供了HTTP接口用于监控所有任务的状态。在实际使用中,需要注意全局调度器的停止操作会对所有任务产生影响,如果希望实现更细粒度的单任务停止,可以仅调用Remove而不调用Stop()。此外,NewCronEntry中每次添加任务都调用Start()虽然可行,但建议将Start()放在初始化阶段一次调用,使逻辑更清晰。整体设计为中小规模定时任务管理提供了一个良好的模板,具备扩展性,例如可在此基础上增加持久化、错误重试或任务依赖等高级特性。

  • webRTC(Golang)

    在视频监控、工业巡检以及边缘计算等场景中,常见的一个现实问题是协议割裂:前端设备通常通过RTSP推流,而浏览器原生并不支持直接播放RTSP。这就带来了一个工程上的关键挑战——如何在不引入高延迟和复杂转码的前提下,将设备侧的视频流高效地分发到浏览器端。

    一种更直接且高效的思路,是利用WebRTC作为浏览器侧的实时传输协议,同时在服务端完成协议层的桥接,将RTSP流转换为WebRTC可消费的RTP数据流。这种方式避免了传统转码链路(例如FFMPEG+纯接口转发)带来的性能损耗,同时保留了实时性的优势。

    通过golang程序实现一个典型的视频桥接架构:上游通过RTSP拉流(通常来自摄像头或推流工具),服务端将RTP包转发到WebRTC PeerConnection,下游浏览器通过 WebRTC 实时播放视频。整体链路为:RTSP → RTP → Go服务 → WebRTC → 浏览器。


    零、在本机启一个mediamtx作为RTSP服务端,再通过ffmpeg把本机摄像头推流到服务器来模拟摄像头的RTSP流

    ./mediamtx &
    ./ffmpeg -f v4l2 -i /dev/video0 \
    -vcodec libx264 -preset veryfast -tune zerolatency \
    -f rtsp -rtsp_transport tcp \
    rtsp://test:123456@127.0.0.1:8554/test

    一、golang程序入口main中首先确定RTSP地址,并开一个Gin HTTP服务,同时维护一个clients映射,用于保存每个WebRTC客户端对应的Track:

    每个客户端并不是单独拉流,而是共享同一 RTSP输入流,服务端通过fan-out(扇出)机制将RTP包写入多个WebRTC Track,从而实现“一路输入,多路输出”

    import (
    	"github.com/bluenviron/gortsplib/v5"
    	"github.com/bluenviron/gortsplib/v5/pkg/base"
    	"github.com/bluenviron/gortsplib/v5/pkg/description"
    	"github.com/bluenviron/gortsplib/v5/pkg/format"
    	"github.com/gin-gonic/gin"
    	"github.com/pion/rtp"
    	"github.com/pion/webrtc/v3"
    )
    
    rtspURL := os.Getenv("RTSP_URL")
    if rtspURL == "" {
    	rtspURL = "rtsp://test:123456@127.0.0.1:8554/test"
    }
    
    var clientsMu sync.Mutex
    clients := map[string]*webrtc.TrackLocalStaticRTP{}

    二、HTTP部分分为三个路由:

    1)“/”路由返回播放器页面
    2)“/offer”路由处理WebRTC SDP信令交换
    3)“/stats”路由返回实时码率与包速率

    前端页面核心逻辑如下:

    浏览器创建RTCPeerConnection → 生成Offer(SDP)→ 发送给服务端 → 服务端生成Answer → 浏览器设置远端描述。需要注意的是,这里没有使用STUN/TURN(iceServers 为空),意味着该方案默认运行在内网或可直连环境,否则无法穿透NAT。

    pc=new RTCPeerConnection({iceServers:[]});
    pc.addTransceiver('video',{direction:'recvonly'});
    
    const offer=await pc.createOffer();
    await pc.setLocalDescription(offer);
    
    const resp=await fetch('/offer',{
      method:'POST',
      headers:{'Content-Type':'application/json'},
      body:JSON.stringify({sdp:offer.sdp,type:offer.type})
    });
    
    const answer=await resp.json();
    await pc.setRemoteDescription({type:answer.type,sdp:answer.sdp});

    服务端“/offer”路由处理逻辑是WebRTC的核心:

    这里做了两件关键事情:
    1)注册编解码器(H264/H265)
    2)创建 PeerConnection

    WebRTC本质上是RTP的增强版,但浏览器对编码格式要求严格,因此必须显式注册codec,否则SDP协商无法匹配。

    m := webrtc.MediaEngine{}
    _ = m.RegisterCodec(webrtc.RTPCodecParameters{
    	RTPCodecCapability: webrtc.RTPCodecCapability{
    		MimeType: webrtc.MimeTypeH264,
    		ClockRate: 90000,
    		SDPFmtpLine: "packetization-mode=1;profile-level-id=42e01f",
    	},
    	PayloadType: 96,
    }, webrtc.RTPCodecTypeVideo)
    
    api := webrtc.NewAPI(webrtc.WithMediaEngine(&m))
    pc, _ := api.NewPeerConnection(webrtc.Configuration{})

    创建Track,并绑定到PeerConnection:

    Track是WebRTC中媒体发送的抽象,本质上是RTP流的出口。这里使用TrackLocalStaticRTP,意味着可以手动写入RTP包

    track, _ := webrtc.NewTrackLocalStaticRTP(
    	webrtc.RTPCodecCapability{
    		MimeType: webrtc.MimeTypeH264,
    		ClockRate: 90000,
    	},
    	"video", "pion",
    )
    pc.AddTrack(track)

    信令交换部分:

    接收浏览器Offer → 设置远端 SDP → 生成 Answer → 返回给浏览器。GatheringCompletePromise用于等待ICE candidate收集完成,否则SDP不完整。

    offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: req.SDP}
    pc.SetRemoteDescription(offer)
    
    ans, _ := pc.CreateAnswer(nil)
    pc.SetLocalDescription(ans)
    
    <-webrtc.GatheringCompletePromise(pc)

    将Track存入clients,用于后续RTP分发:

    这里通过DESCRIBE + SETUP建立RTSP会话,并解析媒体格式(H264/H265)。

    clientsMu.Lock()
    clients[c.ClientIP()+"_"+time.Now().Format("150405")] = track
    clientsMu.Unlock()

    核心转发逻辑在OnPacketRTP:

    RTSP → 解复用 → 得到RTP包 → 广播写入所有WebRTC Track
    服务端本质上只是一个RTP转发器,并不进行转码,这带来两个重要特性:
    优点:延迟极低(基本无编码延迟)
    CPU占用极小
    架构简单稳定
    限制:浏览器必须支持该编码(通常是H264)
    RTSP输入必须与WebRTC codec兼容

    cli.OnPacketRTP(media, formaH264, func(pkt *rtp.Packet) {
    	clientsMu.Lock()
    	totalBytes += uint64(len(pkt.Payload))
    	totalPackets++
    
    	activeClients := make([]*webrtc.TrackLocalStaticRTP, 0, len(clients))
    	for _, t := range clients {
    		activeClients = append(activeClients, t)
    	}
    	clientsMu.Unlock()
    
    	for _, t := range activeClients {
    		t.WriteRTP(pkt)
    	}
    })

    统计接口“/stats”路由通过简单计数实现码率计算:

    前端每秒拉取一次,实现实时监控。

    kbps := uint64(float64(totalBytes*8) / 1024 / duration)
    pktps := uint64(float64(totalPackets) / duration)

    第一,WebRTC并不负责“获取视频”,它只负责“传输媒体流”。视频源可以来自 RTSP、文件、摄像头等。

    第二,WebRTC的关键不是API,而是:

    • SDP协商
    • ICE建连
    • RTP收发

    第三,这种架构属于典型的“边缘网关模式”:

    RTSP(设备侧协议) → WebRTC(浏览器协议)

    在工业监控、视频巡检、边缘计算中非常常见

  • Load AVG(Linux)

    Load Average】:代表机器在某一时间段内,处于“可运行状态”或“不可中断等待状态”的进程平均数量。

    三个数值统计跨度分别为:1分钟平均 5分钟平均 15分钟平均


    一台4核心的处理器的Load为 1.21 1.34 1.12

    1、CPU压力为 1.21 / 4 ≈ 0.30

    2、CPU压力为 1.34 / 4 ≈ 0.34

    3、CPU压力为 1.12 / 4 ≈ 0.28

    平均只有约1个任务在运行或等待CPU,而系统有4核心,完美胜任服务


    在Linux中,平均负载并非在每个时钟滴答时计算,而是由一个基于HZ频率设置的变量值驱动,并在每个时钟滴答时进行检测。该设置定义了内核时钟滴答速率(单位:赫兹,即每秒次数),默认值为100,对应10毫秒的滴答间隔。内核活动使用这些滴答数进行计时。具体来说,calc_load() 函数(位于loadavg.h文件,原为 sched.h)负责计算平均负载,它大约每LOAD_FREQ(5*HZ+1)个滴答运行一次,即略多于5秒。