深夜提醒

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

🏮 🏮 🏮

新年快乐

祝君万事如意心想事成!

share-image
ESC

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

引言

骑车时录了不少视频,一直想给画面加上运动数据——心率、速度、轨迹图那种 DJI / Garmin Virb 风格的仪表盘 overlay。

最开始做的是网页版方案:HLS 切片 + hls.js 播放,再用 HTML + Canvas 在视频上叠一层实时 HUD。能用,但有几个硬伤:移动端全屏时 HTML 层会被系统播放器吃掉,视频下载/转发后数据也没了,而且不同浏览器对原生 HLS 的支持参差不齐(新版 Chrome 自称支持 HLS,但对 AES-128 加密切片的行为并不一致)。

干脆换个思路:把数据直接烧录进视频画面。数据成为画面本身,全屏、下载、转发、任何播放器都带着。这篇文章记录完整实现过程。

为什么不用 DJI Studio

先说结论:DJI Studio / Mimo 这类官方 App 的仪表盘功能,只能用 DJI 自家的数据源(相机 GPS、手机),第三方手表的数据接不进去。

我的实际情况更尴尬一点:心率在华为手表里,而华为运动健康导出的 GPX / FIT 文件心率是脱敏的——两个格式都试过,轨迹在、心率没了。但同一份数据同步到 RQ Run 之后心率是完整的,而 RQ 的数据我早就接入了自建的运动数据服务端(博客的运动详情页就是用它渲染的)。

所以数据源其实现成:服务端的 trkpt 轨迹点,心率、速度、海拔、步频、里程、经纬度全有。缺的不是数据,而是一个”一句话就自动生成”的流水线——我最终的用法是:在微信里告诉 AI agent 视频在哪个目录,它直接在 Linux 服务器上跑脚本,解析数据、烧录、切片、加密、上传,全自动。DJI Studio 给不了这个。

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

整体方案

运动记录 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

几个关键点:

  • -framerate 1:HUD PNG 按 1fps 读入,每张图正好覆盖一秒
  • scale2ref:把 1920x1080 的 HUD 帧缩放到和主画面一致再叠加,主画面缩到任何分辨率 HUD 都不会错位
  • 多段视频的时间轴:两段视频共用一个全局 HUD 序列,第二段用 -start_number 1273 从全局第 1273 秒开始读
  • 必须重编码:烧录天然要求重编码。顺带解决了一个老问题——DJI 原片是 HEVC(H.265),Chrome/Edge 的 MSE 无法软解 TS 里的 HEVC,转 H.264 后全平台可播
  • -map 0:v:0 -map 0:a:0:DJI 的 MP4 里有私有数据流和缩略图流,不剔除会导致 TS 封装失败

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

留言反馈

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