阅读视图

发现新文章,点击刷新页面。
🔲 ☆

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,

这其中 admin1Mapadmin2Map 就是通过读取 admin1CodesASCII.txtadmin2Codes.txtidname 的映射关系得到的。

再结合前面提到的反向编码逻辑,就是根据 latitudelongitude 找到最近的点,然后拿到他的 countryCodeadmin1Namename,这一信息就作为了照片的地理位置信息。

没错,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 逆向地理编码的原理,以及分享了如何实现汉化的,代码都放在了这个 仓库 中,也有现成的东西可以用。

🔲 ☆

Go 在使用泛型时无法与 Pointer Receiver 共存的解决方法

问题描述

在使用 Go 的泛型时,如果泛型类型存在 constraint,而传入的类型在实现这个 constraint 时使用的是 pointer receiver,那么就会遇到 XXX does not satisfy XXX (method XXX has pointer receiver) 的报错,就比如下面这个例子希望用 Create 函数完成所有创建 Person 的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Person interface {
	SetID(id int)
}

type Student struct {
	ID int
}

func (p *Student) SetID(id int) {
	p.ID = id
}

func Create[T Person](id int) *T {
	var person T
	person.SetID(id)
	return &person
}

这里 Student(p *Student) 实现了 Person,然而如果用 Create[Student](id) 这种方式调用时,编译会遇到这个报错

1
Student does not satisfy Person (method SetID has pointer receiver)

问题解释

问题就在于这段代码中的 (p *Student)

1
2
3
func (p *Student) SetID(id int) {
	p.ID = id
}

在 Go 中会认为是 *Student 实现了 SetID 方法,或者说实现了 Person interface,而不是 Student,因此提示 Student 并不满足 Person

那一个办法是把实现 interface 传入的改成 value receiver

1
2
3
func (p Student) SetID(id int) {  // 传入的 p 类型去掉了 *
	p.ID = id
}

这样可以通过编译且正常运行,但问题是变成了值传递后,SetID 并不会作用于传入的那个变量,这个函数也形同虚设。

另一个解决方案是可以把调用函数时改成 Create[*Student](1),加上这个 *,报错也会随之消除。但问题就解决了吗?

再仔细看这个函数在传入类型后会变成什么样

1
2
3
4
5
6
7
// T -> *Student

func Create[T Person](id int) *T {
	var person T // var person *Student
	person.SetID(id)
	return &person
}

这里暂且不论原本的返回类型 *T 会变成 **Student 的问题,这个很容易通过调整返回值类型解决。

核心问题在于第二行我们声明了一个 *Student 类型的指针,但实例化在哪?我们创建了一个空指针,所以在运行时会遇到 runtime error: invalid memory address or nil pointer dereference。同时由于语言限制,我们手上的 T* 并不能转成 T 然后让我们完成实例化。

那么能不能传入 T,然后转成指针再调用 interface 的方法呢?

1
2
3
4
5
func Create[T Person](id int) *T {
	person := new(T) 
	person.SetID(id)    // 报错
	return &person
}

然而编译器又给了一个错误 person.SetID undefined (type *T is pointer to type parameter, not type parameter),这个问题在于 SetID 是定义给 Student 的,不是给 Student* 用的。

很遗憾,由于 Go 语言层面的缺陷,在仅使用 T 这一个参数时并不能完成我们想要的东西,如果有办法,请通过网页最下方的邮件告诉我,不甚感激。

解决方案

问题在于用 T 编译器不认 constraint,用 T* 又拿不到 T 进行实例化,那么只能去掉 T 的限制,同时再传入带有限制的 T*。思路如此,具体实现来说需要定义这么一个 interface

1
2
3
4
type PersonPtr[T any] interface {
	*T
	Person
}

这个定义了一个指针 interface,第一行这里暂时先去掉了 constraint,允许传入任意类型 T,然后通过第二行使得这个 interface 允许的类型是且只能是 *T,让我们能从 T 拿到指针,再通过第三行去保证实现了 Person 这个 interface。

那我们就可以进一步修改函数,将传入的类型改为 PersonPtr

1
2
3
4
5
func Create[Ptr PersonPtr[T]](id int) *T {
	var ptr Ptr = new(T)
	ptr.SetID(id)
	return ptr
}

但这仍然不够,编译器会提示 undefined: T,因为我们没有定义 T,所以必须在函数的泛型列表中加上 T,这个函数只能变为

1
2
3
4
5
func Create[T any, Ptr PersonPtr[T]](id int) *T {
	var ptr Ptr = new(T)
	ptr.SetID(id)
	return ptr
}

调用时就变成了

1
stu := Create[Student, *Student](1)

这样调用真的很丑,但好在 Go 这回终于做了个人,通过类型的自动推导可以自动推导出第二个参数,所以调用时可以简化为

1
stu := Create[Student](1)

这样调用看起来就和谐了许多(虽然背后的实现需要用些难懂的 trick,但我们至少终于实现了 Go 中的泛型与 pointer receiver 的共存…

总结

珍爱生命,远离 Go 的泛型!

🔲 ⭐

N5105 PVE 虚拟机随机死机/重启解决方案

N5105 运行虚拟机会随机死机/重启的问题很常见,之前我采取过如下办法

  • 爱快降级至3.6.1
  • OpenWRT 换用 LXC 模式安装
  • 关闭各种直通

只能说降低了死机概率,一般能撑到一天以上,所以我选择在半夜自动重启,勉强可以正常使用,但日常使用还是不可避免的会断网。

不过现在似乎有了一个终极解决方案,可以彻底解决 N5105 的死机问题,根据这个链接反馈,已经可以超过 10 天稳定运行,我目前也暂时未遇到死机问题。

UPDATE: 我已经几十天都没有死机过了

解决方案就是更新 microcode 至 0x24000024 版本。

1
2
3
4
5
6
# 安装 microcode
apt update
apt install intel-microcode
reboot
# 查看 microcode 版本
dmesg -T | grep microcode

重启完成后,microcode 应该就已经更新到不会死机的版本了,你应该可以看到 0x24000024 字样。

1
2
3
4
5
root@pve:~# dmesg -T | grep microcode
[Wed Mar 22 22:23:26 2023] microcode: microcode updated early to revision 0x24000024, date = 2022-09-02
[Wed Mar 22 22:23:26 2023] SRBDS: Vulnerable: No microcode
[Wed Mar 22 22:23:30 2023] microcode: sig=0x906c0, pf=0x1, revision=0x24000024
[Wed Mar 22 22:23:30 2023] microcode: Microcode Update Driver: v2.2.

或者 grep 'stepping\|model\|microcode' /proc/cpuinfo 查看 microcode 版本。

1
2
3
4
5
root@pve:~# grep 'stepping\|model\|microcode' /proc/cpuinfo
model           : 156
model name      : Intel(R) Celeron(R) N5105 @ 2.00GHz
stepping        : 0
microcode       : 0x24000024

但如果源版本比较老的话,更新的版本还是例如 0x24000023 的话,就请继续后续步骤

1
2
3
4
5
6
7
# 接下来继续更新
wget https://github.com/intel/Intel-Linux-Processor-Microcode-Data-Files/archive/main.zip
unzip main.zip -d MCU
cp -r /root/MCU/Intel-Linux-Processor-Microcode-Data-Files-main/intel-ucode/. /lib/firmware/intel-ucode/
update-initramfs -u
reboot
# 重启后应当可以更新至 0x24000024
🔲 ☆

NGINX 配置避免 IP 访问时证书暴露域名

TL;DR

利用 NGINX 1.19.4 后的新特性 ssl_reject_handshake on;,将其置于默认访问时配置中,IP 访问时会终止 TLS 握手,也就不会暴露域名了。

细说

CDN 是建站时常用的工具,在自己的主机外面套一层 CDN 是常见操作,一般这样认为自己的主机就安全了,有人来攻击也会先到 CDN 服务器,攻击者根本无法获取到自己主机的 IP,但事实真的是这样吗?

我们先来看看一般配置后会出现什么问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
server {
    listen 80 default_server;

    # Redirect all HTTP requests to HTTPS.
    return 301 https://$host$request_uri;
}

server {
    listen 443 default_server;
    server_name _;
    include conf.d/ssl.config;
    return 444;
}

上面是一个很常用的 NGINX 配置,HTTP 访问全部重定向到 HTTPS 的 443 端口上,没有配置过的域名返回 444 终止连接。

好了,现在尝试用 IP 和 HTTPS 访问你的网站,你应该能够看到预想中访问失败、证书无效等连接失败的提示。

但是!注意下浏览器左上角提示的不安全,点开查看证书信息,你就会发现你的域名其实随着证书发送了过来。此时如果你是攻击者,那么其实就可以知道该域名背后的源主机 IP 就是这个。

上图即为用 IP 访问后,依旧能看到证书内容。这是因为返回 444 是 HTTP 层面的事情,意味着到达这一步下层的 TLS 握手已经完成。证书不被信任是一回事,但说明已经拿到了服务器的证书。

CDN 确实避免了直接 DNS 查询暴露 IP 的问题,但攻击者通过扫描全网 IP,用上述方式依旧可以知道每个 IP 对应的域名是什么,这也是为什么很多站长用了 CDN 后并且反复更换 IP 却依旧被攻击者迅速找到 IP 的原因。

Censys 就一直在干这件事,全网扫描 IP 并找到其对应的域名

那该怎么办呢?

问题根源出在 client 在 TLS 握手时发送了 ClientHello 后,NGINX 在 ServerHello 中带着含有域名的默认证书返回了,因为 NGINX 期望可以完成握手,这可能可以算是 NGINX 的一个缺陷。

如果你不熟悉 TLS 握手流程,那么可以看看 这篇文章

笨办法

既然 NGINX 默认提供了带有域名的证书,那么想不暴露也很简单,提供一个不含有正确域名的证书即可。

NGINX 设置中 HTTPS 访问如果没有设置证书,那么就会报错。但反正 IP 访问也不需要提供服务,那么直接自签一个 IP 证书,或者随便一个域名的证书都可。当然,如果能搞定合法的 IP 证书也不是不行。

搞定证书后,添加一个配置,让 IP 访问返回错误证书就完事了。

1
2
3
4
5
6
7
8
9
server {
    listen 443 ssl default_server;
    server_name your_ip;

    ssl_certificate    xxxx.pem;
    // and more ssl config ...

    return 444;
}

好方法

这种方法还得自己搞个证书,如果服务器多每个都得这么搞也挺麻烦的,好在这个问题 NGINX 这已经有了很完美的解决方案。

ClientHello 中是带着 SNI 的,所以其实握手阶段是可以知道访问的域名是否合法的,NGINX 1.19.4 中添加了一个新的配置项 ssl_reject_handshake 用于拒绝握手,也就不会提供证书。

使用方法也很简单,将原本默认配置中的 return 444 替换成 ssl_reject_handshake on 即可。

1
2
3
4
5
6
7
8
9
server {
    listen 443 default_server;
    server_name _;
    include conf.d/ssl.config;
    
    # 不用返回 444 了,直接拒绝握手
    ssl_reject_handshake on;
    # return 444;
}

配置后,再尝试 IP 访问,会发现浏览器报了 ERR_SSL_UNRECOGNIZED_NAME_ALERT 的错误,也看不到证书信息,目标达成!

其实还没完

上述方法是通过 ClientHello 中的 SNI 确定访问是否合法的,那如果 SNI 就是正确的域名呢?

这种场景发生于攻击者已经确定要攻击某个域名,那么他就可以将带着该域名的握手信息遍历所有 IP,握手成功就找到,这样访问其实与正常访问并无区别,唯一解决方法就是白名单只允许 CDN 服务器访问。

例如 hosts 直接将硬写 IP,将域名强行指向某个 IP

或者用这种方式 curl https://example.com --resolve 'example.com:443:172.17.54.18'

1
2
3
4
5
location / {
    allow   172.1.2.0/24;
    allow   1.2.3.4/32;
    deny    all;
}

上述 IP 段只能向 CDN 提供商询问,一般文档中都是有相关信息的。

🔲 ⭐

「论文笔记」A Unified Generative Framework for Various NER Subtasks

NER 任务分为三类,目前来说,各个子任务目前都有方法可以一定程度上解决,但缺少一个方法可以同时解决三个任务。下图分别介绍了三个子任务的常见解决方案

  • Flat NER(扁平实体抽取):通常采用序列标注的方法
  • Nested NER(嵌套实体抽取):将输入文本采用类似 n-gram 的方法进行拆分,拆成一个个小的 span 后进行分类
  • Discontinous NER(非连续实体抽取):用一个堆栈,为每个 token 选择移入移出规约等操作,类似于编译器生成 AST 的过程,详见另一篇论文

这篇文章提出使用 seq2seq 的框架以生成的方式来同时完成三个任务,也就是标题中的那个可用于各种 NER 子任务的通用框架。输出形式则为上图中的 (d),通过生成的方式输出实体的内容,最后以一个指示实体类型的特殊标签作为该实体的结束,并以同样的方式生成下一个实体。

模型结构

模型整体结构较为简单,为了让模型更好的完成生成任务,文中采用预训练过的 seq2seq 模型 BART 作为基础。输入为一段文本(首尾加上了特殊 token),输出时采用指针网络的方式,输出在输入中的位置。

具体来说,模型沿用了 BART 的 encoder-decoder 的架构(下半部分),主要工作在如何生成字符(上半部分)。

模型通过 encoder 和 decoder 后,分别拿到了两个隐状态 \(H^e\) 和 \(h_t^d\)。

实际中,index 并不能直接输入 decoder,所以解码器中还进行了一步 index2token,以 token 的方式输入

之后作者将 \(H^e\) 通过一层 MLP 后与输入的 Token Embedding 加权相加(公式 7)得到 \(\overline{H}^e\)。

然后模型会计算一个 Pointer Distribution 和 Tag Distribution,即文中每个位置(指针)的概率分布和标签的概率分布,两者合在一起就是图中的 Final Prediction Prob.,从中选择概率最高的也就是这一步输出的结果。

实际上,就是将 \(\overline{H}^e\) 和 tag 的 embedding 后的输出 \(G^d\) 分别与 decoder 的输出 \(h_t^d\) 相乘后拼接(公式 9),通过 softmax 找到概率最大的,作为输出。

然后就以自回归的方式不断重复上述步骤,依次输出下一个 token,从而得到最后的结果。

结果

实验结果基本上都处于 SOTA 的水平,不过文中有许多分析的部分,例如实体表示方式之类的。

实验表明长度更短、更类似 BPE 序列的实体会有更好的效果。此外,文中出现越靠后的实体,在 Flat NER 和 Discontinous NER 上召回率都会随之增高,而嵌套实体并没有,作者认为是因为实体间有关联,导致错误传播。

文章最大的贡献点在于提出了一种方式,能够用生成的方式完成各类 NER 任务。

❌