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