引言
骑车时录了不少视频,一直想给画面加上运动数据——心率、速度、轨迹图那种 DJI / Garmin Virb 风格的仪表盘 overlay。
最开始做的是网页版方案:HLS 切片 + hls.js 播放,再用 HTML + Canvas 在视频上叠一层实时 HUD。能用,但有几个硬伤:移动端全屏时 HTML 层会被系统播放器吃掉,视频下载/转发后数据也没了,而且不同浏览器对原生 HLS 的支持参差不齐(新版 Chrome 自称支持 HLS,但对 AES-128 加密切片的行为并不一致)。
干脆换个思路:把数据直接烧录进视频画面。数据成为画面本身,全屏、下载、转发、任何播放器都带着。这篇文章记录完整实现过程。
说起 FFmpeg,我第一次系统接触是十几年前看 CSDN 博主 雷霄骅 的系列文章。当时在做直播相关的推拉流,天天用 ffmpeg 推 RTMP、用 ffplay 拉流测试。雷神的图文把 filter、codec、container 这些概念讲得特别清楚,后来我做视频切片、转码、overlay,其实都是从他那篇文章的底子长出来的。很可惜他已经不在了,但我视频相关的入门,确实是他教的。
为什么不用 DJI Studio
先说结论:DJI Studio / Mimo 这类官方 App 的仪表盘功能,只认 DJI 自家的数据源(相机 GPS、手机)。我试了华为手表导出的 GPX / FIT,文件能导进去,但心率字段脱敏,仪表盘里不显示心率。
我的实际情况更尴尬一点:心率在华为手表里,而华为运动健康导出的 GPX / FIT 文件心率是脱敏的——两个格式都试过,轨迹在、心率没了。但同一份数据同步到 RQ Run 之后心率是完整的,而 RQ 的数据我早就接入了自建的运动数据服务端(博客的运动详情页就是用它渲染的)。
所以数据源其实现成:服务端的 trkpt 轨迹点,心率、速度、海拔、步频、里程、经纬度全有。缺的不是数据,而是一个”一句话就自动生成”的流水线——我最终的用法是:在微信里告诉 AI agent 视频在哪个目录,它直接在 Linux 服务器上跑脚本,解析数据、烧录、切片、加密、上传,全自动。DJI Studio 给不了这个。
华为运动健康数据怎么落到服务端
要把华为手表里的心率、轨迹完整拿下来,有两条路:
1. 华为运动健康 App 直接导出(不推荐)
在华为运动健康 App 里打开某条记录,点击分享或导出,可以拿到 .gpx 或 .fit 文件。这条路的问题是:心率字段被脱敏了。我两个格式都试过,轨迹、距离、海拔都在,唯独心率没了,所以 DJI Studio 也读不到心率。
2. 通过 RQ Run 同步 + Python 脚本抓取(推荐)
我的实际链路是:
- 在 RQ Run(
https://www.rq.run)绑定华为运动健康账号,让 RQ 自动同步每一次运动。 - RQ 上的记录心率是完整的,而且每条记录都有详细的
trkpt轨迹点。 - 用仓库根目录下的
rq_scraper.py自动登录 RQ、抓取列表和详情、上传到自建的运动数据服务端。
脚本核心逻辑是:
|
运行方式:
|
脚本会用 Playwright 模拟登录(带验证码识别),拿到 cookies 后调用 RQ 内部接口 https://www.rq.run/Dc/Api?_=User/Record/get_record_info 拉取单条记录的完整轨迹点,再通过 POST /api/v1/workouts/detail 上传到服务端。上传时会按 original_id 去重,provider_id == 20 的记录会标注数据源为”华为运动健康”。
服务端存好数据后,博客的运动详情页和 HUD 烧录脚本就都能直接用了。

整体方案
|
数据源是运动服务端的 trkpt 轨迹点,每个点包含:sec(秒)、heart_rate、speed、altitude、cadence、distance、经纬度。一条 36 分钟的骑行记录大约 430 个点(5 秒一个)。
逐秒渲染 HUD 帧
核心思路:视频多少秒就生成多少张透明 PNG,第 i 张对应该秒的数据状态,再用 FFmpeg 以 1fps 作为第二路输入叠上去。
布局
参考 DJI 仪表盘的布局:
- 左上:日期 + 实时时钟(运动开始时间 + 已时长逐秒走)
- 顶部居中:深色圆角计时胶囊
- 左列:数据卡(斜体大数值 + 绿色斜体单位),按运动类型适配
- 右上:指北轨迹图(N 标记 + 虚线参考线 + 当前位置点)
- 底部居中:坡度小弧表
- 右下:半圆速度表(骑行)/ 配速表(跑步)
数据卡按运动类型适配——骑行显示海拔/坡度/总里程/心率/消耗热量/累计爬升/平均速度,跑步换成配速/步频/平均步频等,超过 6 项自动收紧行距。
字体是最大的坑
最初用系统里的文泉驿正黑,效果一言难尽。后来试了一圈,数字和单位最终选定 Barlow Semi Condensed Bold Italic(Google Fonts 免费可商用,气质最接近大疆仪表盘),中文标签用 Noto Sans CJK:
|
两个细节:
- 不要用 PIL 的仿射变换做伪斜体,切变会让文字发虚,直接选自带斜体的字体
- 描边换成柔和投影:黑色文字 +
GaussianBlur(4)+ 偏移 3px,比stroke_width的硬描边干净得多
轨迹图的圆形裁切
最初版本的轨迹会画出圆形边框外——CSS 里 border-radius: 50% + overflow: hidden 是天然裁切,PIL 里要手动做蒙版:
|
数据对齐的坑
- 步频:华为设备直接给总步频(
180),RQ 给单脚步频(90 需要 ×2)。取中位数判断:>130 认为是总步频直接用,否则 ×2 - 消耗热量:API 只有总热量,按里程占比折算到每秒(比时间线性更接近真实消耗曲线)
- 心率配色:与网页版保持一致(<110 白 / <130 蓝 / <150 绿 / <170 橙 / ≥170 红),无数据显示
--
FFmpeg 烧录与转码
HUD 帧生成后,一条命令完成烧录 + 缩放 + 转码 + HLS 切片:
|
这条命令里同时有两个视频输入:
0:v:原始运动相机视频(比如 DJI Action 导出的 MP4)1:v:Python 生成的逐秒 HUD PNG 序列
filter_complex 分三步完成叠加:
|
scale2ref 是关键。主画面可能是 4K(3840x2160),而 HUD 帧固定 1920x1080,直接 overlay 会让 HUD 只覆盖左上角 1/4 画面。scale2ref 会先以第二个输入 [vm] 为参考,把第一个输入 [1:v] 缩放到同样分辨率,这样无论原始视频是什么尺寸,HUD 都能铺满画面。
逐秒 PNG 怎么和视频时间对齐?靠 -framerate 1 和 -start_number:
-framerate 1表示图片序列按 1fps 读入,第hud_00000.png覆盖 0-1 秒,hud_00001.png覆盖 1-2 秒,以此类推。-start_number 0表示从hud_00000.png开始读。- 如果是多段视频(比如运动相机分段录成
1.MP4、2.MP4),两段视频共享同一个全局 HUD 序列。第一段从 0 开始,第二段就要用-start_number 1273从全局第 1273 秒对应的 PNG 开始读。
几个额外的关键点:
- 必须重编码:overlay 天然要求重编码。顺带解决了一个老问题——DJI 原片是 HEVC(H.265),Chrome/Edge 的 MSE 无法软解 TS 里的 HEVC,转 H.264 后全平台可播。
-map 0:v:0 -map 0:a:0:DJI 的 MP4 里除了视频、音频,还经常带有私有数据流和缩略图流。不剔除直接封装 TS 会报错,-map显式只取第一路视频和第一路音频。- 码率控制:
-preset veryfast在速度和体积之间取平衡;-crf 23是 x264 常用画质档位。运动相机原片细节多,如果追求更小体积可以提到-crf 25。
在完整脚本 tools/upload_workout_video.py 里,FFmpeg 命令是动态拼出来的:
|
其中 hud_start 就是多段视频对应的全局起始秒数,保证时间戳和 HUD 帧一一对应。
Rust 版 HUD 渲染器
Python + Pillow 渲染一帧大约 0.1-0.2 秒,36 分钟的视频要生成 2160 帧,单线程得跑 3-4 分钟。实际测试下来,Python 单线程渲染 2168 帧大概要 8 秒左右,已经不算慢,但上到 60fps 或 4K 视频时还是能感觉到瓶颈。
所以我在 tools/burn_video_hud_rust/ 里写了一个 Rust 版渲染器,功能完全对齐 Python 版:同样的布局、同样的字体、同样的配色、同样的数据解析逻辑。技术栈是:
image+tiny-skia:绘图和抗锯齿ab_glyph:字体加载和字形渲染rayon:多线程并行渲染ureq:拉取运动数据 API
用法和 Python 版一样:
|
构建依赖 tools/fonts/ 下的字体:
NotoSansCJKsc-Regular.otfNotoSansCJKsc-Bold.otfBarlowSemiCondensed-BoldItalic.ttfBarlowSemiCondensed-MediumItalic.ttf
渲染完成后也是输出 hud_00000.png、hud_00001.png 这种命名,FFmpeg 直接拿来做 overlay,产物和 Python 版完全一致。
性能对比很直观:同样是 2168 帧 1920x1080 HUD,Python 单线程约 8 秒,Rust + rayon 约 0.2 秒,快了 40 倍左右。tools/upload_workout_video.py 会自动检测 Rust 二进制是否存在,存在就优先调用 Rust 版:
|
如果你的视频很长、需要反复调试 HUD 样式,建议先把 Rust 版编译出来;如果只是偶尔跑一次,Python 版也够用了。
HLS AES-128 加密与密钥下发
视频不想被随便搬走,用了 HLS 标准的 AES-128 加密:keyinfo.txt 里指定密钥 URL、本地密钥文件和 IV,FFmpeg 切片时自动加密并在 m3u8 里写入 #EXT-X-KEY。
关键设计是密钥不放 OSS,而是存在后端数据库,通过一个公开接口下发,但校验 Referer/Origin:
|
- 浏览器从博客域名发起 → 放行,返回 16 字节密钥
- 空 Referer(curl 直接打)→ 403
- 其他域名(被盗链嵌套)→ 403 + CORS 拦截
要说明的是天花板:这是”防君子不防小人”的方案,会抓包伪造 Referer 的人依然能拿到密钥。对于无登录的公开博客,这就是合理上限——目的是让别人不能简单地右键另存、不能把 m3u8 搬到自己网站播。
前端播放
烧录之后前端反而简单了——HTML HUD 整套删掉,回归原生播放器:
- 读
workout_videos.json拿到 OSS 目录,探测index.json(404 = 没有视频,不渲染卡片) - 滚动到视频卡片才加载 hls.js
- hls.js 按播放进度拉切片,密钥请求自动带 Referer
- 多段视频生成一个合并的
all.m3u8,进度条显示总时长、可跨段拖动
踩过的坑(都值得记一笔)
- CDN 缓存的 CORS 投毒:无 Origin 请求先把
.ts切片缓存进 CDN,之后所有跨域请求拿到的都是这份没有Access-Control-Allow-Origin的缓存,hls.js 全被浏览器拦死。解法是在 CDN 域名配置里强制附加响应头(缓存命中也生效) - Cloudflare 拦 Python UA:脚本用 urllib 提交密钥被 1010 拦截,伪装浏览器 UA 解决;TLS 指纹级别的拦截则直接换 curl
- 断点续传的密钥一致性:加密切片中途失败后,密钥文件必须和切片同生共死——密钥丢了但残留切片还在时,必须清掉重切,否则就是”切片旧密钥、库里新密钥”的死局
- 大切片调优:2 分钟 1080p50 的切片有 130-170MB,hls.js 默认 20 秒超时必死,
fragLoadingTimeOut要放宽到 120s 并加大重试 - OSS 偶发 502:上传必须带指数退避重试
效果与总结
最终效果就是文章开头那张图:数据以每秒一帧的精度烧进画面,36 分钟视频、2168 张 HUD 帧、19 个加密切片。全屏、下载、转发,数据都在。
整条链路最后收敛成一条命令:
|
latest 会自动解析为最新一条运动记录。现在我生成视频的方式是:在微信里告诉 AI agent 视频在哪个目录,它在服务器上执行这条命令,解析 RQ 数据、渲染 HUD、烧录转码、加密切片、上传 OSS、提交密钥,一条龙全自动——几分钟后博客的运动详情页里就能看了。
整个实现其实就两个脚本:一个 Python 渲染帧,一个 FFmpeg 转码上传。花费最多的是样式打磨——字体选了三轮,对照参考图一个像素一个像素调位置。技术本身不复杂,但把”能看”做到”好看”,才是这类小工具最耗时间的地方。
如果你也有运动相机 + 运动数据,这套方案直接可用:数据源换成你的 GPX/FIT 解析结果即可,渲染和转码部分完全通用。
评论
0 条评论