深夜提醒

现在是深夜,建议您注意休息,不要熬夜哦~

🏮 🏮 🏮

新年快乐

祝君万事如意心想事成!

share-image
ESC

把运动数据烧录进视频:用 FFmpeg + Python 实现 DJI 风格骑行仪表盘

引言

骑车时录了不少视频,一直想给画面加上运动数据——心率、速度、轨迹图那种 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 脚本抓取(推荐)

我的实际链路是:

  1. 在 RQ Run(https://www.rq.run)绑定华为运动健康账号,让 RQ 自动同步每一次运动。
  2. RQ 上的记录心率是完整的,而且每条记录都有详细的 trkpt 轨迹点。
  3. 用仓库根目录下的 rq_scraper.py 自动登录 RQ、抓取列表和详情、上传到自建的运动数据服务端。

脚本核心逻辑是:

# rq_scraper.py
CONFIG = {
"username": os.environ.get("RQ_USERNAME"),
"password": os.environ.get("RQ_PASSWORD"),
"login_url": "https://www.rq.run/login",
"target_url": "https://www.rq.run/user/record",
"api": {
"base_url": os.environ.get("API_BASE_URL"),
"token": os.environ.get("API_TOKEN"),
}
}

运行方式:

python3 rq_scraper.py              # 同步最近一周的新记录
python3 rq_scraper.py --sync-all-details # 同步所有历史详情

脚本会用 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 烧录脚本就都能直接用了。

烧录效果:左侧数据卡 + 顶部计时胶囊 + 右上指北轨迹 + 底部坡度弧 + 右下速度表

整体方案

运动记录 API (trkpt 轨迹点)


Python + Pillow 逐秒渲染透明 PNG(1920x1080 RGBA,每秒一帧)


FFmpeg overlay 烧录 + 转码 H.264 1080p


HLS 切片(2分钟一段)+ AES-128 加密


阿里云 OSS ──► 博客页面 hls.js 按需播放(密钥由 API 校验 Referer 后下发)

数据源是运动服务端的 trkpt 轨迹点,每个点包含:sec(秒)、heart_ratespeedaltitudecadencedistance、经纬度。一条 36 分钟的骑行记录大约 430 个点(5 秒一个)。

逐秒渲染 HUD 帧

核心思路:视频多少秒就生成多少张透明 PNG,第 i 张对应该秒的数据状态,再用 FFmpeg 以 1fps 作为第二路输入叠上去。

布局

参考 DJI 仪表盘的布局:

  • 左上:日期 + 实时时钟(运动开始时间 + 已时长逐秒走)
  • 顶部居中:深色圆角计时胶囊
  • 左列:数据卡(斜体大数值 + 绿色斜体单位),按运动类型适配
  • 右上:指北轨迹图(N 标记 + 虚线参考线 + 当前位置点)
  • 底部居中:坡度小弧表
  • 右下:半圆速度表(骑行)/ 配速表(跑步)

数据卡按运动类型适配——骑行显示海拔/坡度/总里程/心率/消耗热量/累计爬升/平均速度,跑步换成配速/步频/平均步频等,超过 6 项自动收紧行距。

字体是最大的坑

最初用系统里的文泉驿正黑,效果一言难尽。后来试了一圈,数字和单位最终选定 Barlow Semi Condensed Bold Italic(Google Fonts 免费可商用,气质最接近大疆仪表盘),中文标签用 Noto Sans CJK:

FONT_CJK = "fonts/NotoSansCJKsc-Regular.otf"          # 中文标签
FONT_LATIN_BOLDIT = "fonts/BarlowSemiCondensed-BoldItalic.ttf" # 数值
FONT_LATIN_MEDIT = "fonts/BarlowSemiCondensed-MediumItalic.ttf" # 单位

两个细节:

  1. 不要用 PIL 的仿射变换做伪斜体,切变会让文字发虚,直接选自带斜体的字体
  2. 描边换成柔和投影:黑色文字 + GaussianBlur(4) + 偏移 3px,比 stroke_width 的硬描边干净得多

轨迹图的圆形裁切

最初版本的轨迹会画出圆形边框外——CSS 里 border-radius: 50% + overflow: hidden 是天然裁切,PIL 里要手动做蒙版:

mask = Image.new("L", img.size, 0)
ImageDraw.Draw(mask).ellipse([cx-r, cy-r, cx+r, cy+r], fill=255)
img.paste(track_layer, (0, 0), mask) # 轨迹只贴在圆形区域内

数据对齐的坑

  • 步频:华为设备直接给总步频(180),RQ 给单脚步频(90 需要 ×2)。取中位数判断:>130 认为是总步频直接用,否则 ×2
  • 消耗热量:API 只有总热量,按里程占比折算到每秒(比时间线性更接近真实消耗曲线)
  • 心率配色:与网页版保持一致(<110 白 / <130 蓝 / <150 绿 / <170 橙 / ≥170 红),无数据显示 --

FFmpeg 烧录与转码

HUD 帧生成后,一条命令完成烧录 + 缩放 + 转码 + HLS 切片:

ffmpeg -y -i 1.MP4 \
-framerate 1 -start_number 0 -i hud_%05d.png \
-filter_complex "[0:v]scale=-2:1080[vm];[1:v][vm]scale2ref[hs][vs];[vs][hs]overlay=0:0[v]" \
-map "[v]" -map 0:a:0 \
-c:v libx264 -preset veryfast -crf 23 -c:a aac -b:a 128k \
-f hls -hls_time 120 -hls_playlist_type vod \
-hls_key_info_file keyinfo.txt \
-hls_segment_filename seg_%05d.ts index.m3u8

这条命令里同时有两个视频输入:

  • 0:v:原始运动相机视频(比如 DJI Action 导出的 MP4)
  • 1:v:Python 生成的逐秒 HUD PNG 序列

filter_complex 分三步完成叠加:

[0:v]scale=-2:1080[vm]        # 主画面等比缩放到 1080p
[1:v][vm]scale2ref[hs][vs] # 让 HUD 帧缩放到和主画面一样大
[vs][hs]overlay=0:0[v] # 把 HUD 贴在主画面上,左上角对齐

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.MP42.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 命令是动态拼出来的:

cmd = [ffmpeg, "-y", "-i", str(video)]
if hud_dir:
cmd += ["-framerate", "1", "-start_number", str(hud_start),
"-i", str(hud_dir / "hud_%05d.png")]
# ... 根据 --reencode、--scale 等参数拼接 filter_complex、编码器、HLS 参数

其中 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 版一样:

cd tools/burn_video_hud_rust
cargo build --release
./target/release/burn_video_hud_rust 106120018 /tmp/hud \
--offset 0 --duration 2168

构建依赖 tools/fonts/ 下的字体:

  • NotoSansCJKsc-Regular.otf
  • NotoSansCJKsc-Bold.otf
  • BarlowSemiCondensed-BoldItalic.ttf
  • BarlowSemiCondensed-MediumItalic.ttf

渲染完成后也是输出 hud_00000.pnghud_00001.png 这种命名,FFmpeg 直接拿来做 overlay,产物和 Python 版完全一致。

性能对比很直观:同样是 2168 帧 1920x1080 HUD,Python 单线程约 8 秒,Rust + rayon 约 0.2 秒,快了 40 倍左右。tools/upload_workout_video.py 会自动检测 Rust 二进制是否存在,存在就优先调用 Rust 版:

rust_bin = PROJECT_ROOT / "tools" / "burn_video_hud_rust" / "target" / "release" / "burn_video_hud_rust"
if rust_bin.exists():
print(" ⚡ 使用 Rust 版 HUD 渲染器")
subprocess.run([str(rust_bin), str(workout_id), str(hud_dir),
"--offset", str(hud_offset),
"--duration", str(total)], check=True)
else:
print(" 🐢 使用 Python 版 HUD 渲染器(Rust 二进制不存在)")
generate_hud_frames(workout_id, hud_dir, offset=hud_offset, duration=total)

如果你的视频很长、需要反复调试 HUD 样式,建议先把 Rust 版编译出来;如果只是偶尔跑一次,Python 版也够用了。

HLS AES-128 加密与密钥下发

视频不想被随便搬走,用了 HLS 标准的 AES-128 加密:keyinfo.txt 里指定密钥 URL、本地密钥文件和 IV,FFmpeg 切片时自动加密并在 m3u8 里写入 #EXT-X-KEY

关键设计是密钥不放 OSS,而是存在后端数据库,通过一个公开接口下发,但校验 Referer/Origin:

GET https://your-api-domain.com/api/v1/public/video-key/<id>
  • 浏览器从博客域名发起 → 放行,返回 16 字节密钥
  • 空 Referer(curl 直接打)→ 403
  • 其他域名(被盗链嵌套)→ 403 + CORS 拦截

要说明的是天花板:这是”防君子不防小人”的方案,会抓包伪造 Referer 的人依然能拿到密钥。对于无登录的公开博客,这就是合理上限——目的是让别人不能简单地右键另存、不能把 m3u8 搬到自己网站播。

前端播放

烧录之后前端反而简单了——HTML HUD 整套删掉,回归原生播放器:

  1. workout_videos.json 拿到 OSS 目录,探测 index.json(404 = 没有视频,不渲染卡片)
  2. 滚动到视频卡片才加载 hls.js
  3. hls.js 按播放进度拉切片,密钥请求自动带 Referer
  4. 多段视频生成一个合并的 all.m3u8,进度条显示总时长、可跨段拖动

踩过的坑(都值得记一笔)

  1. CDN 缓存的 CORS 投毒:无 Origin 请求先把 .ts 切片缓存进 CDN,之后所有跨域请求拿到的都是这份没有 Access-Control-Allow-Origin 的缓存,hls.js 全被浏览器拦死。解法是在 CDN 域名配置里强制附加响应头(缓存命中也生效)
  2. Cloudflare 拦 Python UA:脚本用 urllib 提交密钥被 1010 拦截,伪装浏览器 UA 解决;TLS 指纹级别的拦截则直接换 curl
  3. 断点续传的密钥一致性:加密切片中途失败后,密钥文件必须和切片同生共死——密钥丢了但残留切片还在时,必须清掉重切,否则就是”切片旧密钥、库里新密钥”的死局
  4. 大切片调优:2 分钟 1080p50 的切片有 130-170MB,hls.js 默认 20 秒超时必死,fragLoadingTimeOut 要放宽到 120s 并加大重试
  5. OSS 偶发 502:上传必须带指数退避重试

效果与总结

最终效果就是文章开头那张图:数据以每秒一帧的精度烧进画面,36 分钟视频、2168 张 HUD 帧、19 个加密切片。全屏、下载、转发,数据都在。

整条链路最后收敛成一条命令:

python3 tools/upload_workout_video.py latest /path/to/videos --reencode --scale 1080 --burn-hud

latest 会自动解析为最新一条运动记录。现在我生成视频的方式是:在微信里告诉 AI agent 视频在哪个目录,它在服务器上执行这条命令,解析 RQ 数据、渲染 HUD、烧录转码、加密切片、上传 OSS、提交密钥,一条龙全自动——几分钟后博客的运动详情页里就能看了。

整个实现其实就两个脚本:一个 Python 渲染帧,一个 FFmpeg 转码上传。花费最多的是样式打磨——字体选了三轮,对照参考图一个像素一个像素调位置。技术本身不复杂,但把”能看”做到”好看”,才是这类小工具最耗时间的地方。

如果你也有运动相机 + 运动数据,这套方案直接可用:数据源换成你的 GPX/FIT 解析结果即可,渲染和转码部分完全通用。

文章作者:阿文
文章链接: https://www.awen.me/post/abd73f6.html
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 阿文的博客

评论

0 条评论
😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘 🛑📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭 ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️ 🈯💹❇️ ✳️🌐 💠Ⓜ️🌀 💤🏧🚾 🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️
加载中...

留言反馈

😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘 🛑📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭 ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️ 🈯💹❇️ ✳️🌐 💠Ⓜ️🌀 💤🏧🚾 🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️