从零开始用OSM建立足迹App引擎
我在做一个足迹 App,以区县为单位点亮走过的地方:在一个区里到过足够多的地标、走过足够大的面积,这个区就点亮,往后还有“熟悉”和“走遍”两档。
这句话听着简单,真做起来,每个词背后都是一个要定死的口径:
在哪个区? 区县以什么为准,功能区、新设的县算不算。
什么算地标? 哪些地方叫得上名字、值得去,名气怎么量,一个景区算几个。
能走的有多大? “探索够大的面积”里的分母是什么。一个县一两千平方公里,大半是山和水。
怎么算到过? 走到哪里、离多近,才算到访了一个地标。
口径定得不对,用户会直接感觉到:住在功能区里的人永远不在任何一个区,大县怎么走都点不亮,走进景区大门却不算到过。
这篇讲我怎么用 OpenStreetMap(OSM)把这四个口径一个个落成数据,国内的 OSM 又有哪些坑。所有数据都在电脑上离线生成,打成一个数据包随 App 发,手机上的判断完全不联网。
为什么不问苹果
App 的底图是苹果地图,所以我最先想的是直接问苹果。可这四个问题,苹果一个都答不了:
在哪个区: MapKit 和 Maps Server API 只给点、地址和外接矩形,没有边界形状,底图上也不画区县界。反向编码倒是能给区名,但要联网、限频率,一天几万个轨迹点,不可能每个都去问。
什么算地标:
MKLocalSearch的结果按离搜索范围中心的远近排,不按名气。按 6 个类别各取最多 25 条,杭州萧山区一共只拿到 22 个。类别本身也乱:办公室和学校归在同一类,名胜的类别反而是空的,按类别查周边的接口在国内直接报错。能走的有多大、怎么算到过: 没有任何接口。
换成 OSM 加维基数据以后,萧山区有 111 个地标,边界、面积、到访也全部能在本地算。
数据从哪来:整份下载,本地算
三份源数据都先拿到本地,后面所有的计算都在自己电脑上跑,不调在线接口:
OSM 不用 Overpass 这类在线接口去查。全国的路网和景点量太大,在线查会被限流;而且 OSM 随时有人在改,同一个查询今天和明天的结果可能不一样。把整份提取下载下来,这一版数据就固定了:口径改了随时重跑,跑出来的差别只来自口径本身。
只有一处例外:补边界用的几个 OSM 关系,是从 Overpass 按编号单独取的,取回来以后直接入库,后面再也不重新下载。
口径一:在哪个区
口径:以国家统计局的区县名单为准;数据源里单独划出来的功能区照样算一个区。
边界从 Overture 来
区县边界取自 Overture Maps 的 divisions 主题。它源自 OSM,做过清洗,按 GeoParquet 放在 S3 上,用 DuckDB 能直接查:
select id, subtype, names.primary, hierarchies, parent_division_idfrom read_parquet('s3://overturemaps-us-west-2/release/2026-09-23.0/theme=divisions/type=division/*')
where country = 'CN' and subtype in ('region', 'county', 'localadmin')
区县一级用 localadmin。东莞、中山、儋州、嘉峪关这几个不设区的地级市下面没有 localadmin,就用 county 那一级补上。
对着名单修
拿区县名单逐省一对,对不上的地方很多,各有各的原因:
苏州工业园区这一行,就是口径里“功能区照样算一个区”的来历。它不在民政部的名单里,可数据源已经把这块地从周围的区里挖掉了。不把它补成一个区,城里就有 280 平方公里不属于任何区,住在那里的人永远点不亮。
这些问题都按同一个办法处理:
修补写成表,每条带断言。 要改的对象在数据源里必须正好有一个;要补的区如果数据源里已经有了,就报错。上游哪天修好了,对应的补丁会自己跳出来提醒你删。
从 OSM 补的数据钉住版本。 补江北、渝北、三沙用的 OSM 原始关系,取回来以后直接入库。半年后重新下载,拿到的未必还是你核对过的那一版。
补上的区优先。 补进来的区和同省别的区有重叠时,重叠部分归补上的区;扣完以后,两份数据边界差几米留下的碎片,小于约 0.01 平方公里的丢掉。
核对脚本是一道闸门
每次重新生成都要过一遍核对脚本:
名单:逐省和区县名单比,查多了的、少了的、上级不一样的。
形状:全国拼起来,中间有没有洞,区和区之间有没有大块重叠。挨着国界、海岸的缺口查不出来,只查四周被区围住的洞。
和上一版比:哪些区县没了,哪些是新的。
知道了、决定不处理的出入登记在表里,每条写上原因。比如“和安县、和康县:2024 年 12 月新设的县,名单还没有”,“金门县:名单上挂名,数据源归在台湾”。出现没登记的出入,脚本以非 0 退出;登记过的出入已经不存在了,脚本同样失败,逼着你把过时的登记删掉。这样一来,登记表就是这个口径完整的例外清单,始终和数据对得上。
编号用区划代码
区县的编号是 CN-330108 这种形式,名字和上下级从区划表现查,区县改名不影响用户数据。名单里没有、但区界里有的功能区(深汕特别合作区、苏州工业园区)和兵团垦区,给一个带字母的代码(4403X1),永远不会和以后的真代码撞上。
手机上怎么判断
边界简化到约 50 米,坐标换成 2²⁶ 网格上的 Web 墨卡托整数,每个点和上一个点做差,再用 zigzag 变长整数编码。全国 2861 个区县,6.6 MB。
手机上把世界切成 z22 的像素(国内约 8 米一格),64×64 像素一块。整块落在一个区县里的,只记一个编号;压在区界上的块,逐像素算出一张 64×64 的归属图,用 zlib 压缩(约小 45 倍)。绝大多数块都不压界,所以判断一个点在哪个区,基本就是查一次表。
口径二:什么算地标
口径:区里叫得上名字、去的人多的地方。不分高低,只用来算比例,所以名单大体合理就行。
先想清楚地标拿来干什么。它是用来衡量探索度的:一个区里那些有名、人多的地方,你去过几成,熟悉、走遍都按比例算。用的是比例,所以不用精确。多一两个冷门的点,比例几乎不变;漏了西湖,就不行。
由此定下一条原则:只用通用规则,不做人工修补表。 2861 个区县,人工维护不现实。规则写对了全国一起生效,下次换新的 OSM 数据,直接重跑就行。
国内的 OSM 缺了什么
国外的城市我用的是一套简单规则:取 tourism、historic、宗教场所、公园这几类标签,按名字被翻译成几种语言(name:xx 的个数)排名气,每个市取前 100 个。国外的 OSM 维护得好,这样做效果不错。
照搬到国内,县城的地标中位数只有 11 个,原因有三:
很多有名的地方只有名字,没挂标签。 东方明珠、兵马俑、颐和园在 OSM 里都是这样。
几乎没有外语名, 按翻译数排名气就排不出来。
社区设施特别多。 口袋公园、健身角、绿道廊架、“3 号楼”,全都挂着
leisure=park或historic。
名气怎么量:被几种语言收录
维基数据正好补上这三个缺口。它的条目有坐标,有类别,还有一个特别好用的数:这个条目被几种语言的维基百科收录。 这个数就是现成的名气指标,故宫有 30 多种,街角的小公园一种也没有。
取条目时连同类别(P31)和文保级别(P1435)一起取。类别再沿上下级往上追,归到旅游景点、博物馆、寺庙、湖泊、遗址这些根类,这样“国家级风景名胜区”“佛教寺院”这种细类也能认出来。
然后和 OSM 对上:
OSM 对象挂了
wikidata编号的,按编号对。没挂的,按“300 米以内、名字相同或互相包含”对。
对不上的维基条目,单独算一个地标。东方明珠就是这样进来的。
维基条目也不是来者不拒。景点类的条目,被一种语言收录就收;山、瀑布、泉、峡谷、海滩、岛、塔这类到处都有,要被至少 3 种语言收录才收。
编号本身也会挂错。娃哈哈酒店挂的是娃哈哈集团的条目,蒋介石故居挂的是蒋介石本人。所以条目是公司、酒店、写字楼、桥、电站这类,又不是景点、没有文保、收录的语言不到 15 种的,当作没挂;没有坐标的条目(人物、事件)也不认。
除了景点,口径里还包括几类日常去的地方:大学、商场、火车站、民用机场。火车站只收有维基条目的,小站、废站就去掉了;地铁站、车辆段、货场不算。大学去掉老年大学、社区学院、开放大学、培训机构,分校区各算一个。
哪些不算:只砍没有维基也没有文保的
零碎的过滤规则有十几条:
不到 2 万平方米的公园、花园;
名字是通用词(“公园”“广场”“观景台”),带编号(“3 号楼”“A 区”),或者带“口袋公园”“健身”“廊架”“绿道”“小区”这类字样;
全国同名 20 个以上的,比如大雄宝殿、土地庙,但以“公园”“景区”“博物馆”“故居”结尾的除外;
水库、河渠、池塘、雕塑、菜场;
名字写着“不对外开放”“已拆除”“已关闭”的。
这些规则敢写得这么狠,是因为它们只对既没有维基条目、也没有文保标记的 OSM 对象生效。有名的地方总会占其中一样,误伤的机会很小。维基数据在这里起了两个作用:给名单补点,也给过滤规则兜底。
也有反过来、专门放行的两条:
景区里的湖。 一般的水面不收,可西湖、湘湖这种湖本身就是景点。有名字、2 万平方米以上,并且和景区、公园、度假区重叠三成以上,或者名字能和 1 公里内的景区对上(湘湖对上湘湖国家旅游度假区),就收。
空壳维基条目。 有的湖挂了维基条目,可这个条目没有任何一种语言的百科页面。这种当作没挂,再按普通的湖来判断,杭州的白马湖就是这样补回来的。
一个地方算几个:合并
OSM 里同一个地方常被画成好几块,大景区里还有一堆小项。不合并的话,逛一个景区能刷出十几个地标。合并规则:
同一个维基条目,在同一个区县只留一个。 长城各段挂的是同一个条目,每个区县各留一段。
1 公里内同名的合成一个,形状也合起来。 比名字之前先繁转简(用 OpenCC 的词表),再去掉分区后缀,“钱江世纪公园 C 区”就是“钱江世纪公园”。用分隔符连起来的名字也算同名,“中天竺 - 法净禅寺”和“法净禅寺”是一处。
大景区吞并小项。 有维基条目的大面,把里面名气不到它四分之一的小项并进来。故宫在 OSM 里只有一个点,但被 30 多种语言收录,600 米内名字以殿、门、阁、楼结尾的都并给它。
寺院里的殿阁并进寺院,大学里的老楼并进大学。
结果
名单不设上限,候选全部收下。排名(按收录语言数、文保级别、面积)只影响界面上的先后。全国约 9 万个地标,每个区县中位数 14 个,有 69 个区县一个都没有,贵州最多。
口径三:能走的有多大
口径:区里所有的路,两侧各 65 米,合起来的面积。
点亮要“探索够大的面积”,但这个面积不能拿区县的行政面积当分母。一个县一两千平方公里,大半是山和水,按行政面积定门槛,谁也走不到。
试过的几种“哪里算能走”都不行:
最后的口径很朴素:把区里每条路都走一遍,能探索到的面积就是这么大。
65 米不是随便取的。App 在轨迹两侧会多揭开 65 米的一圈,你探索了多少面积就按这一圈算。分子(你探索了多少)和分母(这里能探索多少)是同一把尺子量出来的。
路取 OSM 里 22 种 highway,从高速公路一直到 footway、path、steps。景区、校园、公园里的步道本来就是人逛的地方,要算进来。还没修的路、废弃的路、站台和室内走廊不算。
算法也和手机上算探索面积的那套一样:路画到 z22 的像素上(国内约 8 米一格),先用 3×3 的画笔扩一圈,再用半径 round(65 米 ÷ 像素边长) 的圆盘外扩,最后按区界数格子,每个格子按所在纬度算面积。墨卡托投影下像素的地面尺寸随纬度变化,所以国内的半径从 7 像素到 11 像素不等。全国怎么在半分钟里算完,放到后面 Rust 那一节讲。
口径四:怎么算到过
口径:走到地标的任何一处 65 米以内,就算到访。
“任何一处”是关键。大景区不能只看中心点:西湖的中心在湖里,故宫的中心在殿里,谁也不会走到那儿去。
所以每个地标都带一组判断点:
有轮廓的,轮廓先简化到约 20 米,沿轮廓每 50 米放一个点;
加上中心点;
加上合并时并进来的小项的位置。
走到任何一个判断点 65 米以内,就算到访。从哪个门进景区都算,绕西湖走一段就算,走到午门就算到访了故宫;线状的地标,比如一条老街,走到哪一段都算。
65 米和揭开、探索面积用的是同一个数。手机上按所在纬度换成像素格数,杭州是 8 格,哈尔滨是 10 格,和探索面积是同一个换算。四个口径里,后三个共用这一把尺子:能走多大、你探索了多少、算不算到过,说的都是“轨迹两侧 65 米”。
口径要能反复调
口径不是一次就能定对的。地标的过滤规则我改了很多轮,每一轮都要回答同一个问题:这次改动是在清理零碎,还是砍到了真地标?
审计工具
每次生成都会和上一版比,列出地标数、有效面积变化大的区县。另外还有一个审计命令,能看某个区县收了什么、去掉了什么、为什么去掉。下面是萧山区的一段:
=== 萧山区:111 个 杭州萧山国际机场[36](机场);杭州南站[10](火车站);跨湖桥遗址博物馆[7];湘湖[3](湖) …
- 湖:面积太小(2):湘浦观鱼 0.8 万㎡、茭河池 0.1 万㎡
- 湖:水库、鱼塘、河渠(1):天乐湖光
- 没有维基条目的小公园(49):永兴公园 1.4 万㎡、时代公园 0.6 万㎡ …
- 泛称、零碎名字(11):H 区块公园、道源路南侧绿道廊架、1 号楼、2 号楼 …
- 同一处的几块(6):钱江世纪公园 8.5 万㎡、钱江世纪公园 5.6 万㎡ …
方括号里是收录的语言数。改一条规则,挑几个自己熟悉的区县跑一遍审计,比看全国总数有用得多。
一个数据包,一个版本号
区划表、区县边界、地标、有效面积打成一个数据包,共用一个版本号,用一个脚本一起生成、一起核对。这几份数据靠区县编号互相引用,只要有一份单独更新,编号就可能对不上,所以干脆不允许单独更新。
用户数据不引用口径
用户的数据库里只存事实:轨迹点、擦除、标记。不存“这个点属于哪个区”,也不存“到访过哪个地标”。
所以口径一变,手机上只需要做一件事:数据包版本一变,就从轨迹点整份重算,区县归属、到访、面积、档位全部按新口径重判,不在新名单里的地标就不再算。两年的数据重算只要几秒,口径可以放心地改。
用 Rust 让口径调得动
口径要反复调,前提是跑一遍够快。上面的有效面积和地标,第一版都是 Python 写的:有效面积全国要半小时左右,地标要 5 分钟。调地标是一个很密的循环,改一条规则、跑一遍、审计几个县、再改,每轮等 5 分钟就太慢了。以后还要做国外的数据包,数据量是国内的好几倍,半小时会变成几小时。所以两样都用 Rust 重写了。
依赖不多:osmpbf 读 PBF,rayon 做并行,geos 绑定 GEOS 做几何运算(和 Python 的 shapely 底层是同一个库),bincode 存中间产物。Release 开了 lto = "fat" 和 codegen-units = 1。
重写以后,有效面积是半分钟,只改面积规则、不重读路网时 13 秒;地标是半分钟,只改挑选规则重新打包 5 秒。快的主要原因不是换了语言,而是换了循环的方式。
慢在哪:按区县循环
Python 版的有效面积按区县循环:每个区县找出自己碰到的块,在每块里画路、外扩,再按区界数像素,用多进程并行。
问题出在区界上。一个块压着三个区县,就要画三遍、外扩三遍;每个区县还得先从全国的路里挑出附近的路。全国 2861 个区县,压界的块占了很大比例,大量的活是重复做的。
改成按块循环
Rust 版把循环反过来,以块为单位:
路段分块。 每段路(同一条路上相邻的两个点)按外接矩形放进它碰到的块,记成一个
u64:块的行号、列号和段的序号拼在一起。全部排序一次,同一块的段就挨在一起,同一行的块也挨在一起。每块只画一次、外扩一次。 块里有几个区县,就按它们用到的几种半径各外扩一次。同一纬度带上的区县半径基本相同,通常只算一种。
按区县数像素。 区界按扫描线切成每行的区段,在外扩后的位图上数区段里有几个 1。
按条带并行。 一行块是一个条带,条带之间没有共享状态,交给 rayon。每个线程一份临时缓冲(
map_init),不用反复分配。
let partial: Vec<Vec<(usize, f64)>> = bands .par_iter()
.map_init(Scratch::new, |sc, &(ty, s, e)| band_areas(ty, &entries[s..e], &points, ®ions, sc))
.collect();
位图外扩:整字移位
位图每 64 个像素存成一个 u64。一行往右扩 k 个像素,就是整行左移 k 位,跨字的部分从前一个字补过来:
fn shl_row(src: &[u64], dst: &mut [u64], k: u32) { dst[0] |= src[0] << k;
for w in 1..TW {
dst[w] |= src[w] << k | src[w - 1] >> (64 - k);
}
}
圆盘外扩拆成两步。先算出“每行横向扩 0、1、2 …… r 像素”的 r+1 张位图,每张在前一张的基础上多扩一位。圆盘第 dy 行的半宽是 ⌊√(r² − dy²)⌋,所以竖向只要把上下 r 行里对应半宽的那张按位或起来。一次外扩只有移位和按位或,空行直接跳过。
块的四周多留一圈余量(左右各一个字,上下各 16 行),半径以内的路就算画在相邻块里,也能扩进来,不用去读别的块。
数像素:扫描线加 popcount
区界不做几何求交。每个区县的多边形先换成像素坐标下的边,对每一行像素的中心线求它和各条边的交点,排序以后两两配对,就是这一行落在区里的区段。这个取法和 GDAL 栅格化一样,按像素中心判断。
数一个区段里有几个 1,就是区段两头的字用掩码截一下,中间的字直接 count_ones():
fn count_bits(row: &[u64], a: usize, b: usize) -> u32 { let (wa, wb) = (a / 64, (b - 1) / 64);
let lo = !0u64 << (a % 64);
let hi = !0u64 >> (63 - (b - 1) % 64);
if wa == wb {
return (row[wa] & lo & hi).count_ones();
}
let mut n = (row[wa] & lo).count_ones() + (row[wb] & hi).count_ones();
for w in &row[wa + 1..wb] {
n += w.count_ones();
}
n
}
读 1.7 GB 的 PBF
有效面积和地标都要先读一遍 OSM 的中国提取。PBF 由一个个独立压缩的数据块组成,天然适合并行:
块并行解压。
BlobReader用par_bridge交给线程池,每块各自解压、解析,结果带上块号,最后按块号排序。输出顺序和单线程读一样,结果可复现。两遍读,第二遍跳块。 路只记着点的编号,坐标在点里。第一遍读路时顺手记下哪些块里有点;第二遍找坐标,只解压这些块。
点编号的查找。 要找坐标的点编号排序去重后有几千万个。按编号的高位分桶,查找时只在一个桶里二分,比整表二分少一半以上的缓存缺失。找到的坐标打成一个
u64(经度、纬度各 32 位),用AtomicU64按Relaxed写回。每个编号只会被一个线程写到,不用加锁。中间产物直接按内存写盘。 路上的点换成像素坐标后,按
i32数组原样写成文件。只改面积规则时直接读回来,不用再碰 PBF。
和 Python 版逐项对齐
重写最怕的是口径悄悄变了,所以每一步都拿同一份数据和 Python 版比:
有效面积:2861 个区县里 2859 个一平方米都不差,剩下两个差几个像素。
地标:OSM 候选、维基条目、59 万个名字的繁转简结果逐条相同,地标名单、顺序、去掉的记录全部一致。
为了做到这一点,有几处细节是特意照抄的:
画路时每半个像素取一个点,向下取整;
空间索引用 GEOS 的 STRtree,节点容量也和 shapely 一样设成 10;
输出的 JSON 和 Python
json.dump的格式一字不差,可以直接 diff。
唯一对不上的地方,出在 GEOS 从 3.11 升到了 3.15:求交得到的轮廓起点变了,判断点沿轮廓整体错开一段,形状本身没变。
还有一个坑值得提醒。有几条规则是“在空间索引里找到第一个就停”,比如一个点落在两个区县的边界上,取查到的第一个。这种规则的结果取决于索引的查询顺序,换语言、换库的时候最容易悄悄变。
国外不照搬
国内加的维基补点、零碎过滤,都是为了补国内 OSM 的缺口。国外的 OSM 质量好,按标签取、按翻译数排、每个市取前 100 就够了,没必要把国内这套规则搬过去。四个口径本身不变,变的只是怎么从数据里把它落出来。
几条经验
先把口径说成一句话,再找数据。 “在哪、去哪、多大、到没到过”,每一个都先定成一句能讲给用户听的规则,数据和算法都围着这句话走。
口径之间共用一把尺子。 能走的面积、探索的面积、到访的距离都是“轨迹两侧 65 米”,分子分母才对得上,用户也只需要记住一个数。
开源边界不能直接用,要对着一份权威名单修。 修补写成带断言的表,用到的原始数据钉住版本,核对脚本当成闸门。
国内 OSM 的问题不在没有数据,而在没有标签、没有外语名。 维基数据的“被几种语言收录”是最便宜的名气指标;过滤规则只对没有维基也没有文保的对象生效,就可以写得很狠。
口径用来算比例,就不必追求精确。 不追求精确,才能放弃人工修补,全靠规则自动跑。
调口径要有审计工具。 列出收了什么、去掉了什么、为什么去掉。
用户数据不引用口径。 只存事实,口径改了就整份重算。
快主要靠换循环,换语言是其次。 从按区县算改成按块算,每块只做一次;重写时和旧版逐项对齐,才敢换掉旧版。
数据来源:OpenStreetMap(ODbL)、Overture Maps(ODbL,源自 OpenStreetMap)、维基数据(CC0)。