Immich 反向地理编码原理和汉化思路
Immich 默认识别出来的照片位置都奇奇怪怪的,不仅仅是英文,还有一些不常见的名字,在照片分类搜索的时候非常麻烦。周末仔细研究了下 Immich 到底是怎么实现反向地理编码的,并想办法对其进行了汉化。 如果你到这里,是为了实现地名汉化的话,请直接前往 这个项目 Immich 反向地理编码工作原理 为了能够实现汉化的目标,首先我们得先明白 Immich 是怎么在本地实现反向地理编码的。 反向编码 以下以 v1.124.2 为例,Immich 的反向地理编码都实现在 reverseGeocode 这个函数中,传入的是一个 GeoPoint 对象,实际上就是经度和纬度。 之后,根据经纬度,进行了如下的 SQL 查询 1 2 3 4 5 6 7 8 9 10 11 SELECT * FROM geodata_places WHERE earth_box(ll_to_earth_public(${point.latitude}, ${point.longitude}), 25000) @> ll_to_earth_public(latitude, longitude) ORDER BY earth_distance( ll_to_earth_public(${point.latitude}, ${point.longitude}), ll_to_earth_public(latitude, longitude) ) LIMIT 1; 这其中 earth_box 创建一个以给定点为中心的球体范围 ll_to_earth_public 将地理坐标 (纬度和经度) 转换为三维球体上的点 WHERE 子句筛选出 距离输入的目标点 25,000 米(25 公里)范围内 的地理点,ORDER BY 子句根据距离从近到远排序。换句话说,就是找到了 geodata_places 库中,距离输入点最近的地理点。 找到了最近的点之后,取出这个点的 { countryCode, name: city, admin1Name },也就是 国家码、名称、一级行政区名称。整理一下顺序,将国家码转换成国家名,这就对应了我们在 Immich 中看到的照片位置中的 国、省、市 三级。至于这个表是如何构建的,后面我们再单独分析。 这里名称和一级行政区名称都是直接从数据库表中得到的,而国家名是从国家码转换得到的,这里用到了 node-i18n-iso-countries 这个库的 getName 方法。但在 Immich 中,调用时的代码是 getName(countryCode, 'en'),将语言用 'en' 写死了,所以只能是英文,并没有加上任何 i18n 的机制。 而如果上面没有找到的话,就会再进行一次 SQL 查询 1 2 3 4 SELECT * FROM naturalearth_countries WHERE coordinates @> point(:longitude, :latitude) LIMIT 1; 这段 SQL 就是在 naturalearth_countries 表中找到哪些记录的 coordinates 包含输入的坐标,也就是根据自然地球中国家的划分,确定坐标所在的国家。如果走到这一条,则不会再去确定更细粒度的省市两级划分。 简而言之,Immich 就是在数据库里事先准备好了大量地名,然后用照片的坐标去匹配数据库里最近的地名,之后就以该地名作为照片的地名。找不到的话,就退化到只用国家信息,根据国家的区划划分。 数据构建 接下来的一个大问题就是,数据库里的数据是从哪来的。 Immich 所有的反向地理编码数据都来的 GeoNames,放在了 /build/geodata 文件夹下,每次发版都会从 这里 获取最新的数据。 文件夹中有这么几个文件: admin1CodesASCII.txt:一级行政区划列表(id | name | name ascii | geoname id) admin2Codes.txt:二级行政区划列表(id | name | name ascii | geoname id) cities500.txt:所有人口大于 500 的城市列表 geodata-date.txt:数据更新时间 ne_10m_admin_0_countries.geojson:自然地球国家划分,详细介绍可以 看这 Immich 导入的入口在 init 函数中,这里会首先查看 system-metadata 中 key 为 reverse-geocoding-state 的值,里面记录了 lastUpdate 的时间,也就是上次导入数据的时间。会将这个时间与 geodata-date.txt 文件中的时间进行比较,如果文件中时间较新则说明有更新的数据则开始导入,否则就跳过避免重复导入。 具体导入的逻辑在 importGeodata 中,其中抛开建立表的逻辑,核心在于 loadCities500 函数。 cities500.txt 中格式类似 csv,以 \t 作为分隔,通过如下规则转换成数据库中的内容 1 2 3 4 5 6 7 8 9 10 11 id: Number.parseInt(lineSplit[0]), name: lineSplit[1], alternateNames: lineSplit[3], latitude: Number.parseFloat(lineSplit[4]), longitude: Number.parseFloat(lineSplit[5]), countryCode: lineSplit[8], admin1Code: lineSplit[10], admin2Code: lineSplit[11], modificationDate: lineSplit[18], admin1Name: admin1Map.get(`${lineSplit[8]}.${lineSplit[10]}`) ?? null, admin2Name: admin2Map.get(`${lineSplit[8]}.${lineSplit[10]}.${lineSplit[11]}`) ?? null, 这其中 admin1Map 和 admin2Map 就是通过读取 admin1CodesASCII.txt 和 admin2Codes.txt 中 id 到 name 的映射关系得到的。 再结合前面提到的反向编码逻辑,就是根据 latitude 和 longitude 找到最近的点,然后拿到他的 countryCode、admin1Name 和 name,这一信息就作为了照片的地理位置信息。 没错,admin2Name 根本没用上,admin2Codes.txt 也没用 汉化思路 Immich 将照片的地理位置信息分为了 国、省、市 三级。再捋一遍文件的作用,也就是 从 cities500.txt 中找到最近的点,拿到他的名称作为 市 根据这个点的 admin1Code 信息,去 admin1CodesASCII.txt 文件中找到 省 级别的名称 根据这个点的 countryCode,用 node-i18n-iso-countries 转换成 国 级别名称 作用搞清楚了,接下来汉化的思路就好搞了 国 这一步骤主要依赖 node-i18n-iso-countries 这个库,而 代码 中把转换的目标语言写死为了 en,那么没有办法改目标语言,就只能从这个库的数据入手。 这个库的数据来源也是通过静态文件的形式实现的,具体文件内容可以看 这里。en.json 就是转换成 'en' 时候的数据来源,那我们只需要将其改写成中文即可,而中文的信息就在 zh.json 里,替换掉即可,就像 这样。 最后,将修改后的文件替换掉 Immich 镜像中的原始文件就可以了。 省 省的名称都在 admin1CodesASCII.txt 文件中,好在 GeoNames 提供了 alternateNamesV2.zip 这一文件,包含了许多地点的不同语言的名称,借助这一信息可以直接进行翻译,替换掉原来的名称即可。代码实现在 这里。 市 cities500.txt 这个文件主要的目标就是翻译 name 字段,但观察这个文件后可以发现,它的粒度非常细,不仅仅到市一级,还可能是区或者县,还是很古老的名字,非常不适合使用。 为了解决这个问题,可以通过地图提供商的逆向地理编码 API 对这些地方进行重新识别,获得标准的一级、二级行政区划名称,这里分别实现了适用于 国内采用高德的版本 和 国外使用 LocationIQ 的版本。 另外,默认的 cities500.txt 文件由于数据量有限,部分地区数据点较少,就会导致 Immich 在反向地理编码的时候出错。而实际上,GeoNames 还提供了不同国家的完整地理点信息,比如 CN.zip,可以作为补充添加进 cities500.txt 以提升效果,实现在 这里。但考虑到数据量庞大,所以只默认增加了直辖市,有需要的再增加。 总结 以上总结了 Immich 逆向地理编码的原理,以及分享了如何实现汉化的,代码都放在了这个 仓库 中,也有现成的东西可以用。