用代码绘制道路编号标志

起因

如果你看行车记录 POV 视频的话,很容易看到这类视频都会把行经的公路编号名称,以路牌的形式贴在封面和视频内容中。

公路 POV 视频,很多都会把路牌贴在封面上

我的话,往往会做一块道路指示牌,把沿途的道路和城市写上去:

我往期的 POV 封面

最近有人推荐我在视频里面添加道路信息之类的集成信息。

其实最开始,我做南京绕城公路环线的 POV 视频时,就想添加一个滚动的地图,放在视频下方,这样就只需要靠地图上的信息来指示当前所行道路和沿途地标了。为此,我截了三四十张高德地图上的图,花了好大劲拼接起来。最后发现地图图片太大了(宽高都到达了五位数),我的电脑剪 4K 视频本来就不太流畅,加上那张图就更不行了。

当时想要实现的效果(仅作示意,非实际图像)

于是,我后来放弃了这种思路,转而专门追求画质。关于道路信息,就拿手机记录轨迹,然后生成轨迹动画,嵌入到视频里面。

左上角有轨迹信息的 POV 视频

要不是实现的效果还是不太理想,我早就写文章描述自己怎么做的了。如果设备允许,还是导航录屏比较好一些:地图自动缩放,也有道路信息和方向。除了实现方式不那么优雅,其余的没什么缺点。很可惜,苹果不支持 CarPlay 录屏,想要录屏就必须牺牲车载屏幕的功能。所以我一般都是用安卓的备用机,一次开两个导航。

左上角有地图导航录屏的 POV 视频

当然,换了显卡之后,在电脑能够带动的情况下,还是花些时间加上道路信息比较好。

我先是参照现在的车载 HUD(说是参照,但是最多也只是灵感来源,毕竟车载 HUD 和播放记录视频还是有很大区别的),做了个很潦草的示例图:

参照 HUD 制作的道路信息显示画面

很明显,如果要添加方位和时速(虽然上面的图里面没有)之类的信息,必然要根据轨迹信息自动化生成。其他的信息理论上也可以自动生成,但是考虑到各大地图厂商的 API 调用费用,相对于自己做视频几乎可以忽略不计的收入来说,实在是太贵,现阶段要是需要做的话,还是手工处理吧。

高德开放平台的 API 定价

高德开放平台对个人开发者的日配额很低,不够做动辄几小时的轨迹信息获取

想要提高配额还要再花一倍的钱买配额

但是,就我的个人经验来看,如果一个流程的一部分需要自动化处理,最好将其他部分也尽可能自动化处理。比如说关于上述的信息,我的大致思路如下:

  1. 根据记录轨迹生成的 GPX 文件,计算出每个记录点(我设置为每秒记录一次)的方位角、瞬时速度、已走路程,加上本身记录的经纬度、时间(包括绝对时间和相对于出发时的秒数),转换为 CSV 文件
  2. CSV 文件添加区划、道路、限速、背景音乐等列
  3. 根据修改后的 CSV 文件,生成每一帧,方便后面覆盖在视频上

当然,也可以做一个集成的工具,不用导出再导入,直接在工具里面添加信息。不过,就我这种对网页前端和图形界面算是一窍不通的程度来说,还是目前的想法比较容易实现。

这里面涉及到生成交通标志的部分,尤其是道路编号标志。于是我便想到,如果能自动生成道路编号标志的话,就能大大减少工作量。这也是这篇文章介绍的部分。

当前情况

无论是高速公路还是国道、省道等道路,都有统一的编号标志规范。在 GB 5768.2 中有它们的示例、用法、文字和尺寸要求。

GB 5768.2-2022 对高速公路编号标志的规范

GB 5768.2-2022 关于高速公路编号的制作图例

文字的字体也有规范,英文和数字部分很明显就是 FHWA 标准的字符:

GB 5768.2-2022 对字体的规范

甚至交通运输部给了对应的字体。我也是最近几天才知道的,此前一直用 Roadgeek 字体一遍遍调试。

当然,已经有人制作了绝大多数国道和高速公路的标志,从维基百科上的相关地区 / 高速公路的条目里面就能轻松找到对应图片。如果只是找几个图片的话,直接下载就行了,CC0 协议,没有版权限制,还是 SVG 格式的矢量图。

Wikimedia Commons 里面的一部分高速公路标志

不过,如果要自动化的话,一个一个下载再调用不太现实:总有遗漏的,而且也就高速公路和国道比较全,有人愿意为爱发电做图,像省道等低等级的公路就少有人做了。我要做的话,就要做得尽量能够通用一些。

况且,如果有时间精力的话,我还想做一下其他道路标志的自动化生成——目前我的做法是在 Adobe Illustrator 绘制的,虽然没那么麻烦,但是如果能够只输入几个字符就能生成一个用在封面的道路标牌的话,还是很有吸引力的。以前有一个工具可以生成,应急的话还可以,我用过两三次,但是后来变成了邀请制,我也不喜欢加 QQ 群(大学毕业后我基本上不用 QQ 了,更何况是为了用一个工具就要加群填问卷才能拿到账号),就算了,自己也不是不会做。

Adobe Illustrator 的简称 AI 用了几十年了,但是最近几年,人工智能火起来,直接拿这个简称称呼它也就很怪了。本文如无特殊说明,所述的 AI 均指人工智能。

其实还有一个原因:上述途径得到的编号标志颜色太亮,放在视频里面格格不入。如果可以的话,我希望能够自定义颜色,换个不那么刺眼的颜色,毕竟国标里面没有规定必须使用某个色号,只是给了颜色范围和检验方法——更何况我只是做视频,还是以视频观感为重。

模板制作

为描述方便,以下的步骤并非实际经历,有合并重组。自己实现花费的时间远超描述的这些步骤。

参照国标,在 Adobe Illustrator 里面绘制好示例图像。国道、省道等可以共用一套模板(后期改一下颜色就行),高速的话就要分编号长度,以及是否包含名称,制作出一整套模板:

高速公路模板

图上的模板我改过了:因为国标里面没有写明标志四周圆角的半径,我参照给的图片,测量计算并调整了圆角半径;而且后来我突发奇想,让国家高速的牌子和省级高速的牌子共用一套模板,文字也改成通过代码添加。

对于四位编号的高速公路,我没在国标上面找到相关的尺寸规范(除了整个面板的宽高),就拿示例图片和其他牌子调整了。

将每个图形按画板导出为 SVG 格式,样式改为内联样式。

导出 SVG

手动给导出的 SVG 文件中的各个路径添加 class,以便后面填色使用。比如说,国道、省道、县道、乡道可以共用一套模板,我们可以把背景和前景色分别设置为同一个 class,在生成 SVG 时识别 class 并填相应颜色:

1
2
3
4
5
6
<?xml version="1.0" encoding="UTF-8"?>
<svg ... viewBox="0 0 400 200">
<rect class="background" .../>
<rect class="stroke" .../>
<rect class="banner" .../>
</svg>

模板文件

添加文字

思路简单来说就是:将所需文字导出为 SVG 格式,再将其放在指定的位置,再导出文件。

通过询问 AI,我了解到 Python 包里面,svgpathtools 能够很方便地读取 SVG 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from svgpathtools import svg2paths

paths, attributes = svg2paths('SVG 文件路径')

# paths 类型为 list[svgpathtools.path.Path],记录了 SVG 文件的每个路径
# attributes 类型为 list[dict],记录了 SVG 文件每个路径的参数

path = paths[0]

# svgpathtools.path.Path 实例的一些方法

path.d() # 返回其曲线代码,对应 SVG 文件对应项的 d 参数,如:'M 165.0,0.0 L 835.0,0.0 A 165.0,165.0 0.0 0,1 1000.0,165.0 L 1000.0,835.0 A 165.0,165.0 0.0 0,1 835.0,1000.0 L 165.0,1000.0 A 165.0,165.0 0.0 0,1 0.0,835.0 L 0.0,165.0 A 165.0,165.0 0.0 0,1 165.0,0.0'
# 对路径更改都会改变代码

minx, maxx, miny, maxy = path.bbox() # 返回该路径最小、最大的 x 坐标和 y 坐标,据此能够获得路径的位置和宽高
width = maxx - minx
height = maxy - miny

path.translated(complex(x, y)) # 返回平移后的路径,坐标增加 (x, y)
path.scaled(sx, sy) # 返回缩放后的路径,宽高分别为原路径的 sx、sy 倍;负数为反方向缩放。只定义 sx 时,宽高缩放比例均为 sx
path.rotated(degs, complex(x, y)) # 绕坐标 (x, y) 逆时针旋转 degs°。未定义坐标的话,则绕路径(而非整个图形)的中间位置的那个点旋转,故如果想实现传统意义上的旋转,基本上要定义坐标

但是实际使用过程中,我发现它读取路径是无序的,返回的列表顺序和 SVG 实际图层关系对不上。有人修改了这个 Bug,但不知为何,原作者好久没更新了,代码也迟迟没有合并。可以在这里下载修改后的代码安装。

我在这里使用该工具,主要是为了计算文字排入的区域,转换为合适大小的文字路径:

  1. 根据道路类型选取模板、待填入文字的位置和宽高
  2. 获取每个字的路径(需要注意,获取到的路径宽高、起始坐标会有差异)
  3. 将字符路径等比例缩放到指定高度,假设两端对齐,计算文字之间需要留空白的宽度
  4. 获取到上面的信息后,变换文字路径到与模板相同的坐标系,获得待输入字符的路径,预备后面填入模板中

正常情况下,文字高度并非一成不变;只不过,考虑到目前的场景里面暂时只会有汉字、数字和若干个大写字母,故可以无脑将文字的高度设为一致。

待填入文字的位置、宽高在制作模板的时候就能够获得,作为常量写在代码里面就行。

字形调整示意图

以下函数生成待输入字符的路径。其中 char_to_svg_path(font, i) 的作用是:返回给定字体下给定文字的 SVG 字形路径,数据类型为 svgpathtools.path.Path。关于这个后面会说。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
def calculate_scaled_char_info(code: str, start_x: int | float, start_y: int | float, width: int | float, height: int | float, font: str):
"""
给定一段单行文字,这段文字的起始坐标,文本块的宽高,以及使用的字体,给出各个文字的 SVG Path。排版时假定所有字符都等高。
:param code: 文字
:param start_x: 文本块起始 x
:param start_y: 文本块起始 y
:param width: 文本块宽
:param height: 文本块高
:param font: 字体
:return: 各个文字的 SVG Path 组成的列表
"""
scaled_char_path_list = []
scaled_char_width_list = []
char_pos_list = []
for i in code:
paths_char = char_to_svg_path(font, i)
char_minx, char_maxx, char_miny, char_maxy = paths_char.bbox()
char_width = char_maxx - char_minx
char_height = char_maxy - char_miny
ratio = height / char_height
scaled_path_char = paths_char.scaled(ratio)
scaled_char_minx, scaled_char_maxx, scaled_char_miny, scaled_char_maxy = scaled_path_char.bbox()
scaled_char_width = scaled_char_maxx - scaled_char_minx
scaled_char_height = scaled_char_maxy - scaled_char_miny
scaled_path_char = scaled_path_char.translated(complex(-scaled_char_minx, -scaled_char_miny))
scaled_char_width_list.append(scaled_char_width)
scaled_char_path_list.append(scaled_path_char)
if len(code) > 1:
space = (width - reduce(lambda x, y: x + y, scaled_char_width_list)) / (len(code) - 1)
else:
space = 0
char_x = start_x
char_y = start_y
for i, char_width in enumerate(scaled_char_width_list):
if i != 0:
char_x += scaled_char_width_list[i - 1] + space
char_pos_list.append((char_x, char_y))
scaled_char_path_list = [path.translated(complex(*pos)) for path, pos in zip(scaled_char_path_list, char_pos_list)]
return scaled_char_path_list

对于多处文本(比如说带路名的高速公路标志),可以分别处理各处的文本,反正最后放一起就行了。

读取字形

仍然是通过 AI,我了解到,可以使用 FontForge 处理字体。但是它并没有告诉我:FontForge 虽然支持 Python 调用,但是必须通过其捆绑的 Python 来调用,你无法在其他 Python 环境直接调用 FontForge。

于是我想到了一个方法:将字体内全部字形分别导出为 SVG 文件(文件名为 Unicode 码位)。生成牌子时,选取已保存的字形就可以了。这样部署环境时,也不需要装 FontForge 了。只是过多的小文件会占用非常多的磁盘空间。

FontForge 的 GUI 界面只提供了将单个字形导出为 SVG 文件的功能,如果想导出全部的文件,必须写脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import fontforge
import os


def font_to_svgs(font_path, output_dir):
# 确保输出目录存在
if not os.path.exists(output_dir):
os.makedirs(output_dir)

# 打开字体文件
font = fontforge.open(font_path)

# 遍历字体中的所有字形
for glyph_item in font.glyphs():
# 跳过非打印字符(可选)
if glyph_item.glyphname.startswith('.') or glyph_item.glyphname == '.notdef':
continue

# 获取字形对象
glyph = font[glyph_item.glyphname]
if type(glyph.codepoint) == str:
safe_glyph_name = ''.join(c for c in glyph.codepoint.replace('+', '').lower() if c.isalnum() or c in '_-')
svg_file_path = os.path.join(output_dir, f'{safe_glyph_name}.svg')

# 保存SVG文件
glyph.export(svg_file_path)
# with open(svg_file_path, 'w', encoding='utf-8') as svg_file:
# svg_file.write(svg_str)
print(f'SVG saved to {svg_file_path}')


if __name__ == "__main__":
font_path = '字体文件路径'
output_dir = '想要保存 SVG 文件的目录'
font_to_svgs(font_path, output_dir)

由于调用了 FontForge,故该脚本必须通过 FontForge 捆绑的 Python 来执行。但是也只是这一个脚本。

最开始我还没发觉有什么问题。但是后来处理思源黑体的时候,我发现了一个很大的问题。

用思源黑体而非交通运输部给的字体,是因为我看到字体的版权是华文的。英文和数字显然是来源于 FHWA 标准的字符,但是汉字部分可不是。简单对比明显是华文黑体粗体,非免费字体。

A 型交通标志专用字体,汉字用的是华文黑体粗体

考虑到汉字部分恐涉及版权纠纷,故改用思源黑体。对于编号标志,使用汉字的部分目前不可能有英文和数字,故如果仅考虑编号标志的场景的话,汉字部分使用思源黑体是可以的。

官方提供的 OTF 文件用上面的脚本处理,只能输出五十多个字符,明显不符合字体里面的字量。于是我好不容易拿 FontForge 重新导出了字体,希望换一下格式(TTF)能够解决这个问题。拿改过后的字体导出文件,确实能够导出不少字。但是试图生成标志的时候才发现,为什么连“高”这个字都没有?一看才发现,思源黑体里面的“高”(U+9AD8)的字形来源于康熙部首的字“⾼”(U+2FBC),如果拿上面的代码,是遍历不到 U+9AD8 的“高”的。

思源黑体的“高”字形来源于康熙部首的字

我对字体相关的信息不太了解,如有错误,敬请指出。

看来这种方法还是不稳定。我再次询问 AI 时,得知可以用 fontTools 包来导出某字体的某字形,而且不需要另外安装什么软件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.ttLib import TTFont
from svgpathtools import parse_path
from svgpathtools.path import Path


def char_to_svg_path(font_path, char) -> Path:
# 加载字体
font = TTFont(font_path)

# 获取字符对应的字形名称
cmap = font.getBestCmap()
glyph_name = cmap[ord(char)]

# 获取字形对象
glyph_set = font.getGlyphSet()
glyph = glyph_set[glyph_name]

# 创建SVG路径
pen = SVGPathPen(glyph_set)
glyph.draw(pen)
svg_path = pen.getCommands()
# 输出图形会上下颠倒
return parse_path(svg_path).scaled(1, -1)

最后要翻转才能得到正确的字形,还是我自己发现的。

拼合模板和字符

仍然是咨询 AI 得知,可以使用 svgwrite 来更方便地生成 SVG 文件。

我写文章的时候才发现,这个工具已经不更新了……

但是,svgwrite 只能从头开始生成 SVG,不支持在已有的 SVG 基础上修改保存。所以可以通过之前读取、生成的 Path,逐个写入,最后生成文件。

首先要新建一个和模板相同大小的文件:

1
2
dwg = svgwrite.Drawing('output.svg', size=('400', '200'))
# 'output.svg' 其实没那么重要,反正最后保存时还能自定义文件名

不过,每个模板的宽高都不一样。虽然可以把模板宽高写死在代码里面,但是我还是希望能够通过读取模板宽高来得到这两个数。用以下函数解决:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def get_svg_dimensions(svg_path: str):
"""
获取 SVG 文件的尺寸。
:param svg_path: 路径
:return: 宽、高
"""
# 解析 SVG 文件
tree = ET.parse(svg_path)
root = tree.getroot()

# 获取 <svg> 标签的 width 和 height 属性
width = root.get('width')
height = root.get('height')

if not width or not height:
view_box = [float(i) for i in root.get('viewBox').split(' ')]
width_value = view_box[2] - view_box[0]
height_value = view_box[3] - view_box[1]
else:
# 解析这些值,它们可能是带有单位的字符串(如 "100px", "100%")
# 这里假设单位是像素("px"),如果不是,则需要额外处理
width_value = float(width.strip('px')) if 'px' in width else float(width)
height_value = float(height.strip('px')) if 'px' in height else float(height)
return width_value, height_value

dwg = svgwrite.Drawing('output.svg', size=tuple([str(i) for i in get_svg_dimensions('模板路径')]))

然后将路径逐个写入 dwg 中,同时可以根据此前维护的 class 重新填色了,参考下面的语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
for path, attr in zip(paths, attributes):
fill = '#ffffff'
if 'class' in attr:
if attr['class'] == 'background':
fill = background_color
if attr['class'] == 'stroke':
fill = stroke_color
if attr['class'] == 'banner':
fill = banner_color
dwg.add(dwg.path(d=path.d(), fill=fill))
for path in scaled_banner_text_char_path_list:
dwg.add(dwg.path(d=path.d(), fill=banner_char_color))
for path in scaled_big_code_char_path_list:
dwg.add(dwg.path(d=path.d(), fill=stroke_color))
if small_code:
for path in scaled_small_code_char_path_list:
dwg.add(dwg.path(d=path.d(), fill=stroke_color))
if name:
for path in scaled_name_char_path_list:
dwg.add(dwg.path(d=path.d(), fill=stroke_color))

最后保存:

1
2
dwg.saveas('路径')
# 如果用 dwg.save(),则根据之前初始化 dwg 时填的文件名来保存

这样就生成了一张 SVG 图片了。

实现效果

完整代码见 GitHub 上的 DingJunyao/gpxutil 项目里面对应的文件。不过这个项目的名字我还没有定好,如果有修改的话,直接到我的资料页面找就行了。

通过代码生成的编号标志如下所示:

通过代码生成的编号标志

目前存在的问题也很明显:

  • 汉字的字形看上去比国标的偏大。不知道是什么方面的原因,大概是因为字体本身四周也有间距,我处理的时候把间距忽略掉了。但是字母和数字就没有这种问题。
  • 带名字的高速路牌两边间距太小。我看了一下国标,尺寸没什么问题。
  • 四位编号的路牌,“11”这种字本身就窄的编号的观感不佳。国标上的示例似乎是把大编号靠右对齐,小编号靠左对齐。但是就我现在的思路,做到这个不太容易。 GB 5768.2-2022 中的四位编码高速公路标志示例

不过,也是一个比较好的开始吧。