기계 판독 영역(MRZ)에서 가장 예측하기 어려운 필드는 이름입니다. 날짜는 여섯 자리 숫자이고, 문서 번호는 검증 숫자가 붙은 아홉 글자이며, 코드는 정해진 목록에서 나옵니다. 이름은 그 사람이 불리는 그대로를 정해진 수의 대문자에 밀어 넣은 것입니다. 공백도, 악센트도, 문장 부호도 없고, 글자가 잘못 읽혔을 때 알려 줄 검증 숫자도 없습니다.

이 글에서는 영역이 이름을 쓰는 방식, 그것을 성과 이름으로 다시 나누는 방법, 음역이 이름에 미치는 영향, 이름이 잘렸을 수 있다는 것을 알아보는 방법, 그리고 페이지에 인쇄된 이름과 비교하는 방법을 다룹니다. 아래의 이름은 모두 지어낸 것이거나 ICAO 자체의 견본인 Anna Maria Eriksson이며, 실존 인물은 등장하지 않습니다.

이곳은 여권 및 신분증 OCR API doc.cheap의 블로그입니다. 아래 내용에는 doc.cheap이 필요하지 않으며, 코드에는 의존성이 없습니다.

이름의 위치

기계 판독 여행 문서의 표준인 ICAO Doc 9303은 세 가지 레이아웃 각각에서 이름에 고정 폭의 필드를 줍니다. 여기서 위치는 표준이 세는 방식대로 1부터 셉니다.

형식 이름의 위치 폭
TD3(여권), 2 × 44 1행, 6–44번 위치 39자
TD2, 2 × 36 1행, 6–36번 위치 31자
TD1(신분증), 3 × 30 3행 전체 30자

TD3나 TD2의 1행에서 처음 다섯 글자는 문서 코드와 발급 국가이고, 이름은 그 뒤를 모두 차지합니다. TD1은 이름에 한 줄을 통째로 주지만, 그래도 셋 중 가장 좁습니다. MRZ 형식 페이지에서 각 레이아웃을 필드별로 설명합니다.

TD3 견본의 1행은 P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<입니다. P<UTO를 빼면 이름 필드는 ERIKSSON<<ANNA<MARIA이고, 그 뒤로 39자까지 필러가 이어집니다.

문법: <<, <, 그리고 채움

영역의 문자 집합은 A–Z, 0–9, 그리고 필러 <입니다. 이름은 그 위에 세 가지 규칙을 더합니다.

  • 1차 식별자가 먼저 옵니다. 보통 성입니다.
  • 필러 두 개 <<가 1차 식별자를 끝냅니다. 그 뒤가 2차 식별자이며, 보통 이름입니다.
  • 필러 하나 <는 어느 부분에서든 단어를 구분합니다. ANNA<MARIA는 이름이 두 개이고, VAN<DER<BERG는 세 단어로 된 성입니다.

이름 뒤에 남은 필드는 <로 채워집니다. 따라서 이름을 다시 읽는 것은 세 단계입니다. 끝의 필러를 제거하고, 첫 번째 <<에서 나누고, 남은 단일 <를 공백으로 바꿉니다.

이상해 보이지만 오류가 아닌 형태가 두 가지 있습니다. 이름이 하나뿐인 사람은 1차 식별자만 가지므로, 필드는 이름 다음에 바로 필러가 오고 그 앞에 <<가 없습니다: OKONKWO<<<<…. 구분자를 쓰고 그 뒤에 아무것도 없는 필드 NAKAMURA<<<<…도 끝의 필러를 제거하면 똑같이 읽힙니다. 어느 쪽이든 2차 식별자는 비어 있는 것이지, 빠진 것이 아닙니다.

살아남지 못하는 것은 문장 부호와 공백 자체입니다. 하이픈, 아포스트로피, 공백은 영역에 쓸 수 없으므로, 인쇄된 Smith-Jones는 SMITH<JONES로 들어오고 두 단어로 된 성과 구별되지 않습니다. 문장 부호가 필요하다면 그것은 영역이 아니라 인쇄된 페이지에 있습니다.

음역: 모든 문자 체계에서 26개 글자로

모든 이름은 결국 A–Z 안에 들어가야 합니다. 발음 구별 기호가 붙은 라틴 문자로 인쇄된 이름이라면, 악센트가 붙은 각 글자를 일반 글자로 바꾸는 것을 뜻합니다. 그리스 문자, 아랍 문자, 키릴 문자, 한자 등 다른 문자 체계의 이름이라면, 이름 전체를 라틴 문자로 쓰는 것을 뜻합니다.

모든 형식에 공통인 부분인 Doc 9303 Part 3에는 두 경우 모두에 대한 권장 음역이 실려 있습니다. 하지만 결정은 발급 국가가 하며, 모든 국가가 같은 선택을 하지는 않습니다. 코드에 중요한 결과는 세 가지입니다.

  1. 인쇄된 이름 하나가 영역에서 여러 철자를 가질 수 있습니다. 인쇄된 MÜLLER는 발급 기관의 규칙에 따라 영역에서 MUELLER로도, MULLER로도 나타날 수 있습니다. 고정된 하나의 대응을 기대하는 코드는 올바른 영역을 틀렸다고 판정합니다.
  2. 대응은 거꾸로 되돌릴 수 없습니다. 영역의 MULLER는 페이지의 MULLER, MÜLLER, MÚLLER 중 어느 것에서도 올 수 있습니다. 영역이 알려 주는 것은 그 안에 든 글자이지, 그 사람이 쓰는 이름이 아닙니다.
  3. 페이지의 라틴 문자 이름이 영역과 다를 수 있습니다. 자국 문자로 인쇄된 문서는 라틴 문자 버전도 함께 인쇄하는 경우가 많지만, 그 인쇄된 라틴 철자가 영역의 철자와 한 글자 한 글자 같다는 보장은 없습니다. 차이는 살펴볼 대상으로 다루고, 오독의 증거로 보지 마십시오.

실용적인 규칙은 이렇습니다. 영역의 이름과 인쇄된 이름은 둘 다 순수한 글자로 줄인 뒤 비교하고, 실제로 다루는 발급 기관이 쓰는 철자를 허용하며, 사람이 읽는 모든 곳에는 인쇄된 철자를 사용하십시오.

잘림: 이름이 들어가지 않을 때

긴 이름은 다음 줄로 넘어가지 않습니다. 필드에 맞게 줄여지고, 전체 이름은 인쇄된 페이지에 남습니다. 표준은 이름을 줄이는 규칙을 정해 두었고, 줄인 이름은 보통 필드의 맨 마지막 글자까지 채웁니다.

여기서 파서가 얻을 수 있는 유일한 신호가 나옵니다. 이름 필드의 마지막 문자가 <가 아니라 글자라면, 이름이 잘렸을 수 있습니다. "잘렸다"가 아니라 "잘렸을 수 있다"입니다. 필드와 길이가 정확히 같은 이름도 글자로 끝나기 때문입니다. 하지만 필러로 끝나는 필드는 확실히 잘리지 않았습니다.

폭이 중요합니다. 가상의 소지자 Konstantin Friedrich Hartmann Schoenebeck을 예로 들어 보겠습니다. 39자인 TD3 필드에서는 다음과 같습니다.

HARTMANN<SCHOENEBECK<<KONSTANTIN<FRIEDR

마지막 문자가 글자 R이므로 이름이 잘렸을 수 있고, 실제로 잘렸습니다. FRIEDRICH는 세 글자를 잃었습니다. 같은 이름을 TD1 폭인 30자로 자르면 HARTMANN<SCHOENEBECK<<KONSTANT만 남습니다. 영역의 이름을 소지자의 이름으로 저장하는 시스템은 KONSTANT를 이름으로 저장합니다.

여기서 두 가지 습관이 나옵니다. 필드가 가득 차 있으면 영역의 이름을 표시 이름으로 쓰지 말고 인쇄된 이름을 쓰십시오. 그리고 전체 인쇄 이름을 영역의 이름과 대조할 때, 필드가 가득 차 있었다면 영역의 이름으로 시작하는 인쇄 이름은 일치로 보십시오.

발급 기관은 끝을 자르는 것 말고 다른 방식으로 이름을 줄이기도 합니다. 예를 들어 두 번째 이름을 머리글자로 줄이는 방식입니다. 접두사 검사는 그런 이름을 불일치로 보고합니다. 그 경우에는 문서를 거부하지 말고 사람에게 넘기십시오.

이름을 보호하는 검증 숫자는 없다

검증 숫자가 보호하는 것은 문서 번호, 두 날짜, TD3의 선택적 개인 번호, 그리고 아래쪽 줄 대부분에 걸친 복합 검증 숫자입니다. 그중 어느 것도 이름을 보호하지 않습니다. TD3와 TD2에서 이름은 1행에 있고, 어떤 검증 숫자도 그 줄에는 닿지 않습니다. TD1에서는 이름이 3행에 있고, 복합 검증 숫자는 2행에서 멈춥니다.

그래서 이름의 오독은 조용히 지나갑니다. M을 N으로 읽어도, 필러를 글자로 읽어도, 계산은 맞아떨어지는 영역이 나옵니다. 이름을 교차 확인하는 유일한 방법은 인쇄된 페이지입니다. 둘 다 읽고 비교하십시오. 아래의 비교 함수가 바로 그 용도입니다. 검증 숫자가 보호하는 필드의 계산은 MRZ 검증 숫자의 원리에서 설명합니다.

Python으로 필드 나누기

의존성은 없습니다. 함수는 이미 해당 줄에서 잘라 낸 이름 필드만 받습니다(TD3에서는 line1[5:44], TD2에서는 line1[5:36], TD1에서는 line3).

import re

def words(part):
    return " ".join(word for word in part.split("<") if word)

def read_mrz_name(field):
    """Split an MRZ name field into (primary, secondary, may_be_truncated)."""
    if not re.fullmatch(r"[A-Z<]+", field):
        raise ValueError("a name field holds only A-Z and the filler <")
    may_be_truncated = not field.endswith("<")
    body = field.rstrip("<")
    primary, _separator, secondary = body.partition("<<")
    return words(primary), words(secondary), may_be_truncated

partition은 첫 번째 <<에서만 나누고, 그것이 없으면 빈 2차 부분을 돌려줍니다. 이것이 이름이 하나인 경우입니다. 패턴이 숫자를 거부하는 것은 의도된 것입니다. 이름 필드의 0은 O를 잘못 읽은 것이므로, 이름으로 출력되기보다 사람에게 넘어가야 합니다.

JavaScript로 같은 분할

export function readMrzName(field) {
  if (!/^[A-Z<]+$/.test(field)) throw new Error("a name field holds only A-Z and the filler <");
  const mayBeTruncated = !field.endsWith("<");
  const body = field.replace(/<+$/, "");
  const at = body.indexOf("<<");
  const words = (part) => part.split("<").filter(Boolean).join(" ");
  if (at < 0) return { primary: words(body), secondary: "", mayBeTruncated };
  return { primary: words(body.slice(0, at)), secondary: words(body.slice(at + 2)), mayBeTruncated };
}

영역과 인쇄된 이름 비교하기

인쇄된 이름은 사람이 읽는 페이지 부분인 육안 판독 영역에서 악센트와 문장 부호가 있는 그대로 가져옵니다. 아래 함수는 그것을 순수한 대문자로 줄이고 영역 이름의 한 부분과 비교합니다. 반환값은 세 단어 중 하나입니다: match, mismatch, 그리고 인쇄된 이름에 Ø, Æ, Ł처럼 그 자체로는 A–Z로 줄여지지 않는 글자가 있을 때의 unknown.

import re
import unicodedata

# The spellings to try besides plain removal of the accent. These three are
# an example; put in the ones your issuers use.
EXPANSIONS = {"Ä": "AE", "Ö": "OE", "Ü": "UE"}

def compare_name(zone_part, printed_part, truncated=False, expansions=EXPANSIONS):
    """'match', 'mismatch' or 'unknown' for one name part: zone against printed."""
    zone = zone_part.replace(" ", "")
    upper = unicodedata.normalize("NFC", printed_part).upper()
    candidates = {upper, "".join(expansions.get(c, c) for c in upper)}
    verdict = "unknown"
    for candidate in candidates:
        letters = "".join(
            c for c in unicodedata.normalize("NFKD", candidate)
            if c.isalpha() and not unicodedata.combining(c)
        )
        if not re.fullmatch(r"[A-Z]*", letters):
            continue  # a letter with no plain A-Z form: leave it to a person
        if letters == zone or (truncated and letters.startswith(zone)):
            return "match"
        verdict = "mismatch"
    return verdict

NFKD는 악센트가 붙은 글자를 일반 글자와 악센트로 분해하고, 악센트는 버려집니다. 비교는 글자로만 하므로 공백, 하이픈, 아포스트로피도 버려집니다: Smith-Jones와 SMITH JONES는 둘 다 SMITHJONES가 됩니다. 라틴 문자가 아닌 문자 체계로만 인쇄된 이름이라면, 자국 문자 이름이 아니라 페이지에 인쇄된 라틴 문자 버전과 영역을 비교하십시오. 자국 문자로는 이 함수가 매번 unknown을 반환합니다.

테스트 벡터

ICAO 견본을 제외한 모든 이름은 지어낸 것입니다. 첫 번째 표에서 각 필드는 <로 TD3 폭인 39자까지 채웁니다(Python에서는 name.ljust(39, "<")). 단, 마지막 행은 이미 정확히 39자입니다.

채우기 전 필드 1차 식별자 2차 식별자 잘렸을 가능성
ERIKSSON<<ANNA<MARIA ERIKSSON ANNA MARIA 아니요
VAN<DER<BERG<<PIETER VAN DER BERG PIETER 아니요
SMITH<JONES<<MARY SMITH JONES MARY 아니요
OKONKWO OKONKWO (비어 있음) 아니요
NAKAMURA<< NAKAMURA (비어 있음) 아니요
HARTMANN<SCHOENEBECK<<KONSTANTIN<FRIEDR HARTMANN SCHOENEBECK KONSTANTIN FRIEDR 예

그리고 compare_name의 경우:

영역 부분 인쇄된 부분 잘림 기대값
ANNA MARIA Anna Maria 아니요 match
SMITH JONES Smith-Jones 아니요 match
MULLER Müller 아니요 match
MUELLER Müller 아니요 match(확장을 통해)
MILLER Müller 아니요 mismatch
KONSTANTIN FRIEDR Konstantin Friedrich 예 match
KONSTANTIN FRIEDR Konstantin Friedrich 아니요 mismatch
SORENSEN Sørensen 아니요 unknown

마지막 행은 의도된 것입니다. Ø가 O가 되었는지 OE가 되었는지는 발급 기관의 선택이며, 추측하는 함수는 누군가에게는 틀리게 됩니다. 대신 실제로 다루는 발급 기관이 쓰는 형태를 확장 목록에 추가하십시오.

이 사이트의 MRZ 파서가 하는 일

이 사이트의 MRZ 파서도 같은 방식으로 이름을 읽습니다. 끝의 필러를 제거하고, 첫 번째 <<에서 나누고, 단일 필러를 공백으로 바꿉니다. <<가 없는 필드는 성만 있는 것으로 읽습니다. 이름이 필드의 마지막 글자까지 차 있으면, 이름이 필드에 맞게 잘렸을 수 있다는 경고를 이름이 차지하는 위치와 함께 보여 줍니다. 브라우저에서 실행되므로, 합성한 영역을 붙여 넣고 그 판독 결과를 직접 작성한 코드와 비교해 볼 수 있습니다.

파이프라인에서의 위치

호스팅형 인식 서비스는 영역과 인쇄된 페이지를 모두 읽으므로, 문제는 어느 이름을 넘겨주느냐입니다. doc.cheap 응답에서 holder.surname, holder.given_names, holder.full_name은 통합된 라틴 문자 판독 결과를 담습니다. 자국 문자로 인쇄된 이름은 fields의 별도 항목입니다. 각 판독마다 자체 항목이 있고, 기본 라틴 문자 항목은 id가 @0으로 끝나며, 자국 문자 항목은 language(예: Greek)를 밝힙니다. 영역 자체는 읽은 그대로 mrz.lines에 있으므로, 이름 필드를 잘라 내어 위의 두 함수를 실행할 수 있습니다. 라틴 문자가 아닌 문자 체계 가이드(영문)에서 판독 결과를 묶고 두 철자를 모두 저장하는 방법을 보여 줍니다. 아직 서비스를 고르는 중이라면 여권 OCR API 비교에서 공개된 가격을 나란히 볼 수 있습니다.

위 코드가 잘못 읽는 이름을 발견하면 admin@doc.cheap으로 알려 주십시오.

마지막으로 질문 하나: 영역과 인쇄된 이름을 비교했을 때 가장 놀라웠던 음역은 무엇이었나요? 아래 댓글로 알려 주세요.