Mỗi cuốn hộ chiếu đều có hai dòng chữ trông khá khó chịu ở cuối trang ảnh: chữ in hoa, chữ số và rất nhiều dấu <. Đó là vùng đọc máy, hay MRZ (machine-readable zone), được quy định trong ICAO Doc 9303, tiêu chuẩn về giấy tờ đi lại. Vùng này chứa những thông tin cốt lõi giống trang in (họ tên, số giấy tờ, quốc tịch, ngày sinh, giới tính, ngày hết hạn), ở dạng mà máy quét đọc được mà không phải đoán phông chữ hay bố cục.
MRZ còn tự mang cơ chế phát hiện lỗi. Một vài ký tự là chữ số kiểm tra (check digit): mỗi chữ số được tính từ một trường cụ thể, và một chữ số cuối cùng bao quát nhiều trường cùng lúc. Nếu chỉ một ký tự bị đọc sai, chữ số bảo vệ nó thường sẽ không còn khớp. Nhờ vậy MRZ là một trong số ít thứ trong xử lý giấy tờ tùy thân mà bạn tự kiểm chứng được, với hai mươi dòng code và không cần tin vào OCR của bất kỳ ai.
Bài viết này đi qua thuật toán, vị trí các chữ số trong từng định dạng trong ba định dạng MRZ, và một bộ kiểm tra bằng Python và JavaScript mà bạn có thể dán vào dự án. Mọi ví dụ đều dùng mẫu hư cấu của chính ICAO, Anna Maria Eriksson đến từ “Utopia” (UTO, mã quốc gia chỉ tồn tại trong các mẫu). Không có giấy tờ thật nào xuất hiện ở đây.
Đây là blog của doc.cheap, một API nhận dạng giấy tờ đọc MRZ và kiểm tra lại các chữ số này trên máy chủ. Không phần nào dưới đây cần đến nó; code chạy được offline.
Bảng ký tự
MRZ dùng đúng 37 ký tự: 0-9, A-Z và ký tự đệm <. Không có chữ thường, không có khoảng trắng, không có dấu câu. Tên có dấu hoặc viết bằng chữ không phải Latinh được chuyển tự, và khoảng trắng bên trong một trường trở thành <. Ký tự đệm cũng lấp đầy mỗi trường đến độ rộng cố định của nó, nên ERIKSSON<<ANNA<MARIA<<<<<<< nghĩa là “họ ERIKSSON, tên ANNA MARIA”, với cặp << ngăn cách họ và tên.
Thuật toán: trọng số 7, 3, 1
Chữ số kiểm tra được tính theo cùng một cách cho mọi trường của mọi định dạng:
- Đổi mỗi ký tự thành một số. Chữ số giữ nguyên giá trị. Chữ cái là vị trí của nó trong bảng chữ cái cộng 9, nên
A= 10,B= 11, …Z= 35. Ký tự đệm<là 0. - Nhân với trọng số lặp lại 7, 3, 1, 7, 3, 1, … bắt đầu từ ký tự đầu tiên của trường.
- Cộng các tích lại và lấy phần dư khi chia cho 10. Chữ số duy nhất đó chính là chữ số kiểm tra.
Tính thử trên số hộ chiếu mẫu L898902C3, có chữ số kiểm tra in sẵn là 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
Vì sao lại là 7-3-1? Các trọng số được chọn để những lỗi đọc phổ biến nhất làm thay đổi tổng: sai một ký tự, và nhiều trường hợp hoán đổi hai ký tự liền kề. Đây không phải checksum mật mã. Ai cũng tính được, nên một chữ số khớp chỉ chứng minh rằng vùng này nhất quán nội bộ, chứ không chứng minh giấy tờ là thật.
Ba định dạng
ICAO 9303 định nghĩa ba bố cục MRZ. Số dòng và số ký tự mỗi dòng giúp phân biệt chúng:
| Định dạng | Số dòng × số ký tự | Gặp ở đâu |
|---|---|---|
| TD1 | 3 × 30 | Thẻ căn cước, thẻ cư trú |
| TD2 | 2 × 36 | Thẻ căn cước đời cũ và một số giấy tờ đi lại |
| TD3 | 2 × 44 | Hộ chiếu dạng sổ |
Các mẫu dùng bên dưới:
TD3 P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<
L898902C36UTO7408122F1204159ZE184226B<<<<<10
TD2 I<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<
D231458907UTO7408122F1204159<<<<<<<6
TD1 I<UTOD231458907<<<<<<<<<<<<<<<
7408122F1204159UTO<<<<<<<<<<<6
ERIKSSON<<ANNA<MARIA<<<<<<<<<<
Đọc dòng thứ hai của TD3 từ trái sang phải: L898902C3 là số giấy tờ, 6 là chữ số kiểm tra của nó, UTO là quốc tịch, 740812 là ngày sinh (YYMMDD), 2 là chữ số kiểm tra của nó, F là giới tính, 120415 là ngày hết hạn, 9 là chữ số kiểm tra của nó, ZE184226B<<<<< là dữ liệu tùy chọn (thường là mã số cá nhân), 1 là chữ số kiểm tra của nó, và cuối cùng 0 là chữ số kiểm tra tổng hợp.
Trình phân tích MRZ có bảng tra vị trí của mọi trường và mọi chữ số kiểm tra trong cả ba định dạng, và trang về các định dạng MRZ giải thích từng bố cục.
Vị trí của từng chữ số kiểm tra
Vị trí được đánh số từ 0, nên bạn có thể đưa thẳng vào slice. Chữ số kiểm tra của mỗi trường nằm ngay sau trường đó.
| Trường | TD3 (dòng 2) | TD2 (dòng 2) | TD1 |
|---|---|---|---|
| Số giấy tờ | 0–8, chữ số ở 9 | 0–8, chữ số ở 9 | dòng 1: 5–13, chữ số ở 14 |
| Ngày sinh | 13–18, chữ số ở 19 | 13–18, chữ số ở 19 | dòng 2: 0–5, chữ số ở 6 |
| Ngày hết hạn | 21–26, chữ số ở 27 | 21–26, chữ số ở 27 | dòng 2: 8–13, chữ số ở 14 |
| Dữ liệu tùy chọn | 28–41, chữ số ở 42 | không có | không có |
| Tổng hợp | chữ số ở 43 | chữ số ở 35 | dòng 2: chữ số ở 29 |
Chữ số tổng hợp là chỗ mà phần lớn các bộ kiểm tra tự viết làm sai, vì nó không bao cả dòng:
- TD3: các vị trí 0–9, 13–19 và 21–42 của dòng 2. Nó bỏ qua quốc tịch (10–12) và giới tính (20).
- TD2: các vị trí 0–9, 13–19 và 21–34 của dòng 2. Bỏ qua giống như trên.
- TD1: trải trên hai dòng: dòng 1 vị trí 5–29, rồi dòng 2 vị trí 0–6, 8–14 và 18–28.
Mỗi khoảng đều chứa các chữ số kiểm tra của từng trường nằm bên trong nó, và chính điều đó giúp chữ số tổng hợp bắt được lỗi trong bản thân các chữ số kiểm tra.
Bộ kiểm tra bằng Python
Không cần thư viện ngoài. Nó nhận diện định dạng từ hình dạng, kiểm tra chữ số của từng trường và chữ số tổng hợp, rồi trả về một dict kết quả.
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):
# Trường chỉ gồm ký tự đệm có thể in "<" làm chữ số kiểm tra.
expected = 0 if printed == "<" else int(printed)
return check_digit(data) == expected
# (tên, chỉ số dòng, bắt đầu, kết thúc, vị trí chữ số kiểm tra) cho từng định dạng
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<<<<<<<<<<"]))
# Một ký tự bị đọc sai: số 3 bị đọc thành 4 trong số giấy tờ
print(*validate(["P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
"L898902C46UTO7408122F1204159ZE184226B<<<<<10"]))
Kết quả:
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}
Dòng cuối cùng là mục đích của cả bài tập: một ký tự bị đọc nhầm thành ký tự bên cạnh, và cả chữ số của trường lẫn chữ số tổng hợp đều báo lỗi.
Bộ kiểm tra tương tự bằng JavaScript
ES module thuần, chạy được trong Node hoặc trình duyệt.
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 = {};
// Chữ số kiểm tra nằm ngay sau trường mà nó bảo vệ.
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 in ra format: 'TD3' và true cho cả năm phép kiểm tra.
Những cái bẫy
Đừng cắt bỏ ký tự đệm. Các ký tự < là một phần dữ liệu dùng để tính chữ số. Xóa các < ở cuối dòng là chữ số tổng hợp sẽ báo sai trên một giấy tờ hoàn toàn hợp lệ.
Đừng dựng lại vùng MRZ từ các trường đã phân tích. Nếu bạn phân tích MRZ thành các trường, chuẩn hóa chúng (ngày sang ISO, tên có khoảng trắng) rồi ghép lại để kiểm tra chữ số, thì thứ bạn đang kiểm tra là bộ ghép của chính mình. Hãy kiểm tra các dòng thô đúng như khi được đọc.
Chuẩn hóa kết quả OCR trước khi kiểm tra, nhưng cẩn thận. Các engine OCR hay trả về chữ thường, khoảng trắng, hoặc « thay cho <. Chuyển sang chữ hoa và bỏ khoảng trắng là an toàn. Thay O bằng 0 “vì số giấy tờ là số” thì không: số giấy tờ có thể chứa chữ cái, đúng như L898902C3 cho thấy.
Chữ số khớp không có nghĩa là ngày có thật. 740812 khớp chữ số kiểm tra dù ngày 12 tháng 8 năm 1974 có hợp lý hay không, và YYMMDD không có thế kỷ. Hãy xác định thế kỷ theo ngữ cảnh: ngày sinh nằm trong quá khứ, ngày hết hạn thường nằm trong tương lai.
Số giấy tờ dài trên TD1. ICAO cho phép số giấy tờ TD1 dài hơn chín ký tự tràn sang trường dữ liệu tùy chọn, với một < ở vị trí chữ số kiểm tra thông thường và chữ số kiểm tra đặt sau ký tự cuối cùng của số. Bộ kiểm tra ở trên không xử lý trường hợp này. Nếu bạn xử lý thẻ căn cước của những nơi cấp có dùng cách này, hãy thêm một nhánh; trình phân tích MRZ có xử lý, nếu bạn cần thứ gì đó để đối chiếu.
Chữ số kiểm tra không chứng minh tính xác thực. Ai sửa được ảnh thì cũng tính được một chữ số hợp lệ. MRZ cho bạn biết vùng này được đọc đúng và nhất quán nội bộ, chứ không cho biết giấy tờ là thật. So sánh MRZ với vùng in nhìn bằng mắt là một tín hiệu mạnh hơn, nhưng ngay cả điều đó cũng không phải là kiểm tra giả mạo.
Dữ liệu kiểm thử không cần hộ chiếu thật
Bạn không bao giờ cần hộ chiếu của một người thật để kiểm thử đoạn code này. Có hai lựa chọn:
- Các mẫu ICAO ở trên, được công bố chính cho mục đích này.
- Tự tạo: trình tạo MRZ dựng một vùng TD3 tổng hợp với chữ số kiểm tra đúng từ các giá trị bạn nhập, ngay trong trình duyệt. Sau đó đổi một ký tự là bạn có một trường hợp lỗi.
Theo chiều ngược lại, hãy dán bất kỳ vùng nào (TD1, TD2 hoặc TD3) vào trình phân tích MRZ: nó nhận diện định dạng, đọc mọi trường và hiển thị từng chữ số kiểm tra tính được bên cạnh chữ số in sẵn, tất cả trong trình duyệt. Rất tiện khi cách cài đặt của bạn và của người khác cho kết quả khác nhau.
Vị trí của bước này trong một pipeline thực tế
Nếu bạn đọc MRZ bằng OCR của riêng mình, hãy chạy các phép kiểm tra này cho mọi lần đọc và coi thất bại là “chụp lại”, chứ không phải “từ chối người đó”: lóa sáng trên một ký tự, lớp phủ bị mòn hay trang bị gấp phổ biến hơn gian lận rất nhiều.
Nếu bạn dùng một API nhận dạng được lưu trữ sẵn, vẫn hãy tự tính lại các chữ số khi kết quả quyết định chuyện tiền bạc hoặc quyền truy cập. Đó là phần duy nhất của câu trả lời mà bạn kiểm tra được mà không cần tin nhà cung cấp. Kể cả với chúng tôi: phản hồi của doc.cheap công bố nguyên văn vùng MRZ trong mrz.lines và mrz.text (các dòng nối liền, không có gì ở giữa) cạnh kết luận mrz.status của chính nó, chính là để bạn đưa vào một hàm như hàm ở trên. Hướng dẫn Check an MRZ trong tài liệu (bằng tiếng Anh) mô tả luồng đó.
Nếu bạn thấy một trường hợp mà bộ kiểm tra xử lý sai, hãy viết cho admin@doc.cheap.
Cả hai khối code đều đã được chạy và kết quả được dán đúng như khi in ra; mọi nhận định về doc.cheap đều đã được đối chiếu với code của nó.