使用百度地图路书为骑行视频添加同步轨迹

问题背景

使用 gopro 记录骑行过程 (参考《使用二手 gopro 做行车记录仪 》),事后将视频文件导出来回顾整个旅程,会发现将它们与地图对应起来是一件困难的事。想要视频和地图对应,首先需要上报每个时刻的位置,gopro 本身是支持的,然而要到版本 5 才可以,我的 3+ 太老了没这能力。为此我配备了专门的 GPS 定位器来记录骑行轨迹 (e.g. 途强定位),在官网上是可以看到整个骑行轨迹,像下面这样:

这个界面也可以回放轨迹,回放速度还能调整:

不过即使调到最慢,速度相对视频还是快,更最要命的是,这个回放看起来并不参考 GPS 时间,仅仅是按顺序播放。举例来说,间隔 10 秒的两个点和间隔 10 分钟的两个点,播放时没有差别,都是相同的速度播放。

所幸 GPS 轨迹数据是可以导出的:

如果能用导出的轨迹数据,根据 GPS 时间精准的制作骑行轨迹,是不是就能和视频同步了?抱着这个想法,有了下面的探索过程。

可行性研究

DashWare

根据 GPS 数据制作骑行轨迹并不算一个新鲜需求,早期玩极限运动的各位先驱早已经探索的明明白白,也有专业的软件支持这种需求,DashWare 就是其中的佼佼者。之前看到 B 站上一个旅游区 UP,他就介绍过一种基于 DashWare 给国外徒步旅行的游记视频加轨迹路线的方法 (参考附录 15),整个过程可以总结为三步:

  • 抽取视频的 GPX 信息
  • 编辑 GPX 然后快速跳转地图截图
  • 用 Dashware 套用作者的模板生成轨迹路径

这个过程严重依赖视频记录的 GPX 信息,而我的硬件设备不支持,放弃。如果你的设备可以支持,其实用 DashWare 还是蛮方便的。

晒一下我自己配的 GPS 定位器:

这种硬件不太方便的地方是需要单独供电并插流量卡,后者只保两年,两年以后需要自己续费或买流量卡应付。GPS 数据量不大,据客服说一直不停上报大约需要 22M/月,我骑的少 3M 就够用,最后在某宝上配的 5M/月 的移动流量卡大约 8.7 元,给各位做个参考。流量查询和续费可以通过公众号进行:

百度路书

根据 GPS 数据绘制轨迹,其实国内各大地图服务商都提供了解决方案,偶然的一个机会看到使用百度地图的路书可以方便快捷的制作行驶轨迹 (参考附录 1),最终效果和我的场景非常相似:

源码不过寥寥一百行,其中关键的就是下面这几十行:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>百度地图显示车辆运行轨迹(静态)</title>
        <script type="text/javascript" src="http://api.map.baidu.com/api?v=2.0&ak=你在百度地图开放平台申请的ak"></script>
        <!--  你在百度地图开放平台申请的ak -->
        
        <!-- 路书功能 -->
        <script type="text/javascript" src="http://api.map.baidu.com/library/LuShu/1.2/src/LuShu_min.js"></script>
    </head>
    <body>
        <div id="allmap" style="position: absolute; width: 100%; top: 0px; bottom: 0px"></div>
        <script type="text/javascript">
...
        //显示车辆轨迹线
        //车辆轨迹坐标
        var latLngArray = [
            "113.408984,23.174023",
            "113.406639,23.174023",
            "113.403944,23.173566",
            "113.400827,23.17394",
            "113.397468,23.174496",
            "113.391494,23.174513",
            "113.389032,23.174588",
            "113.388736,23.173217",
            "113.388511,23.171888",
            "113.388592,23.170501",
            "113.38861,23.170219",
            "113.38861,23.168342",
            "113.388574,23.165218"
        ];
...
        var pois = [];
        for(var i = 0; i < latLngArray.length ; i++) {
            var latLng = latLngArray[i];
            var pointArray = latLng.split(",");
            pois.push(new BMap.Point(pointArray[0], pointArray[1]));
        }
...
        var polyline = new BMap.Polyline(pois, {
            enableEditing: false,//是否启用线编辑,默认为false
            enableClicking: true,//是否响应点击事件,默认为true
            icons: [icons],
            strokeWeight: '8',//折线的宽度,以像素为单位
            strokeOpacity: 0.8,//折线的透明度,取值范围0 - 1
            strokeColor: "#18a45b" //折线颜色
        });
        map.addOverlay(polyline);
...
        var lushu = new BMapLib.LuShu(map,pois,{
            defaultContent: trackContent,
            autoView:false,//是否开启自动视野调整,如果开启那么路书在运动过程中会根据视野自动调整
            icon: icon_gps_car_run,
            speed: 100,
            enableRotation:true,//是否设置marker随着道路的走向进行旋转
        });
        lushu.start(); 
        </script>
    </body>    
</html>

梳理一下其中的关键点:

  • html header 中声明百度地图的 ak (申请方式原作者有说明)
  • body 中创建 GPS 坐标数组 (latLngArray) 并转化为路书能接受的格式 (pois)
  • 创建轨迹总览线 (polyline)
  • 制作小车移动轨迹 (lushu.start)

可以看到核心功能其实都是通过百度地图 js 类 BMapLib.LuShu 实现的,下面好好研究一下它的接口。

BMapLib.LuShu

这个类的官方文档可参考附录 16,提供的方法主要如下:

  • constructor
  • start
  • stop
  • pause

非常简洁。其中 start / stop / pause 都不再提供参数,所以想进行更复杂的控制只能事先在构造函数中指定了:

BMapLib.LuShu(map, path, opts)
LuShu类的构造函数

参考示例:
var lushu = new BMapLib.LuShu(map,arrPois,{defaultContent:"从北京到天津",landmarkPois:[]});

参数:
{Map} map
    Baidu map的实例对象.
{Array} path
    构成路线的point的数组.
{Json Object} opts
    可选的输入参数,非必填项。可输入选项包括:
    {
    "landmarkPois" : {Array} 要在覆盖物移动过程中,显示的特殊点。格式如下:landmarkPois:[
    {lng:116.314782,lat:39.913508,html:'加油站',pauseTime:2},
    {lng:116.315391,lat:39.964429,html:'高速公路收费站,pauseTime:3}]

    "icon" : {Icon} 覆盖物的icon,
    "speed" : {Number} 覆盖物移动速度,单位米/秒

    "defaultContent" : {String} 覆盖物中的内容
    "autoView" : {Boolean} 是否自动调整路线视野,默认不调整
    "enableRotation" : {Boolean} 是否开启marker随路走向旋转,默认为false,即不随路走向旋转
    }

除 map、path 是必需参数外,其它均为可选参数。下面对各个选项做个简单说明:

  • speed:用于控制小车移动的速度,单位是 m/s,示例中给的值是 100,相当于 360 km/h,那是相当快了,如果按 72 km/h 算的话,才 20 m/s
  • autoView:随着小车的移动,自动调整地图位置,以保证小车位于视野之内,一般是在小车走出视野边缘后进行调整。推荐打开,除非是鹰眼视图
  • icon:小车的图形,可以指定本地文件
  • defaultContent:对轨迹的文字说明,跟随在小车左右
  • enableRotation:是否旋转小车图形以对准前进方向。推荐打开,以获取更好的演示效果
  • landmarkPois:设置的途经点数组,及在途经点的经停时间,单位为秒,这个选项在示例中未使用

梳理了一遍 LuShu 的功能,发现即使能将小车移动速度调整到与实际平均速度一致,地图与视频仍然对不上。原因与之前一样,LuShu 中根本没有输入 GPS 时间参数的地方,所以它完全没有坐标点与时间对照的概念,所有坐标之间的时间间隔都是一致的,唯一可调节的地方就是 speed 参数,它用来控制这个间隔的大小。

回过头来看途强在线的界面,基本可以确认,这也是基于 LuShu 改的,所以它们的问题是相通的。

定时器

现在问题的关键就变成如何等待真实的时间间隔。一开始想手动 pause 和 start,写了个定时器来做这个事情:

...
        var lushu = new BMapLib.LuShu(map,pois,{
            defaultContent: trackContent,
            autoView:false,//是否开启自动视野调整,如果开启那么路书在运动过程中会根据视野自动调整
            icon: icon_gps_car_run,
            speed: 100,
            enableRotation:true,//是否设置marker随着道路的走向进行旋转
        });
        lushu.start();

        let is_pause=false
        setInterval(function(){
            if (is_pause) {
                lushu.start(); 
            } else { 
                lushu.pause(); 
            }
            is_pause = !is_pause; 
        }, 1000);   
 ...

先简单的设置成每秒一次,后面可以根据实际的时间差来控制等待时间。运行时发现,第一次定时器到期后小车暂停,然后就没有然后了…小车再也没有启动过。在定时器函数中加了一些日志进一步观察:

interval false, count 1
after true
interval true, count 2
interval true, count 3
interval true, count 4
interval true, count 5
interval true, count 6
interval true, count 7
interval true, count 8
interval true, count 9
interval true, count 10
interval true, count 11

发现函数结尾处只被调用了一次 (after true),之后就再也没打印,且 is_pause 的值一直为 true,可以确认反转 is_pause 值的代码没有被执行。

为了确认是否是变量作用域的问题,增加了一个全局变量 count,每次在函数入口处自增并打印它的值,可以看到能正常递增,排除 js 变量作用域的问题。

经过这番折腾,基本可以确认问题是出在了 start 接口,看现象再次调用它貌似没有返回,怀疑这个接口是不能重入的,或者就不能这样用,定时器方案走不下去了,放弃。

landmarkPois

正所谓“踏破铁鞋无觅处,柳暗花明又一村” 😌,之前看 LuShu 构造函数时,有个 landmarkPois 参数里有经停时间,这个和我的场景非常吻合,能不能拿来用呢?仔细的研究了一下这个 landmarkPois,它的本意是提供 speed 参数控制外的时间间隔处理,例如在服务区休息、加油站加油等等,去掉这些耗时较大的坐标点位,剩余的轨迹就能比较真实的反映实际的行走过程。

我的想法比较简单粗暴,直接将每个 GPS 坐标作为一个经停点放在这个参数里,这样在每个 GPS 上报点都能停留相应的时间间隔,是不是就能和视频同步起来了?不过这个参数的本意是放少量数据点,像我这种放大量数据进去会不会有什么副作用,还得试一试才知道。说干就干,用之前的 demo 验证下:

...
        var landmarks = [
            {lng:113.408984,lat:23.174023,html:'1',pauseTime:1},
            {lng:113.406639,lat:23.174023,html:'1',pauseTime:1},
            {lng:113.403944,lat:23.173566,html:'1',pauseTime:1},
            {lng:113.400827,lat:23.17394,html:'1',pauseTime:1},
            {lng:113.397468,lat:23.174496,html:'1',pauseTime:1},
            {lng:113.391494,lat:23.174513,html:'1',pauseTime:1},
            {lng:113.389032,lat:23.174588,html:'1',pauseTime:1},
            {lng:113.388736,lat:23.173217,html:'1',pauseTime:1},
            {lng:113.388511,lat:23.171888,html:'1',pauseTime:1},
            {lng:113.388592,lat:23.170501,html:'1',pauseTime:1},
            {lng:113.38861,lat:23.170219,html:'1',pauseTime:1},
            {lng:113.38861,lat:23.168342,html:'1',pauseTime:1},
            {lng:113.388574,lat:23.165218,html:'1',pauseTime:1}
        ];
...
        var lushu = new BMapLib.LuShu(map,pois,{
            defaultContent: trackContent,
            autoView:false,//是否开启自动视野调整,如果开启那么路书在运动过程中会根据视野自动调整
            icon: icon_gps_car_run,
            speed: 100,
            enableRotation:true,//是否设置marker随着道路的走向进行旋转
            landmarkPois: landmarks
            });
        lushu.start();
...

landmarks 变量使用的坐标与 pois 完全一样,经停时间全部设置为 1 秒,显示内容也为 1 (html 字段)。下面是运行效果:

能行!虽然小车移动没之前那么平滑了,但为了能和视频同步上,这点儿瑕疵可以接受!

研究了下 landmarkPois 与 speed 之间的关系:当 speed 值不是特别大时,小车在两个相邻经停点几乎是瞬移过去的,没有动画,即使将 speed 设置的非常小也是如此,例如 0;当 speed 值特别大时,小车不移动 (> 10W)、或者遗漏部分经停点 (> 2W),最终我设置的 speed 值是 1000,这样可以保证小车移动,同时也避免了如果 LuShu 在经停点之间也有动画时浪费时间的问题。关于 speed 设置太大导致部分经停点遗漏的问题,后面还会详细说明。

解决方案

虽然有了技术路线,但每次制作轨迹视频,都需要把原始数据导入 html,特别是经停点还要根据相邻两点的 GPS 时间计算一个经停时间 (e.g. 10:00:58 到 10:01:05 相隔 7 秒),当骑行距离比较远时,工作量就比较大了。于是想到可以将上面的过程做成一个工具,它一键导入 GPS 数据生成轨迹动画;如果再辅以 mac 上的截图工具,就可以直接出轨迹视频了;而后这个视频可以作为小窗导入骑行视频,从而得到同步的地图轨迹展示,效果就类似下面这样:

csv

正式开始前,先了解下“途强在线”导出的 GPS 数据格式:

序号,定位时间,接收时间,经度/纬度,速度(km/h),方向,定位类型,上报模式,位置
...
267,2022-11-19 11:24:20,2022-11-19 11:24:37,116.092747/40.103613,26km/h,西南向(方向数: 219),卫星定位,拐点上传,"北京市海淀区龙泉寺北路,凤凰岭自然风景区东南1228米"
268,2022-11-19 11:24:22,2022-11-19 11:24:37,116.092693/40.103498,26km/h,正南向(方向数:173),卫星定位,拐点上传,"北京市海淀区凤凰岭路,凤凰岭自然风景区东南1226米"
269,2022-11-19 11:24:25,2022-11-19 11:24:37,116.0928/40.103289,32km/h,东南向(方向数:151),卫星定位,拐点上传,"北京市海淀区凤凰岭路,凤凰岭自然风景区东南1240米"
270,2022-11-19 11:24:29,2022-11-19 11:24:37,116.092933/40.103062,17km/h,正南向(方向数:190),卫星定位,拐点上传,"北京市海淀区凤凰岭路,凤凰岭自然风景区东南1257米"
271,2022-11-19 11:25:19,2022-11-19 11:25:42,116.092747/40.10296,0km/h,正西向(方向数:252),卫星定位,从运动变静止状态补传最后一个有效定位点,"北京市海淀区凤凰岭路,凤凰岭自然风景区东南1244米"
272,2022-11-19 11:25:39,2022-11-19 11:25:42,116.091991/40.102782,21km/h,正西向(方向数:255),卫星定位,定时上报,"北京市海淀区凤凰岭路,凤凰岭自然风景区东南1188米"
273,2022-11-19 11:25:49,2022-11-19 11:25:50,116.09144/40.102653,16km/h,正西向(方向数:256),卫星定位,定时上报,"北京市海淀区凤凰岭路,凤凰岭自然风景区东南1147米"
274,2022-11-19 11:25:59,2022-11-19 11:26:05,116.091164/40.10256,7km/h,西南向(方向数: 215),卫星定位,定时上报,"北京市海淀区凤凰岭路,凤凰岭自然风景区东南1128米"
275,2022-11-19 11:26:23,2022-11-19 11:26:24,116.091236/40.102373,15km/h,西北向(方向数:93),卫星定位,拐点上传,"北京市海淀区凤凰岭路,凤凰岭自然风景区东南1141米"
276,2022-11-19 11:26:26,2022-11-19 11:26:27,116.091422/40.1024,18km/h,西北向(方向数:71),卫星定位,拐点上传,"北京市海淀区凤凰岭路,凤凰岭自然风景区东南1155米"
277,2022-11-19 11:27:23,2022-11-19 11:27:26,116.091698/40.102489,0km/h,西北向(方向数:68),卫星定位,从运动变静止状态补传最后一个有效定位点,"北京市海淀区凤凰岭路,凤凰岭自然风景区东南1174米"

标题行已经对各个列做了说明,这里用到的只有时间、坐标、速度三个列,其中

  • 时间取的是定位时间,因为接收时间会有明显延迟,不能反映真实的"位置-时间"对应关系
  • GPS 坐标在这里是一列 (116.092747/40.103613),后面需要拆分为独立的经纬度 (116.092747, 40.103613)
  • 速度用于设置 landmarkPois 的 html 字段,显示在小车左近,用于展示瞬时 GPS 速度

gps

最终不论是 landmarks 变量还是 latLngArray 变量,都需要坐标数据,所以首先需要将 csv 中的 GPS 坐标提取出来,csv2gps.sh 就是用来做这个的:

...
while :
do
    read -r line
    if [ $? -ne 0 -a -z "${line}" ]; then
        # last line without LF will trigger read return error
        # so here check content of 'line', too
        break;
    fi

    if [ $n -ne 0 ]; then
        # skip csv header
        echo "${line}" | awk -F',' '{print $4}' | awk -F'/' '{print $1,$2}'
    fi

    n=$((n+1))
done < "${file}"

通过 awk 提取 csv 第四列并将其拆分为两列保存在新文件 data.gps 中:

...
116.092747 40.103613
116.092693 40.103498
116.0928 40.103289
116.092933 40.103062
116.092747 40.10296
116.091991 40.102782
116.09144 40.102653
116.091164 40.10256
116.091236 40.102373
116.091422 40.1024
116.091698 40.102489

坐标转换

直接用 gps 坐标在百度地图上绘制,会发现和真实的位置偏差了一些:

经过一番研究,发现原来每个地图服务商都有自己的大地坐标系,例如百度使用的是百度坐标系 BD09,高德、QQ、谷歌使用的是火星坐标系 GCJ02,而 GPS 数据一般是地球坐标系 WGS84。因此需要将 GPS 坐标转换后才能在百度地图中展示,网上有批量转换的工具 (参考附录 11):

取第一个点查看转换结果:

发现还是有明显差别的。经过转换的坐标和实际位置就差不多了:

在线工具虽然好用,但一不能自动执行,二批量转换还要收取会员费,幸好百度地图提供了坐标转换的接口 (参考附录 7):

https://api.map.baidu.com/geoconv/v1/?coords=114.21892734521,29.575429778924&from=1&to=5&ak=你的密钥 //GET请求

其中多个坐标以分号 (;) 分隔,最多一次可以请求 100 个。返回的内容为 json,形式如下:

{
  "status": 0,
  "result": [
    {
      "x": 116.28841472033484,
      "y": 40.21235883074968
    }
  ]
}

result 是个数组,当有多个请求坐标时,将返回多个转换后的坐标。

有了接口,就可以用脚本 (gps2bd.sh) 自动转换啦,下面先上单个转换的版本:

...
while :
do
    read -r line
    if [ $? -ne 0 -a -z "${line}" ]; then 
        # last line without LF will trigger read return error
        # so here check content of 'line', too
        break; 
    fi

    # delete spaces
    # line="${line// /}"
    # replace space to ','
    line="${line/ /,}"
    # echo "${line}"
    resp=$(curl -gs "https://api.map.baidu.com/geoconv/v1/?coords=${line}&from=1&to=5&ak=${BAIDU_MAP_AK}")
    # echo "${resp}"
    if [ ! -z "${resp}" ]; then 
        x=$(echo "${resp}" | jq '.result[0].x')
        y=$(echo "${resp}" | jq '.result[0].y')
        echo "\"$x,$y\","
    fi
done < "${file}"

简洁明了但是性能低,数据量大的时候,足足能等一颗烟的功夫,下面上批量版本:

while [ ${end} -eq 0 ];
do
    while [ $n -lt 100 ];
    do
        read -r line
        if [ $? -ne 0 -a -z "${line}" ]; then
            # last line without LF will trigger read return error
            # so here check content of 'line', too
            end=1
            break;
        fi

        # delete spaces
        # line="${line// /}"
        # replace space to ','
        line="${line/ /,}"
        # echo "${line}"
        if [ $n -eq 0 ]; then
            data="${line}"
        else
            data="${data};${line}"
        fi

        n=$((n+1))
    done

    if [ $n -gt 0 ]; then
        resp=$(curl -gs "https://api.map.baidu.com/geoconv/v1/?coords=${data}&from=1&to=5&ak=${BAIDU_MAP_AK}")
        # echo "$n: ${resp}"
        if [ ${IS_MAC} -eq 0 ]; then
            echo "${resp}" | jq -r '.result[]|.x,.y' | sed -n '{N;s/\n/ /p}' | awk '{printf "\"%s,%s\",\n",$1,$2}'
        else
            echo "${resp}" | jq -r '.result[]|.x,.y' | gsed -n '{N;s/\n/ /p}' | awk '{printf "\"%s,%s\",\n",$1,$2}'
        fi
    fi

    n=0
done < "${file}"

为了提升效率,每 100 个点请求一次,注意解析响应时的技巧,jq 会将坐标 x、y 分为两行,sed/gsed 将它们合并成一行,awk 再次将它们改造为后面 js 能接受的形式:

...
"116.10568842325927,40.11056701573591",
"116.10579500660829,40.11035775778459",
"116.10592816947326,40.11013058608767",
"116.10574200140061,40.11002872275517",
"116.10498588059896,40.10985140314029",
"116.104434547854,40.10972328042703",
"116.10415815632084,40.1096308424909",
"116.10423031248894,40.109443479731034",
"116.10441655222886,40.109470198714",
"116.10469264841298,40.109558635595626",

这种形式和之前 latLngArray 的内容很像,就是为了便于直接插入到最终的 html 中。

计算等待时间

到目前为止,我只处理了坐标数据,GPS 时间及相邻点的时间差还没有计算,这是 landmarks 中 pauseTime 字段所需要的,这一步来搞定它 (bd2land.sh):

while :
do
    read -r line
    if [ $? -ne 0 -a -z "${line}" ]; then 
        # last line without LF will trigger read return error
        # so here check content of 'line', too
        break; 
    fi

    if [ $n -ne 0 ]; then
        # skip csv header
        time=$(echo "${line}" | awk -F',' '{print $2}')
        if [ ${IS_MAC} -eq 1 ]; then 
            timestamp=$(date -j -f "%Y-%m-%d %H:%M:%S" "${time}" "+%s")
        else
            timestamp=$(date -d "${time}" "+%s")
        fi
    
        # read coodinate from data.bd instead of data.csv to prevent data incorrect
        #data=$(echo "${line}" | awk -F',' '{print $4}' | awk -F'/' '{print "lng:",$1,",lat:",$2}')
        # note, sed index is 1 based, and csv file have a header take line 0, so it just match..
        data=$(sed -n "${n}p" "${bdfile}")
        x=$(echo "${data}" | awk -F',|"' '{print $2}')
        y=$(echo "${data}" | awk -F',|"' '{print $3}')
        # echo "data:${data},x:$x,y:$y"
        label=$(echo "${line}" | awk -F',' '{print $5}')
    
        if [ $n -eq 1 ]; then 
            # insert a 10 second stay for first record
            echo "{lng:$x,lat:$y,html:'${label}',pauseTime:10},"
        else
            # compute elapse from second record
            elapse=$((timestamp - prevstamp))
            echo "{lng:$x,lat:$y,html:'${label}',pauseTime:${elapse}},"
        fi
    
        prevstamp=${timestamp}
    fi

    n=$((n+1))
done < "${csvfile}"

纯粹的 shell,整合了以下命令

  • date:获取 GPS 时间对应的 epoch 时间戳值,这样两个相邻时间相减就可以得到时间间隔了
  • sed:获取 csv 文件第 n 条记录对应的 bd 文件中的数据
  • awk:获取 bd 文件中的 x、y 值;获取 csv 文件中的速度信息作为经停点标签

需要注意的是 date 命令在 mac 和 linux 上不同的语法,这方面信息可参考之前的文章:《使用 shell 脚本自动获取发版指标数据》、《使用 shell 脚本自动申请进京证 (六环外)》、《[apue] 一图读懂 Unix 时间日期例程相互关系》。

经过计算输出的 land 文件大概长这样:

...
{lng:116.10568842325927,lat:40.11056701573591,html:'26km/h',pauseTime:2},
{lng:116.10579500660829,lat:40.11035775778459,html:'32km/h',pauseTime:3},
{lng:116.10592816947326,lat:40.11013058608767,html:'17km/h',pauseTime:4},
{lng:116.10574200140061,lat:40.11002872275517,html:'0km/h',pauseTime:50},
{lng:116.10498588059896,lat:40.10985140314029,html:'21km/h',pauseTime:20},
{lng:116.104434547854,lat:40.10972328042703,html:'16km/h',pauseTime:10},
{lng:116.10415815632084,lat:40.1096308424909,html:'7km/h',pauseTime:10},
{lng:116.10423031248894,lat:40.109443479731034,html:'15km/h',pauseTime:24},
{lng:116.10441655222886,lat:40.109470198714,html:'18km/h',pauseTime:3},
{lng:116.10469264841298,lat:40.109558635595626,html:'0km/h',pauseTime:57},

看起来已经非常适合作为元素插入 landmarks 变量声明中了,哈哈。

揉合在一起

在浏览最终的 facade 脚本之前,先看看对 html 做的手脚:

...

var latLngArray = [
    //BD_DATA_PLACETAKER
]; 

var pois = [];
for(var i = 0; i < latLngArray.length ; i++) {
    var latLng = latLngArray[i];
    var pointArray = latLng.split(",");
    pois.push(new BMap.Point(pointArray[0], pointArray[1]));
}

carCenterPoint = pois[0]; 
console.log("center:"+carCenterPoint.lng+","+carCenterPoint.lat); 

var landmarks = [
    //LAND_DATA_PLACETAKER
];
...

latLngArray 留空并设置了插入标识 BD_DATA_PLACETAKER,这里将是 bd 文件插入的位置;landmarks 留空并设置了插入标识 LAND_DATA_PLACETAKER,这里将是 land 文件插入的位置。

start.sh 将一切揉合起来:

#! /bin/sh

if [ ! -f data.csv ]; then 
    echo "please put gps data into 'data.csv' first!"
    exit 1
fi

if [ ! -f data.gps ]; then 
    echo "start generate file: data.gps, extract gps from csv data"
    sh csv2gps.sh data.csv > data.gps
    # data.bd need re-generate
    rm data.bd
else 
    echo "use file: data.gps"
fi 

if [ ! -f data.bd ]; then 
    echo "start generate file: data.bd, convert gps to baidu coordinate"
    sh gps2bd.sh data.gps > data.bd
    # index.data.html need re-generate
    rm data.land
else 
    echo "use file: data.bd"
fi

if [ ! -f data.land ]; then 
    echo "start generate file: data.land, extract land and time from csv & baidu coordinate"
    sh bd2land.sh data.csv data.bd > data.land
    # final file need re-generate
    rm index.data.html
else 
    echo "use file: data.land"
fi 

if [ ! -f index.data.html ]; then 
    echo "start generate file: index.data.html, the final file"
    sed '/BD_DATA_PLACETAKER/ r data.bd' index.html | sed '/LAND_DATA_PLACETAKER/ r data.land' > index.data.html
else 
    echo "use file: index.data.html"
fi

open index.data.html

输入 data.csv 文件,依次生成以下文件:

  • data.gps          <= data.csv
  • data.bd           <= data.gps
  • data.land         <= data.bd & data.csv
  • index.data.html <= index.html & data.bd & data.land

最后打开 index.data.html 展示轨迹动画。下面是一次完整的生成过程:

> sh start.sh
start generate file: data.gps, extract gps from csv data
start generate file: data.bd, convert gps to baidu coordinate
start generate file: data.land, extract land and time from csv & baidu coordinate
start generate file: index.data.html, the final file

每一步都会判断目标文件是否已经存在,存在则跳过,避免重复生成;如果目标是新生成的,删除依赖它的文件以保证下个流程能更新数据。

录屏

mac 自带的截屏就非常好用:

默认生成 mov 格式的录屏文件。其它任何商业录屏软件都可以,例如 Kap,它除了可以生成 gif (本文中的 gif 就是出自它手),还能生成 mp4,压缩比比 mov 还高。不过目测这个工具也只有 mac 版的,windows 和 linux 用不了。

生成好的轨迹视频最终要和第一视角的视频同步,还需要在视频制作软件中做对齐,只要找到那么几个参考点 (e.g. 标志建筑、弯道、交叉路口...),来回调几次,基本就差不多啦!视频编辑软件众多,这里就不做推荐了,因为我一般发 B 站,所以用"必剪"多一点,PC 版比较适合我这种视频比较大的场景。

结语

本文探索了一种与视频同步的轨迹展示方案,所有代码均可以在下面的 github 仓库找到:

https://github.com/goodpaperman/roadbook

会不定时更新,欢迎关注以获取最新 patch 哟~

 

下面是用这个工具制作的一个样例:

(点击图片观看完整视频)

说了这么多 LuShu 的好处,它有没有不足的地方呢?有!没有指南针插件算一个:

打开百度地图,在放大缩小按钮上面,有个改变方向的指南针,可以指定非北方向上。这在制作长直骑行路线时特别有用,可以将南北方向形成的纵向骑行路线改为横向,更加有利于将导航视频铺在骑行视频底部。希望 LuShu 以后可以支持。

后记

一开始我将 speed 设置的尽量大——当然不能超过上限 (10W),否则小车压根不移动——这样做是出于一种担忧,考虑一下一个长度 10km 的骑行过程,如果设置 speed 为 100 (m/s),则整个视频播下来,浪费在中间动画的时间将是 10km / 100 = 100s,对于轨迹与视频同步来说,那是一个相当大的延迟了,视频播到尾部,轨迹会慢上视频足足一百秒,这是不可接受的。因此就想着尽量通过提高 speed 值来减少这种误差,例如设置 speed 为 10000,累计延迟将减少到 1s。

然而实际运行过程中,发现 speed 变大后,生成的轨迹视频不仅不延迟了,还比原视频更快了:视频还没播完,轨迹就已经到终点了,真是咄咄怪事。当时还不知道 LuShu 有遗漏经停点这一说,只能想办法对比轨迹动画与 land 文件内容,看每次经停的标签和文件中的记录是否对得上。这种做法非常考验眼力,对经停时间也不能准确判断,例如记录中是经停 10s,动画是否真的经停了 10s 只能判断个大概,还是存在实际经停时间小于文件记录的可能性,最麻烦的是前后两次车速一样的情况下,只根据标签判断就很容易漏掉记录。

为此,专门制作了一个模拟经停过程的小工具,用来 debug 上述问题简直就是神器,废话不多说,直接上图看效果:

底部是工具输出,顶部是动图输出,两相比照,看起来非常直观。下面是调大速度后遗漏经停点的现象:

具体什么原因,没看过 LuShu 源码不得而知,不过这里给一个合理的推断:LuShu 坐标计算在速度比较大的时候,计算的累计误差增大,导致坐标相等判断失效,从而错过经停点。

下面来看下模拟工具的实现,其实 land 文件中已经提供了经停时间和标签,将它们抽取出来就是工具很好的食材了 (land2wait.sh):

awk -F",|:|}" '{print $8,$6}' "${landfile}"

简约而不简单,回顾下 land 文件内容:

...
{lng:116.10568842325927,lat:40.11056701573591,html:'26km/h',pauseTime:2},
{lng:116.10579500660829,lat:40.11035775778459,html:'32km/h',pauseTime:3},
{lng:116.10592816947326,lat:40.11013058608767,html:'17km/h',pauseTime:4},
{lng:116.10574200140061,lat:40.11002872275517,html:'0km/h',pauseTime:50},
{lng:116.10498588059896,lat:40.10985140314029,html:'21km/h',pauseTime:20},
{lng:116.104434547854,lat:40.10972328042703,html:'16km/h',pauseTime:10},
{lng:116.10415815632084,lat:40.1096308424909,html:'7km/h',pauseTime:10},
{lng:116.10423031248894,lat:40.109443479731034,html:'15km/h',pauseTime:24},
{lng:116.10441655222886,lat:40.109470198714,html:'18km/h',pauseTime:3},
{lng:116.10469264841298,lat:40.109558635595626,html:'0km/h',pauseTime:57},

同时使用 ',' / ':' / '}' 分割后,标签位于 $6,经停时间位于 $8,将其提取出来就形成了 wait 文件:

...
2 '26km/h'
3 '32km/h'
4 '17km/h'
50 '0km/h'
20 '21km/h'
10 '16km/h'
10 '7km/h'
24 '15km/h'
3 '18km/h'
57 '0km/h'

再用 wait.sh 提取等待时间和标签,循环 sleep 即可:

while :
do
    read -r line
    if [ $? -ne 0 -a -z "${line}" ]; then
        break;
    fi

    sec=$(echo "${line}" | awk '{print $1}')
    echo "sleep ${line}"
    sleep ${sec}
done < "${waitfile}"

最后是驱动脚本 simulate.sh:

#! /bin/sh

if [ ! -f data.wait ]; then
    sh land2wait.sh data.land > data.wait
else
    echo "use data.wait"
fi

sh wait.sh data.wait
echo "simulate done!"

这里引入了一个新的依赖:

  • data.wait       => data.land

所以在 data.land 发生变化时,start.sh 也会删除 data.wait 文件以便下次运行 simulate.sh 可以更新数据:

...
if [ ! -f data.land ]; then
    echo "start generate file: data.land, extract land and time from csv & baidu coordinate"
    sh bd2land.sh data.csv data.bd > data.land
    # final file need re-generate
    rm index.data.html
    rm data.wait
else
    echo "use file: data.land"
fi
...

参考

[1]. 百度地图显示车辆运行轨迹(动态轨迹回放功能)

[2]. 百度地图开放平台

[3]. 路书 JavaScript 参考

[4]. linux shell sed 在一个文件中插入另一个文件

[5]. JS文件处理—读取本地文件(必须通过input控件才能实现) 及 下载文件

[6]. 如何在 JavaScript 中读取 JSON 文件

[7]. 百度地图 API - 坐标转换服务

[8]. 百度地图 JavaScript API v2.0类参考 

[9]. 如何用javascript将.txt文件的内容作为数组读取?

[10]. curl: (3) [globbing] bad range specification in column XXX

[11]. 如何批量实现地图坐标系转换

[12]. Linux shell字符串截取、替换、删除以及trim

[13]. 百度地图 Web 服务 API

[14]. Shell 时间与时间戳相换(Mac)

[15]. GoPro游记视频加上地图导航HUD教程——怎么玩转GPS

[16]. BMapLib.LuShu

[17]. JS定时器:setTimeout和setInterval

[18]. 关于百度地图坐标转换接口的研究

[19]. 百度路书实现轨迹回放(标准)

 

posted @ 2023-07-25 14:37  goodcitizen  阅读(1421)  评论(8编辑  收藏  举报