每本护照的资料页底部都有两行看上去颇不友好的文字:大写字母、数字,外加一大串 < 符号。这就是机读区(MRZ,machine-readable zone),由国际民航组织(ICAO)的旅行证件标准 Doc 9303 定义。它承载的核心信息与印刷页面相同(姓名、证件号码、国籍、出生日期、性别、有效期),但采用了扫描设备无需猜测字体或版式就能读取的形式。
MRZ 还自带错误检测。其中有几个字符是校验位(check digit):每个校验位由某个特定字段计算得出,最后还有一个校验位同时覆盖多个字段。只要有一个字符识别错误,保护它的那个校验位通常就会对不上。因此,在身份证件处理中,MRZ 是少数几样您可以自己验证的东西之一:只需二十行代码,也不必信任任何人的 OCR。
本文将讲解这套算法、校验位在三种 MRZ 格式中各自的位置,并提供一份可以直接放进项目的 Python 与 JavaScript 校验代码。所有示例都使用 ICAO 自己的虚构样本:来自“乌托邦”(UTO,一个只存在于样本中的国家代码)的 Anna Maria Eriksson。文中不出现任何真实证件。
这里是 doc.cheap 的博客。doc.cheap 是一个证件识别 API,它会读取 MRZ 并在服务器端重新核验这些校验位。下文的内容都不依赖它,代码可以离线运行。
字符集
MRZ 只使用 37 个字符:0-9、A-Z 以及填充符 <。没有小写字母,没有空格,也没有标点。带重音符号或非拉丁文字的姓名会被转写成拉丁字母,字段内的空格变成 <。填充符还用于把每个字段补足到固定宽度,所以 ERIKSSON<<ANNA<MARIA<<<<<<< 表示“姓 ERIKSSON,名 ANNA MARIA”,中间的双 << 用来分隔姓和名。
算法:权重 7、3、1
无论哪种格式、哪个字段,校验位的计算方法都相同:
- 把每个字符转换成数字。数字字符取其本身的值。字母取其在字母表中的序号加 9,即
A= 10、B= 11、……Z= 35。填充符<为 0。 - 乘以循环的权重 7、3、1、7、3、1……,从字段的第一个字符开始。
- 把乘积相加,再对 10 取余。得到的这一位数就是校验位。
以样本护照号码 L898902C3 为例,它印刷的校验位是 6:
character L 8 9 8 9 0 2 C 3
value 21 8 9 8 9 0 2 12 3
weight 7 3 1 7 3 1 7 3 1
product 147 24 9 56 27 0 14 36 3
sum = 316 316 mod 10 = 6 the zone prints 6
为什么是 7-3-1?这组权重的选择,是为了让最常见的识别错误都会改变总和:单个字符读错,以及许多相邻两个字符互换的情况。它不是密码学意义上的校验和。任何人都能算出它,所以校验位匹配只能证明这个区域内部一致,并不能证明证件是真的。
三种格式
ICAO 9303 定义了三种 MRZ 版式,靠行数和每行字符数来区分:
| 格式 | 行数 × 字符数 | 常见于 |
|---|---|---|
| TD1 | 3 × 30 | 身份证、居留许可 |
| TD2 | 2 × 36 | 较旧的身份证和部分旅行证件 |
| TD3 | 2 × 44 | 护照本 |
下文使用的样本:
TD3 P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<
L898902C36UTO7408122F1204159ZE184226B<<<<<10
TD2 I<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<
D231458907UTO7408122F1204159<<<<<<<6
TD1 I<UTOD231458907<<<<<<<<<<<<<<<
7408122F1204159UTO<<<<<<<<<<<6
ERIKSSON<<ANNA<MARIA<<<<<<<<<<
从左到右读 TD3 的第二行:L898902C3 是证件号码,6 是它的校验位,UTO 是国籍,740812 是出生日期(YYMMDD),2 是它的校验位,F 是性别,120415 是有效期截止日期,9 是它的校验位,ZE184226B<<<<< 是可选数据(通常是个人编号),1 是它的校验位,最后的 0 是复合校验位。
MRZ 解析器提供了一张参考表,列出三种格式中每个字段和校验位的位置;MRZ 格式页面则逐一讲解每种版式。
每个校验位的位置
位置从 0 开始计数,可以直接用于 slice。每个字段的校验位紧跟在该字段之后。
| 字段 | TD3(第 2 行) | TD2(第 2 行) | TD1 |
|---|---|---|---|
| 证件号码 | 0–8,校验位在 9 | 0–8,校验位在 9 | 第 1 行:5–13,校验位在 14 |
| 出生日期 | 13–18,校验位在 19 | 13–18,校验位在 19 | 第 2 行:0–5,校验位在 6 |
| 有效期 | 21–26,校验位在 27 | 21–26,校验位在 27 | 第 2 行:8–13,校验位在 14 |
| 可选数据 | 28–41,校验位在 42 | 无 | 无 |
| 复合校验 | 校验位在 43 | 校验位在 35 | 第 2 行:校验位在 29 |
大多数自己动手写的校验程序都栽在复合校验位上,因为它并不覆盖整行:
- TD3:第 2 行的 0–9、13–19 和 21–42 位。跳过国籍(10–12)和性别(20)。
- TD2:第 2 行的 0–9、13–19 和 21–34 位。跳过的位置相同。
- TD1:跨越两行:先是第 1 行的 5–29 位,然后是第 2 行的 0–6、8–14 和 18–28 位。
每个范围都包含了其中各字段自己的校验位,正因如此,复合校验位才能发现校验位本身的错误。
Python 版校验代码
没有任何依赖。它根据形状判断格式,检查每个字段的校验位和复合校验位,并返回一个包含结果的 dict。
WEIGHTS = (7, 3, 1)
def char_value(c):
if c.isdigit():
return int(c)
if "A" <= c <= "Z":
return ord(c) - ord("A") + 10
if c == "<":
return 0
raise ValueError(f"not an MRZ character: {c!r}")
def check_digit(data):
return sum(char_value(c) * WEIGHTS[i % 3] for i, c in enumerate(data)) % 10
def digit_ok(data, printed):
# 仅由填充符组成的字段,其校验位可能印成 "<"。
expected = 0 if printed == "<" else int(printed)
return check_digit(data) == expected
# 每种格式的(名称,行索引,起始,结束,校验位位置)
LAYOUTS = {
"TD3": [("document number", 1, 0, 9, 9), ("birth date", 1, 13, 19, 19),
("expiry date", 1, 21, 27, 27), ("personal number", 1, 28, 42, 42)],
"TD2": [("document number", 1, 0, 9, 9), ("birth date", 1, 13, 19, 19),
("expiry date", 1, 21, 27, 27)],
"TD1": [("document number", 0, 5, 14, 14), ("birth date", 1, 0, 6, 6),
("expiry date", 1, 8, 14, 14)],
}
def composite(fmt, lines):
if fmt == "TD3":
l = lines[1]
return l[0:10] + l[13:20] + l[21:43], l[43]
if fmt == "TD2":
l = lines[1]
return l[0:10] + l[13:20] + l[21:35], l[35]
a, b = lines[0], lines[1]
return a[5:30] + b[0:7] + b[8:15] + b[18:29], b[29]
def detect(lines):
shape = (len(lines), len(lines[0]))
fmt = {(2, 44): "TD3", (2, 36): "TD2", (3, 30): "TD1"}.get(shape)
if fmt is None or any(len(l) != shape[1] for l in lines):
raise ValueError(f"unknown MRZ shape: {[len(l) for l in lines]}")
return fmt
def validate(lines):
fmt = detect(lines)
results = {}
for name, li, start, end, pos in LAYOUTS[fmt]:
results[name] = digit_ok(lines[li][start:end], lines[li][pos])
data, printed = composite(fmt, lines)
results["composite"] = digit_ok(data, printed)
return fmt, results
if __name__ == "__main__":
print(*validate(["P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
"L898902C36UTO7408122F1204159ZE184226B<<<<<10"]))
print(*validate(["I<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<",
"D231458907UTO7408122F1204159<<<<<<<6"]))
print(*validate(["I<UTOD231458907<<<<<<<<<<<<<<<",
"7408122F1204159UTO<<<<<<<<<<<6",
"ERIKSSON<<ANNA<MARIA<<<<<<<<<<"]))
# 一个字符读错:证件号码中的 3 被读成了 4
print(*validate(["P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
"L898902C46UTO7408122F1204159ZE184226B<<<<<10"]))
输出:
TD3 {'document number': True, 'birth date': True, 'expiry date': True, 'personal number': True, 'composite': True}
TD2 {'document number': True, 'birth date': True, 'expiry date': True, 'composite': True}
TD1 {'document number': True, 'birth date': True, 'expiry date': True, 'composite': True}
TD3 {'document number': False, 'birth date': True, 'expiry date': True, 'personal number': True, 'composite': False}
最后一行正是这一切的意义所在:一个字符被错认成相近的字符,字段校验位和复合校验位都会把它标出来。
JavaScript 版校验代码
一个普通的 ES 模块,可在 Node 或浏览器中运行。
const WEIGHTS = [7, 3, 1];
function charValue(c) {
if (c >= "0" && c <= "9") return c.charCodeAt(0) - 48;
if (c >= "A" && c <= "Z") return c.charCodeAt(0) - 55; // A = 10
if (c === "<") return 0;
throw new Error(`not an MRZ character: ${JSON.stringify(c)}`);
}
export function checkDigit(data) {
let sum = 0;
for (let i = 0; i < data.length; i++) sum += charValue(data[i]) * WEIGHTS[i % 3];
return sum % 10;
}
const digitOk = (data, printed) => checkDigit(data) === (printed === "<" ? 0 : Number(printed));
const LAYOUTS = {
TD3: [["document number", 1, 0, 9], ["birth date", 1, 13, 19], ["expiry date", 1, 21, 27], ["personal number", 1, 28, 42]],
TD2: [["document number", 1, 0, 9], ["birth date", 1, 13, 19], ["expiry date", 1, 21, 27]],
TD1: [["document number", 0, 5, 14], ["birth date", 1, 0, 6], ["expiry date", 1, 8, 14]],
};
function composite(fmt, [a, b]) {
if (fmt === "TD3") return [b.slice(0, 10) + b.slice(13, 20) + b.slice(21, 43), b[43]];
if (fmt === "TD2") return [b.slice(0, 10) + b.slice(13, 20) + b.slice(21, 35), b[35]];
return [a.slice(5, 30) + b.slice(0, 7) + b.slice(8, 15) + b.slice(18, 29), b[29]];
}
export function validate(lines) {
const fmt = { "2x44": "TD3", "2x36": "TD2", "3x30": "TD1" }[`${lines.length}x${lines[0].length}`];
if (!fmt || lines.some((l) => l.length !== lines[0].length)) throw new Error("unknown MRZ shape");
const results = {};
// 校验位紧跟在它所保护的字段之后。
for (const [name, li, start, end] of LAYOUTS[fmt]) {
results[name] = digitOk(lines[li].slice(start, end), lines[li][end]);
}
const [data, printed] = composite(fmt, lines);
results.composite = digitOk(data, printed);
return { format: fmt, results };
}
console.log(validate([
"P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
"L898902C36UTO7408122F1204159ZE184226B<<<<<10",
]));
node mrz.mjs 会输出 format: 'TD3',五项检查全部为 true。
常见陷阱
不要去掉填充符。< 字符是参与校验位计算的数据的一部分。去掉一行末尾的 <,一份完全正常的证件也会复合校验失败。
不要用解析后的字段重新拼出 MRZ。如果您先把 MRZ 解析成字段、做了规范化(日期转成 ISO 格式、姓名加上空格),再重新序列化来检查校验位,那您检查的其实是自己的序列化代码。请直接检查读取到的原始行。
校验前先规范化 OCR 输出,但要小心。OCR 引擎常常返回小写字母、空格,或者用 « 代替 <。转成大写、去掉空白字符是安全的。以“证件号码都是数字”为由把 O 替换成 0 则不安全:证件号码可以包含字母,L898902C3 恰好说明了这一点。
校验位通过不代表日期真实。无论 1974 年 8 月 12 日是否合理,740812 都能通过校验,而且 YYMMDD 不包含世纪信息。世纪要根据上下文判断:出生日期在过去,有效期通常在未来。
TD1 上的长证件号码。ICAO 允许 TD1 中超过九个字符的证件号码延续到可选数据字段中,此时正常的校验位位置上是 <,校验位则放在号码最后一个字符之后。上面的校验代码没有处理这种情况。如果您要处理采用这种做法的签发机构的身份证,请加一个分支;MRZ 解析器支持这种情况,可供对照。
校验位不等于真伪。任何会编辑图片的人都能算出有效的校验位。MRZ 只能说明这个区域被正确读取且内部一致,而不能说明证件是真的。把 MRZ 与印刷的视读区进行比对是更强的信号,但即便如此,也不是伪造检测。
不用真实护照的测试数据
测试这段代码,您永远不需要真人的护照。有两种选择:
- 上文的 ICAO 样本,它们正是为此目的而公开发布的。
- 自己生成:MRZ 生成器会在浏览器中根据您输入的值,生成一个带正确校验位的合成 TD3 机读区。生成后改动一个字符,就得到了一个校验失败的用例。
反过来,您可以把任意机读区(TD1、TD2 或 TD3)粘贴到 MRZ 解析器中:它会识别格式、读出每个字段,并把计算出的每个校验位与印刷的校验位并排显示,全部在浏览器中完成。当您的实现和别人的实现结果不一致时,这很方便。
在实际流程中的位置
如果您用自己的 OCR 读取 MRZ,请对每一次读取都运行这些检查,并把校验失败当作“重新拍照”,而不是“拒绝这个人”:某个字符上的反光、磨损的覆膜或折痕,远比欺诈常见。
如果您改用托管的识别 API,那么当结果关系到资金或访问权限时,仍然应该自己重新计算校验位。这是响应中唯一一部分您无需信任供应商就能自行核验的内容。这也包括我们:doc.cheap 的响应在给出自己的 mrz.status 判定的同时,会原样提供 mrz.lines 和 mrz.text(各行直接首尾相连),正是为了让您可以把它传给上面那样的函数。文档中的 Check an MRZ 指南(英文)介绍了这一流程。
如果您发现校验代码处理错误的情况,请写信至 admin@doc.cheap。
文中两段代码都实际运行过,输出按原样粘贴;关于 doc.cheap 的每一项说法都已对照其代码核实。