हर पासपोर्ट के फ़ोटो वाले पेज के नीचे अजीब-सी दिखने वाली टेक्स्ट की दो लाइनें होती हैं: कैपिटल अक्षर, अंक और ढेर सारे < चिह्न। यही मशीन-रीडेबल ज़ोन यानी MRZ है, और इसे ICAO Doc 9303 परिभाषित करता है, जो यात्रा दस्तावेज़ों का मानक है। इसमें वही मूल जानकारी होती है जो छपे हुए पेज पर है (नाम, दस्तावेज़ नंबर, राष्ट्रीयता, जन्म तिथि, लिंग, समाप्ति तिथि), पर ऐसे रूप में जिसे स्कैनर फ़ॉन्ट या लेआउट का अंदाज़ा लगाए बिना पढ़ सके।

इसमें गलतियाँ पकड़ने का अपना इंतज़ाम भी है। कुछ कैरेक्टर चेक डिजिट (check digit) होते हैं: हर एक किसी ख़ास फ़ील्ड से निकाला जाता है, और एक आख़िरी अंक एक साथ कई फ़ील्ड को कवर करता है। अगर एक भी कैरेक्टर गलत पढ़ा जाए, तो उसकी रक्षा करने वाला अंक आम तौर पर मेल खाना बंद कर देता है। इसी वजह से MRZ पहचान-दस्तावेज़ों की प्रोसेसिंग की उन गिनी-चुनी चीज़ों में से है जिन्हें आप ख़ुद जाँच सकते हैं, बीस लाइन के कोड से और किसी के OCR पर भरोसा किए बिना।

यह पोस्ट एल्गोरिदम समझाती है, बताती है कि तीनों MRZ फ़ॉर्मैट में हर अंक कहाँ होता है, और Python व JavaScript में एक वैलिडेटर देती है जिसे आप अपने प्रोजेक्ट में पेस्ट कर सकते हैं। हर उदाहरण ICAO के अपने काल्पनिक नमूने का इस्तेमाल करता है: "Utopia" की Anna Maria Eriksson (UTO एक ऐसा देश कोड है जो सिर्फ़ नमूनों में मौजूद है)। कहीं भी कोई असली दस्तावेज़ नहीं है।

यह doc.cheap का ब्लॉग है। doc.cheap एक डॉक्यूमेंट-रिकग्निशन API है जो MRZ पढ़ता है और सर्वर पर इन अंकों को दोबारा जाँचता है। नीचे की किसी भी चीज़ के लिए इसकी ज़रूरत नहीं; कोड ऑफ़लाइन चलता है।

वर्णमाला

MRZ में ठीक 37 कैरेक्टर इस्तेमाल होते हैं: 0-9, A-Z और फ़िलर <। कोई लोअर-केस अक्षर नहीं, कोई स्पेस नहीं और कोई विराम चिह्न नहीं। एक्सेंट वाले या गैर-लैटिन लिपि के नाम लिप्यंतरित (transliterate) किए जाते हैं, और फ़ील्ड के अंदर के स्पेस < बन जाते हैं। फ़िलर हर फ़ील्ड को उसकी तय चौड़ाई तक भरता भी है, इसलिए ERIKSSON<<ANNA<MARIA<<<<<<< का मतलब है "उपनाम ERIKSSON, दिए गए नाम ANNA MARIA", जहाँ दोहरा << उपनाम को दिए गए नामों से अलग करता है।

एल्गोरिदम: वेट 7, 3, 1

हर फ़ॉर्मैट के हर फ़ील्ड के लिए चेक डिजिट एक ही तरीके से निकाला जाता है:

  1. हर कैरेक्टर को एक संख्या में बदलें। अंक का मान वही अंक है। अक्षर का मान वर्णमाला में उसकी जगह जमा 9 है, यानी A = 10, B = 11, … Z = 35। फ़िलर < का मान 0 है।
  2. बार-बार दोहराए जाने वाले वेट से गुणा करें: 7, 3, 1, 7, 3, 1, …, फ़ील्ड के पहले कैरेक्टर से शुरू करके।
  3. गुणनफलों को जोड़ें और 10 से भाग देकर शेषफल (modulo 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 ID कार्ड, रेज़िडेंस परमिट
TD2 2 × 36 पुराने ID कार्ड और कुछ यात्रा दस्तावेज़
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 को फ़ील्ड में पार्स करते हैं, उन्हें नॉर्मलाइज़ करते हैं (तारीखें ISO में, नाम स्पेस के साथ) और फिर अंक जाँचने के लिए वापस सीरियलाइज़ करते हैं, तो आप असल में अपने सीरियलाइज़र को जाँच रहे हैं। कच्ची लाइनें वैसे ही जाँचें जैसी वे पढ़ी गई थीं।

वैलिडेट करने से पहले OCR आउटपुट को नॉर्मलाइज़ करें, पर सावधानी से। OCR इंजन अक्सर लोअर-केस अक्षर, स्पेस, या < की जगह « लौटाते हैं। अपर-केस करना और व्हाइटस्पेस हटाना सुरक्षित है। "दस्तावेज़ नंबर तो संख्याएँ ही होते हैं" सोचकर O को 0 से बदलना सुरक्षित नहीं है: दस्तावेज़ नंबर में अक्षर हो सकते हैं, और L898902C3 ठीक यही दिखाता है।

सही चेक डिजिट का मतलब सही तारीख नहीं। 740812 अपना चेक डिजिट पास करता है, चाहे 12 अगस्त 1974 की तारीख संभव लगे या नहीं, और YYMMDD में सदी नहीं होती। सदी संदर्भ से तय करें: जन्म तिथि अतीत में होती है, समाप्ति तिथि आम तौर पर भविष्य में।

TD1 पर लंबे दस्तावेज़ नंबर। ICAO नौ कैरेक्टर से लंबे TD1 दस्तावेज़ नंबर को वैकल्पिक डेटा वाले फ़ील्ड में आगे जाने देता है: सामान्य चेक डिजिट की जगह पर < होता है, और चेक डिजिट नंबर के आख़िरी कैरेक्टर के बाद आता है। ऊपर का वैलिडेटर इस मामले को नहीं संभालता। अगर आप ऐसे जारीकर्ताओं के ID कार्ड प्रोसेस करते हैं जो यह तरीका अपनाते हैं, तो एक अलग ब्रांच जोड़ें; तुलना के लिए कुछ चाहिए तो MRZ पार्सर इसे संभालता है।

चेक डिजिट प्रामाणिकता का सबूत नहीं है। जो कोई इमेज एडिट कर सकता है, वह सही अंक भी निकाल सकता है। MRZ आपको बताता है कि ज़ोन सही पढ़ा गया और अंदर से सुसंगत है, यह नहीं कि दस्तावेज़ असली है। MRZ की तुलना छपे हुए विज़ुअल ज़ोन से करना ज़्यादा मज़बूत संकेत है, और वह भी जालसाज़ी की जाँच नहीं है।

असली पासपोर्ट के बिना टेस्ट डेटा

इस कोड को टेस्ट करने के लिए आपको कभी किसी असली व्यक्ति के पासपोर्ट की ज़रूरत नहीं पड़नी चाहिए। दो विकल्प हैं:

  • ऊपर दिए गए ICAO नमूने, जो ठीक इसी काम के लिए प्रकाशित किए गए हैं।
  • अपने नमूने ख़ुद बनाएँ: MRZ जनरेटर आपके टाइप किए गए मानों से सही चेक डिजिट वाला सिंथेटिक TD3 ज़ोन ब्राउज़र में ही बनाता है। बाद में एक कैरेक्टर बदल दें, और फ़ेल होने वाला टेस्ट केस तैयार है।

उल्टी दिशा के लिए कोई भी ज़ोन (TD1, TD2 या TD3) MRZ पार्सर में पेस्ट करें: यह फ़ॉर्मैट पहचानता है, हर फ़ील्ड पढ़ता है और हर निकाले गए चेक डिजिट को छपे हुए अंक के बगल में दिखाता है, सब कुछ ब्राउज़र में। जब आपके इम्प्लीमेंटेशन और किसी और के इम्प्लीमेंटेशन के नतीजे अलग हों, तब यह काम आता है।

असली पाइपलाइन में इसकी जगह

अगर आप अपने OCR से MRZ पढ़ते हैं, तो हर बार पढ़ने पर ये जाँचें चलाएँ और फ़ेल होने का मतलब "दोबारा फ़ोटो लें" समझें, "व्यक्ति को रिजेक्ट करें" नहीं: किसी एक कैरेक्टर पर चमक, घिसा हुआ लैमिनेट या मुड़ा हुआ पेज धोखाधड़ी से कहीं ज़्यादा आम हैं।

अगर आप इसके बजाय कोई होस्टेड रिकग्निशन API इस्तेमाल करते हैं, तब भी जिस नतीजे पर पैसा या एक्सेस टिका हो, उसके अंक ख़ुद दोबारा जाँचें। जवाब का यही एक हिस्सा है जिसे आप वेंडर पर भरोसा किए बिना जाँच सकते हैं। इसमें हम भी शामिल हैं: doc.cheap का रिस्पॉन्स ज़ोन को हूबहू mrz.lines और mrz.text (लाइनें बिना किसी विभाजक के जोड़कर) के रूप में अपने mrz.status फ़ैसले के बगल में देता है, ठीक इसलिए कि आप उसे ऊपर जैसे किसी फ़ंक्शन में डाल सकें। डॉक्यूमेंटेशन की गाइड MRZ जाँचें यही फ़्लो समझाती है।

अगर आपको कोई ऐसा मामला मिले जिसमें वैलिडेटर गलत नतीजा देता है, तो admin@doc.cheap पर लिखें।

दोनों कोड ब्लॉक चलाए गए और उनका आउटपुट ठीक वैसा ही दिया गया है जैसा छपा था; doc.cheap के बारे में हर बात उसके कोड से जाँची गई।