Hztech commited on
Commit
2ab85df
·
verified ·
1 Parent(s): e556588

Upload 6 files

Browse files
Files changed (6) hide show
  1. .gitignore +13 -0
  2. README.md +13 -0
  3. app.py +231 -0
  4. apt.txt +1 -0
  5. extractor.py +699 -0
  6. requirements.txt +9 -0
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ .env
5
+ .venv
6
+ env/
7
+ venv/
8
+ ENV/
9
+ env.bak/
10
+ venv.bak/
11
+ .gradio/
12
+ flagged/
13
+ data/
README.md ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Passport Extractor 2
3
+ emoji: 🛂
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: gradio
7
+ python_version: '3.10'
8
+ app_file: app.py
9
+ pinned: false
10
+ sdk_version: 6.20.0
11
+ ---
12
+ # Passport OCR Tool
13
+ A robust tool to extract data from passport images (PNG, JPG) and PDFs using OCR. It extracts MRZ (Machine Readable Zone) data and exports it to Excel or CSV formats.
app.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import tempfile
4
+ from pathlib import Path
5
+ import pandas as pd
6
+
7
+ # Keep Gradio on client-side rendering. In hosted Gradio 6 runs, SSR starts a
8
+ # Node proxy that can emit noisy asyncio cleanup tracebacks at startup/shutdown.
9
+ os.environ.setdefault("GRADIO_SSR_MODE", "false")
10
+
11
+ import gradio as gr
12
+ import time
13
+
14
+ # Ensure project root is in python path
15
+ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
16
+
17
+ try:
18
+ import spaces
19
+ except ImportError:
20
+ spaces = None
21
+
22
+ from extractor import (
23
+ PassportExtractor,
24
+ format_fly_baghdad,
25
+ format_fly_dubai,
26
+ format_iraqi,
27
+ )
28
+
29
+
30
+ def gpu_task(duration=120):
31
+ if spaces is None:
32
+ return lambda fn: fn
33
+ return spaces.GPU(duration=duration)
34
+
35
+
36
+ # Initialize Extractor
37
+ print("Initializing Passport Extractor...")
38
+ EXTRACTOR = PassportExtractor(use_gpu=True)
39
+ print("Extractor initialized.")
40
+
41
+
42
+ MAX_FILE_SIZE_MB = 100
43
+ ALLOWED_FILE_TYPES = [
44
+ ".png",
45
+ ".jpg",
46
+ ".jpeg",
47
+ ".pdf",
48
+ ".avif",
49
+ ".webp",
50
+ ".bmp",
51
+ ".tiff",
52
+ ]
53
+
54
+
55
+ def _format_data(good_results, airline):
56
+ if airline == "Fly Dubai":
57
+ return format_fly_dubai(good_results)
58
+ if airline == "Iraqi":
59
+ return format_iraqi(good_results)
60
+ if airline == "Fly Baghdad":
61
+ return format_fly_baghdad(good_results)
62
+
63
+ # Default view
64
+ df = pd.DataFrame(good_results)
65
+ if not df.empty:
66
+ from extractor import format_date
67
+ if "date_of_birth" in df.columns:
68
+ df["date_of_birth"] = df["date_of_birth"].apply(lambda x: format_date(x, "%d/%m/%Y", is_dob=True))
69
+ if "expiration_date" in df.columns:
70
+ df["expiration_date"] = df["expiration_date"].apply(lambda x: format_date(x, "%d/%m/%Y", is_dob=False))
71
+ return df
72
+
73
+
74
+ @gpu_task(duration=300)
75
+ def process_files(files, airline, export_all_files, progress=gr.Progress()):
76
+ if not files:
77
+ return None, "No files uploaded.", None, None
78
+ start_time = time.time()
79
+ all_results, problematic_files = [], []
80
+ total_files = len(files)
81
+ from concurrent.futures import ThreadPoolExecutor, as_completed
82
+
83
+ def _uploaded_path(file_obj):
84
+ if isinstance(file_obj, (str, os.PathLike)):
85
+ return os.fspath(file_obj)
86
+ if hasattr(file_obj, "name"):
87
+ return file_obj.name
88
+ if isinstance(file_obj, dict):
89
+ return file_obj.get("path") or file_obj.get("name")
90
+ return None
91
+
92
+ def process_single_file(file_obj):
93
+ file_path = _uploaded_path(file_obj)
94
+ if not file_path:
95
+ return [], {
96
+ "file_name": "unknown",
97
+ "reason": f"Unsupported upload object: {type(file_obj).__name__}",
98
+ }
99
+
100
+ file_name = Path(file_path).name
101
+ ext = os.path.splitext(file_name)[1].lower()
102
+ file_results, file_problem = [], None
103
+ if os.path.getsize(file_path) > MAX_FILE_SIZE_MB * 1024 * 1024:
104
+ return [], {
105
+ "file_name": file_name,
106
+ "reason": f"File too large. Max {MAX_FILE_SIZE_MB} MB allowed.",
107
+ }
108
+ try:
109
+ if ext == ".pdf":
110
+
111
+ def pdf_progress(p):
112
+ progress(
113
+ completed_files / total_files + (p * (1 / total_files)),
114
+ desc=f"Processing {file_name}...",
115
+ )
116
+
117
+ file_results = (
118
+ EXTRACTOR.process_pdf(
119
+ file_path,
120
+ progress_callback=pdf_progress,
121
+ airline=(airline or "Default").lower(),
122
+ )
123
+ or []
124
+ )
125
+ else:
126
+ result = EXTRACTOR.get_data(
127
+ file_path, airline=(airline or "Default").lower()
128
+ )
129
+ if result:
130
+ file_results = [result]
131
+ except Exception as e:
132
+ file_problem = {"file_name": file_name, "reason": f"Error: {e}"}
133
+ mrz_found = False
134
+ for res in file_results:
135
+ res["source_file"] = file_name
136
+ if res.get("mrz_found"):
137
+ mrz_found = True
138
+ if not file_problem:
139
+ if not file_results:
140
+ file_problem = {
141
+ "file_name": file_name,
142
+ "reason": "No passport data detected",
143
+ }
144
+ elif not mrz_found:
145
+ file_problem = {"file_name": file_name, "reason": "MRZ data not found"}
146
+ return file_results, file_problem
147
+
148
+ completed_files = 0
149
+ with ThreadPoolExecutor(max_workers=4) as executor:
150
+ future_to_file = {executor.submit(process_single_file, f): f for f in files}
151
+ for future in as_completed(future_to_file):
152
+ completed_files += 1
153
+ try:
154
+ file_results, file_problem = future.result()
155
+ except Exception as e:
156
+ original_path = _uploaded_path(future_to_file[future]) or "unknown"
157
+ file_results = []
158
+ file_problem = {
159
+ "file_name": Path(original_path).name,
160
+ "reason": f"Error: {e}",
161
+ }
162
+ all_results.extend(file_results)
163
+ if file_problem:
164
+ problematic_files.append(file_problem)
165
+
166
+ good_results = [r for r in all_results if r.get("mrz_found")]
167
+ end_time = time.time()
168
+ duration = round(end_time - start_time, 2)
169
+
170
+ if not good_results:
171
+ status_msg = f"⚠️ No data could be extracted. (Time: {duration}s)"
172
+ if problematic_files:
173
+ status_msg += "\n\nProblems:\n" + "\n".join(
174
+ [f"- {p['file_name']}: {p['reason']}" for p in problematic_files]
175
+ )
176
+ return None, status_msg, None, None
177
+
178
+ df = _format_data(good_results, airline)
179
+ status_msg = f"Extracted {len(good_results)} passport(s) from {total_files} file(s) in {duration} seconds."
180
+ if problematic_files:
181
+ status_msg += "\n\nProblems:\n" + "\n".join(
182
+ [f"- {p['file_name']}: {p['reason']}" for p in problematic_files]
183
+ )
184
+
185
+ csv_path = tempfile.mktemp(suffix=".csv")
186
+ df.to_csv(csv_path, index=False)
187
+ excel_path = tempfile.mktemp(suffix=".xlsx")
188
+ df.to_excel(excel_path, index=False)
189
+ return df, status_msg, csv_path, excel_path
190
+
191
+
192
+ def create_demo():
193
+ with gr.Blocks(title="Passport OCR Extractor") as demo:
194
+ gr.Markdown("# Passport OCR Extractor")
195
+ with gr.Row():
196
+ with gr.Column(scale=1):
197
+ airline = gr.Dropdown(
198
+ choices=["Default", "Fly Dubai", "Iraqi", "Fly Baghdad"],
199
+ value="Default",
200
+ label="Airline Format",
201
+ )
202
+ file_input = gr.File(
203
+ file_count="multiple",
204
+ label="Upload Passports",
205
+ file_types=ALLOWED_FILE_TYPES,
206
+ )
207
+ extract_btn = gr.Button("Extract Data", variant="primary")
208
+ with gr.Column(scale=2):
209
+ status_output = gr.Markdown()
210
+ table_output = gr.Dataframe(label="Extracted Data")
211
+ with gr.Row():
212
+ csv_download = gr.File(label="Download CSV")
213
+ excel_download = gr.File(label="Download Excel")
214
+ extract_btn.click(
215
+ fn=process_files,
216
+ inputs=[file_input, airline, gr.State(False)],
217
+ outputs=[table_output, status_output, csv_download, excel_download],
218
+ )
219
+ return demo
220
+
221
+
222
+ demo = create_demo()
223
+
224
+
225
+ def main():
226
+ port = int(os.environ.get("PORT", "7860"))
227
+ demo.launch(server_name="0.0.0.0", server_port=port, show_error=True, ssr_mode=False)
228
+
229
+
230
+ if __name__ == "__main__":
231
+ main()
apt.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ tesseract-ocr
extractor.py ADDED
@@ -0,0 +1,699 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import cv2
3
+ import numpy as np
4
+
5
+ # On Hugging Face GPU Spaces, the `spaces` package must be imported before
6
+ # EasyOCR/Torch touches CUDA, otherwise Gradio's Spaces watcher can crash.
7
+ try:
8
+ import spaces # noqa: F401
9
+ except ImportError:
10
+ pass
11
+
12
+ import easyocr
13
+ import ssl
14
+ import re
15
+ import string as st
16
+ import logging
17
+ import pandas as pd
18
+ import tempfile
19
+ import fitz # PyMuPDF
20
+ import shutil
21
+ import pycountry
22
+ from datetime import datetime, date
23
+ from passporteye import read_mrz
24
+
25
+ # ---------------------------------------------------
26
+ # CONFIGURATION & SETTINGS
27
+ # ---------------------------------------------------
28
+ OCR_LANGUAGES = ["en"]
29
+ USE_GPU = True
30
+
31
+ # ---------------------------------------------------
32
+ # LOGGING & UTILS
33
+ # ---------------------------------------------------
34
+ logging.basicConfig(
35
+ level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
36
+ )
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ def clean_string(text):
41
+ if not text:
42
+ return ""
43
+ return "".join(i for i in str(text) if i.isalnum()).upper()
44
+
45
+
46
+ def clean_name_field(text):
47
+ if not text:
48
+ return ""
49
+ text = str(text).upper()
50
+ text = re.sub(r"([<KG]){6,}$", "", text)
51
+ text = text.replace("<", " ")
52
+ text = "".join(c for c in text if not c.isdigit())
53
+ tokens = [token for token in text.split() if token]
54
+ while tokens and (
55
+ re.fullmatch(r"([A-Z])\1{2,}", tokens[-1])
56
+ or re.fullmatch(r"[KG]{2,}", tokens[-1])
57
+ ):
58
+ tokens.pop()
59
+ return " ".join(tokens).strip()
60
+
61
+
62
+ def parse_mrz_line1(line1):
63
+ if not line1:
64
+ return "", "", ""
65
+ line1 = clean_mrz_line(line1)
66
+ issuing_country = normalize_country_code(line1[2:5])
67
+
68
+ name_field = line1[5:44] if len(line1) >= 6 else line1
69
+ name_field = re.sub(r"([<KG]){6,}$", "", name_field)
70
+ parts = name_field.split("<<", 1)
71
+ surname = clean_name_field(parts[0])
72
+ given = clean_name_field(parts[1] if len(parts) > 1 else "")
73
+ return issuing_country, surname, given
74
+
75
+
76
+ def clean_mrz_line(line):
77
+ if not line:
78
+ return ""
79
+ line = line.upper().replace(" ", "")
80
+ allowed = set(st.ascii_uppercase + st.digits + "<")
81
+ line = "".join([c for c in line if c in allowed])
82
+ p_start = line.find("P<")
83
+ if 0 <= p_start <= 3:
84
+ line = line[p_start:]
85
+ elif len(line) > 44:
86
+ line = line[-44:]
87
+ if len(line) < 44:
88
+ line += "<" * (44 - len(line))
89
+ return line[:44]
90
+
91
+
92
+ def mrz_check_digit(value):
93
+ weights = [7, 3, 1]
94
+ total = 0
95
+ for i, char in enumerate(value):
96
+ if char.isdigit():
97
+ number = int(char)
98
+ elif "A" <= char <= "Z":
99
+ number = ord(char) - ord("A") + 10
100
+ elif char == "<":
101
+ number = 0
102
+ else:
103
+ number = 0
104
+ total += number * weights[i % 3]
105
+ return str(total % 10)
106
+
107
+
108
+ def normalize_mrz_digits(text):
109
+ table = str.maketrans(
110
+ {"O": "0", "Q": "0", "D": "0", "I": "1", "L": "1", "S": "5", "B": "8"}
111
+ )
112
+ return (text or "").upper().translate(table)
113
+
114
+
115
+ def valid_yymmdd(text):
116
+ text = normalize_mrz_digits(text)
117
+ if len(text) != 6 or not text.isdigit():
118
+ return False
119
+ try:
120
+ month = int(text[2:4])
121
+ day = int(text[4:6])
122
+ return 1 <= month <= 12 and 1 <= day <= 31
123
+ except:
124
+ return False
125
+
126
+
127
+ def normalize_country_code(code):
128
+ if not code:
129
+ return ""
130
+ code = code.upper().translate(
131
+ str.maketrans({"0": "O", "1": "I", "2": "Z", "4": "A", "5": "S", "8": "B"})
132
+ )
133
+ code = "".join(c for c in code if c.isalpha())
134
+ if len(code) != 3:
135
+ return ""
136
+ if pycountry.countries.get(alpha_3=code):
137
+ return code
138
+ return ""
139
+
140
+
141
+ def clean_mrz_line2(line):
142
+ if not line:
143
+ return ""
144
+ line = line.upper().replace(" ", "")
145
+ allowed = set(st.ascii_uppercase + st.digits + "<")
146
+ line = "".join([c for c in line if c in allowed])
147
+ if len(line) <= 44:
148
+ return (line + "<" * (44 - len(line)))[:44]
149
+
150
+ def score(candidate):
151
+ doc = candidate[0:9]
152
+ doc_check = candidate[9]
153
+ nationality = normalize_country_code(candidate[10:13])
154
+ dob = normalize_mrz_digits(candidate[13:19])
155
+ dob_check = candidate[19]
156
+ expiry = normalize_mrz_digits(candidate[21:27])
157
+ expiry_check = candidate[27]
158
+ value = 0
159
+ value += 12 if doc_check.isdigit() and mrz_check_digit(doc) == doc_check else 0
160
+ value += 10 if nationality else 0
161
+ value += 14 if valid_yymmdd(dob) else 0
162
+ value += 18 if dob_check.isdigit() and mrz_check_digit(dob) == dob_check else 0
163
+
164
+ # Sex field score with OCR error tolerance
165
+ sex_char = candidate[20]
166
+ if sex_char in "MFX<":
167
+ value += 8
168
+ elif sex_char in "HN04KW0": # Likely M
169
+ value += 6
170
+ elif sex_char in "EP735S": # Likely F
171
+ value += 6
172
+
173
+ value += 14 if valid_yymmdd(expiry) else 0
174
+ value += 18 if expiry_check.isdigit() and mrz_check_digit(expiry) == expiry_check else 0
175
+ value += sum(1 for c in candidate[13:19] + candidate[21:27] if c in st.digits + "OQDISBL")
176
+ value -= sum(1 for c in candidate[:9] if c == "<")
177
+ return value
178
+
179
+ windows = [line[i : i + 44] for i in range(0, len(line) - 43)]
180
+ return max(windows, key=score)
181
+
182
+
183
+ def clean_mrz_date(text):
184
+ return normalize_mrz_digits(text)
185
+
186
+
187
+ def parse_mrz_line2(line):
188
+ line = clean_mrz_line2(line)
189
+ if not line:
190
+ return {}
191
+
192
+ passport_number = clean_string(line[0:9]).replace("O", "0")
193
+ nationality = normalize_country_code(line[10:13])
194
+ dob = normalize_mrz_digits(line[13:19])
195
+ sex = get_sex(line[20])
196
+ expiry = normalize_mrz_digits(line[21:27])
197
+
198
+ dob_ok = valid_yymmdd(dob) and line[19].isdigit() and mrz_check_digit(dob) == line[19]
199
+ expiry_ok = valid_yymmdd(expiry) and line[27].isdigit() and mrz_check_digit(expiry) == line[27]
200
+
201
+ return {
202
+ "passport_number": passport_number,
203
+ "nationality": nationality,
204
+ "date_of_birth": dob if dob_ok else "",
205
+ "sex": sex,
206
+ "expiration_date": expiry if expiry_ok else "",
207
+ }
208
+
209
+
210
+ def get_country_name(code):
211
+ code = normalize_country_code(str(code))
212
+ country = pycountry.countries.get(alpha_3=code) if code else None
213
+ return country.name.upper() if country else code
214
+
215
+
216
+ def get_sex(code):
217
+ if not code:
218
+ return ""
219
+ code = str(code).upper().strip()
220
+
221
+ # Standard values
222
+ if code in ["M", "F"]:
223
+ return code
224
+
225
+ # Common OCR misreadings for Male (M)
226
+ if code in ["H", "N", "0", "4", "K", "W"]:
227
+ return "M"
228
+
229
+ # Common OCR misreadings for Female (F)
230
+ if code in ["E", "P", "7", "3", "5", "S"]:
231
+ return "F"
232
+
233
+ return ""
234
+
235
+
236
+ # ---------------------------------------------------
237
+ # FORMATTING
238
+ # ---------------------------------------------------
239
+ def parse_any_date(date_val, is_dob=True):
240
+ """
241
+ Robustly parses a date from various formats and types.
242
+ Handles century correction for Date of Birth.
243
+ """
244
+ if not date_val:
245
+ return None
246
+ if isinstance(date_val, (date, datetime)):
247
+ if isinstance(date_val, datetime):
248
+ date_val = date_val.date()
249
+ # Still apply century correction if it's a DOB and in the future
250
+ if is_dob and date_val > date.today():
251
+ date_val = date_val.replace(year=date_val.year - 100)
252
+ return date_val
253
+
254
+ date_str = str(date_val).strip()
255
+ if not date_str or "•" in date_str:
256
+ return None
257
+
258
+ # Remove common OCR noise but keep alphanumeric and basic separators
259
+ clean_str = re.sub(r"[^0-9A-Z/\-\s]", "", date_str.upper())
260
+
261
+ # Try parsing with various formats
262
+ parsed_date = None
263
+
264
+ # 1. Try MRZ format (YYMMDD) if it's 6 digits
265
+ digits_only = "".join(c for c in clean_str if c.isdigit())
266
+ if len(digits_only) == 6:
267
+ try:
268
+ parsed_date = datetime.strptime(digits_only, "%y%m%d").date()
269
+ # For 2-digit years, strptime uses a 69-99 -> 19xx, 00-68 -> 20xx rule.
270
+ # We override this for DOB to ensure it's not in the future.
271
+ if is_dob and parsed_date > date.today():
272
+ parsed_date = parsed_date.replace(year=parsed_date.year - 100)
273
+ return parsed_date
274
+ except:
275
+ pass
276
+
277
+ # 2. Try common human-readable formats
278
+ for fmt in ("%d/%m/%Y", "%d/%m/%y", "%d-%m-%Y", "%d-%m-%y", "%d%b%y", "%Y%m%d", "%d %b %Y"):
279
+ try:
280
+ parsed_date = datetime.strptime(date_str, fmt).date()
281
+ # If we used a 2-digit year format, apply century correction
282
+ if "%y" in fmt and is_dob and parsed_date > date.today():
283
+ parsed_date = parsed_date.replace(year=parsed_date.year - 100)
284
+ return parsed_date
285
+ except:
286
+ continue
287
+
288
+ return None
289
+
290
+
291
+ def calculate_passenger_type(dob):
292
+ """
293
+ Calculates the Passenger Type Code (PTC) based on the date of birth.
294
+ - Infant (INF): Age < 2 years
295
+ - Child (CHD): 2 <= Age < 12 years
296
+ - Adult (ADT): Age >= 12 years
297
+ """
298
+ parsed_dob = parse_any_date(dob, is_dob=True)
299
+ if not parsed_dob:
300
+ return "Adult", "ADT"
301
+
302
+ try:
303
+ today = date.today()
304
+ age = today.year - parsed_dob.year - ((today.month, today.day) < (parsed_dob.month, parsed_dob.day))
305
+
306
+ if age < 0:
307
+ return "Adult", "ADT"
308
+ if age < 2:
309
+ return "Infant", "INF"
310
+ if age < 12:
311
+ return "Child", "CHD"
312
+ return "Adult", "ADT"
313
+ except:
314
+ return "Adult", "ADT"
315
+
316
+
317
+ def calculate_title(sex, ptc):
318
+ """
319
+ Calculates the Title based on gender and passenger type (PTC).
320
+ - MR: Adult Male
321
+ - MRS: Adult Female
322
+ - MS: Child/Infant Female
323
+ - MSTR: Child/Infant Male (Master)
324
+ """
325
+ sex = str(sex).upper().strip()
326
+ ptc = str(ptc).upper().strip()
327
+
328
+ if sex == "M":
329
+ if ptc == "ADT":
330
+ return "MR"
331
+ return "MSTR"
332
+ elif sex == "F":
333
+ if ptc == "ADT":
334
+ return "MRS"
335
+ return "MS"
336
+
337
+ return "MR" # Default fallback
338
+
339
+
340
+ def format_date(raw_date, fmt="%d/%m/%Y", is_dob=True):
341
+ parsed = parse_any_date(raw_date, is_dob=is_dob)
342
+ if not parsed:
343
+ return str(raw_date) if raw_date else ""
344
+ return parsed.strftime(fmt).upper()
345
+
346
+
347
+ def format_fly_dubai(results):
348
+ rows = []
349
+ for res in results:
350
+ sex = res.get("sex", "").upper()
351
+ raw_dob = res.get("date_of_birth", "")
352
+ raw_exp = res.get("expiration_date", "")
353
+
354
+ dob_formatted = format_date(raw_dob, "%d%b%y", is_dob=True)
355
+ exp_formatted = format_date(raw_exp, "%d%b%y", is_dob=False)
356
+
357
+ _, ptc = calculate_passenger_type(raw_dob)
358
+ title = calculate_title(sex, ptc)
359
+ rows.append(
360
+ {
361
+ "Last Name": res.get("surname", ""),
362
+ "First Name and Middle Name": res.get("name", ""),
363
+ "Title": title,
364
+ "PTC": ptc,
365
+ "Gender": sex,
366
+ "Date of Birth": dob_formatted,
367
+ "Passport Number": res.get("passport_number", ""),
368
+ "Passport Nationality": (res.get("nationality") or "")[:3],
369
+ "Passport Issue Country": (res.get("nationality") or "")[:3],
370
+ "Passport Expiry Date": exp_formatted,
371
+ }
372
+ )
373
+ return pd.DataFrame(rows)
374
+
375
+
376
+ def format_iraqi(results):
377
+ rows = []
378
+ for res in results:
379
+ sex = res.get("sex", "").upper()
380
+ raw_dob = res.get("date_of_birth", "")
381
+ raw_exp = res.get("expiration_date", "")
382
+
383
+ dob_formatted = format_date(raw_dob, "%d/%m/%y", is_dob=True)
384
+ exp_formatted = format_date(raw_exp, "%d/%m/%y", is_dob=False)
385
+
386
+ ptype, ptc = calculate_passenger_type(raw_dob)
387
+ title = calculate_title(sex, ptc)
388
+ nat = (res.get("nationality") or "")[:3]
389
+ rows.append(
390
+ {
391
+ "TYPE": ptype,
392
+ "TITLE": title,
393
+ "FIRST NAME": res.get("name", ""),
394
+ "LAST NAME": res.get("surname", ""),
395
+ "DOB (DD/MM/YYYY)": dob_formatted,
396
+ "GENDER": "Male" if sex == "M" else "Female"
397
+ }
398
+ )
399
+ return pd.DataFrame(rows)
400
+
401
+
402
+ def format_fly_baghdad(results):
403
+ rows = []
404
+ for i, res in enumerate(results):
405
+ sex = res.get("sex", "").upper()
406
+ raw_dob = res.get("date_of_birth", "")
407
+ raw_exp = res.get("expiration_date", "")
408
+
409
+ dob_formatted = format_date(raw_dob, "%d/%m/%Y", is_dob=True)
410
+ exp_formatted = format_date(raw_exp, "%d/%m/%Y", is_dob=False)
411
+
412
+ _, ptc = calculate_passenger_type(raw_dob)
413
+ title = calculate_title(sex, ptc)
414
+ nat = (res.get("nationality") or "")[:3]
415
+ issuing = (res.get("issuing_country") or "")[:3]
416
+ rows.append(
417
+ {
418
+ "Sequence": i + 1,
419
+ "Pax Type": ptc,
420
+ "Title": title,
421
+ "First Name": res.get("name", ""),
422
+ "Last Name": res.get("surname", ""),
423
+ "Gender": "MALE" if sex == "M" else "FEMALE",
424
+ "DOB (dd/mm/yyyy)": dob_formatted,
425
+ "Nationality": get_country_name(nat),
426
+ "Passport Number": res.get("passport_number", ""),
427
+ "Passport Expiry (dd/mm/yyyy)": exp_formatted,
428
+ "Passport Issued Country": get_country_name(issuing)
429
+
430
+ }
431
+ )
432
+ return pd.DataFrame(rows)
433
+
434
+
435
+ # ---------------------------------------------------
436
+ # CORE EXTRACTOR
437
+ # ---------------------------------------------------
438
+ class PassportExtractor:
439
+ def __init__(self, use_gpu=USE_GPU):
440
+ # Fix SSL for model downloads
441
+ try:
442
+ ssl._create_default_https_context = ssl._create_unverified_context
443
+ except:
444
+ pass
445
+
446
+ # Initialize EasyOCR with default system paths
447
+ self.reader = easyocr.Reader(OCR_LANGUAGES, gpu=use_gpu, verbose=False)
448
+ self.has_tesseract = shutil.which("tesseract") is not None
449
+ if not self.has_tesseract:
450
+ logger.info("Tesseract not found. Skipping PassportEye and using EasyOCR.")
451
+
452
+ def crop_mrz_section(self, img_path, top_crop_ratio=0.62):
453
+ img = cv2.imread(img_path)
454
+ if img is None:
455
+ return img_path, False
456
+
457
+ height = img.shape[0]
458
+ crop_start = int(height * top_crop_ratio)
459
+ crop_start = min(max(crop_start, 0), height - 1)
460
+ mrz_img = img[crop_start:height, :]
461
+
462
+ with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
463
+ cropped_path = tmp.name
464
+
465
+ cv2.imwrite(cropped_path, mrz_img)
466
+ return cropped_path, True
467
+
468
+ def preprocess_for_ocr(self, img_path):
469
+ img = cv2.imread(img_path)
470
+ if img is None:
471
+ return []
472
+
473
+ variants = [img]
474
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
475
+ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
476
+ enhanced = clahe.apply(gray)
477
+ variants.append(enhanced)
478
+
479
+ sharp = cv2.GaussianBlur(enhanced, (0, 0), 1.0)
480
+ sharp = cv2.addWeighted(enhanced, 1.6, sharp, -0.6, 0)
481
+ variants.append(sharp)
482
+ return variants
483
+
484
+ def _find_mrz_lines(self, candidates):
485
+ for i, c in enumerate(candidates):
486
+ p_start = c.find("P<")
487
+ if 0 <= p_start <= 3:
488
+ c = c[p_start:]
489
+ for next_line in candidates[i + 1 : i + 4]:
490
+ if len(next_line) >= 35:
491
+ return c, next_line
492
+ for i in range(len(candidates) - 1):
493
+ first, second = candidates[i], candidates[i + 1]
494
+ if len(first) >= 35 and len(second) >= 35:
495
+ return first, second
496
+ return None, None
497
+
498
+ def _mrz_line_boxes(self, image):
499
+ if len(image.shape) == 3:
500
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
501
+ else:
502
+ gray = image
503
+
504
+ _, bw = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
505
+ kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (35, 5))
506
+ dilated = cv2.dilate(bw, kernel, iterations=2)
507
+ contours, _ = cv2.findContours(
508
+ dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
509
+ )
510
+
511
+ h, w = gray.shape
512
+ boxes = []
513
+ for contour in contours:
514
+ x, y, box_w, box_h = cv2.boundingRect(contour)
515
+ if box_w > w * 0.25 and box_h > 8:
516
+ pad = 8
517
+ boxes.append(
518
+ [
519
+ max(0, x - pad),
520
+ min(w, x + box_w + pad),
521
+ max(0, y - pad),
522
+ min(h, y + box_h + pad),
523
+ ]
524
+ )
525
+
526
+ boxes = sorted(boxes, key=lambda box: box[2])[-2:]
527
+ if len(boxes) == 2:
528
+ return sorted(boxes, key=lambda box: box[2])
529
+ return []
530
+
531
+ def extract_mrz_easyocr(self, img_path):
532
+ allow = st.ascii_uppercase + st.digits + "<"
533
+ l1, l2 = None, None
534
+ for image in self.preprocess_for_ocr(img_path):
535
+ boxes = self._mrz_line_boxes(image)
536
+ if not boxes:
537
+ continue
538
+
539
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if len(image.shape) == 3 else image
540
+ results = self.reader.recognize(
541
+ gray,
542
+ horizontal_list=boxes,
543
+ free_list=[],
544
+ detail=0,
545
+ allowlist=allow,
546
+ batch_size=2,
547
+ )
548
+ candidates = [
549
+ "".join([c for c in r.upper().replace(" ", "") if c in allow])
550
+ for r in results
551
+ if len(r) >= 25
552
+ ]
553
+ l1, l2 = self._find_mrz_lines(candidates)
554
+ if l1 and l2:
555
+ return l1, l2
556
+ return l1, l2
557
+
558
+ def extract_mrz_easyocr_slow(self, img_path):
559
+ allow = st.ascii_uppercase + st.digits + "<"
560
+ best = (None, None)
561
+ for image in self.preprocess_for_ocr(img_path):
562
+ results = self.reader.readtext(
563
+ image,
564
+ detail=0,
565
+ allowlist=allow,
566
+ paragraph=False,
567
+ width_ths=0.9,
568
+ ycenter_ths=0.7,
569
+ contrast_ths=0.05,
570
+ adjust_contrast=0.7,
571
+ )
572
+ candidates = [
573
+ "".join([c for c in r.upper().replace(" ", "") if c in allow])
574
+ for r in results
575
+ if len(r) >= 20
576
+ ]
577
+ l1, l2 = self._find_mrz_lines(candidates)
578
+ if l1 and l2:
579
+ return l1, l2
580
+ if candidates:
581
+ best = self._find_mrz_lines(
582
+ [c for c in candidates if len(c) >= 30]
583
+ )
584
+ return best
585
+
586
+ def get_data(self, img_path, airline="iraqi"):
587
+ ocr_path, should_cleanup = self.crop_mrz_section(img_path)
588
+
589
+ # 1. Try PassportEye (Tesseract)
590
+ mrz_data = None
591
+ if self.has_tesseract:
592
+ try:
593
+ mrz = read_mrz(ocr_path)
594
+ if mrz and getattr(mrz, "valid_score", 0) > 20:
595
+ mrz_data = {
596
+ "surname": clean_name_field(getattr(mrz, "surname", "")),
597
+ "name": clean_name_field(getattr(mrz, "names", "")),
598
+ "passport_number": clean_string(getattr(mrz, "number", "")),
599
+ "nationality": clean_string(getattr(mrz, "nationality", "")),
600
+ "issuing_country": clean_string(getattr(mrz, "issuing_state", "")),
601
+ "date_of_birth": clean_string(
602
+ getattr(mrz, "date_of_birth", "")
603
+ ),
604
+ "sex": get_sex(getattr(mrz, "sex", "")),
605
+ "expiration_date": clean_string(
606
+ getattr(mrz, "expiration_date", "")
607
+ ),
608
+ "mrz_found": True,
609
+ }
610
+ except Exception as e:
611
+ logger.info(f"PassportEye failed, using EasyOCR fallback: {e}")
612
+
613
+ # 2. Try EasyOCR direct search if Tesseract failed
614
+ if not mrz_data:
615
+ l1, l2 = self.extract_mrz_easyocr(ocr_path)
616
+ if not (l1 and l2):
617
+ l1, l2 = self.extract_mrz_easyocr_slow(ocr_path)
618
+ if not (l1 and l2) and should_cleanup:
619
+ l1, l2 = self.extract_mrz_easyocr_slow(img_path)
620
+ if l1 and l2:
621
+ try:
622
+ l1, l2 = clean_mrz_line(l1), clean_mrz_line2(l2)
623
+ issuing_country, surname, given_names = parse_mrz_line1(l1)
624
+ line2_data = parse_mrz_line2(l2)
625
+ mrz_data = {
626
+ "surname": surname,
627
+ "name": given_names,
628
+ "issuing_country": issuing_country,
629
+ "passport_number": line2_data.get("passport_number", ""),
630
+ "nationality": line2_data.get("nationality", ""),
631
+ "date_of_birth": line2_data.get("date_of_birth", ""),
632
+ "sex": line2_data.get("sex", ""),
633
+ "expiration_date": line2_data.get("expiration_date", ""),
634
+ "mrz_found": True,
635
+ }
636
+ except:
637
+ pass
638
+
639
+ try:
640
+ if mrz_data:
641
+ # Final fallback for sex if still missing
642
+ if not mrz_data.get("sex"):
643
+ try:
644
+ # Quick check for gender keywords in the whole image
645
+ results = self.reader.readtext(img_path, detail=0)
646
+ text_blob = " ".join(results).upper()
647
+ if "FEMALE" in text_blob:
648
+ mrz_data["sex"] = "F"
649
+ elif "MALE" in text_blob:
650
+ mrz_data["sex"] = "M"
651
+ except:
652
+ pass
653
+
654
+ mrz_data["country"] = get_country_name(mrz_data["nationality"])
655
+ return mrz_data
656
+ return None
657
+ finally:
658
+ if should_cleanup and os.path.exists(ocr_path):
659
+ try:
660
+ os.remove(ocr_path)
661
+ except Exception as delete_error:
662
+ logger.warning(
663
+ f"Could not remove temp cropped file {ocr_path}: {delete_error}"
664
+ )
665
+
666
+ def process_pdf(self, pdf_path, progress_callback=None, airline="iraqi"):
667
+ results = []
668
+ try:
669
+ doc = fitz.open(pdf_path)
670
+ total_pages = len(doc)
671
+ for i in range(total_pages):
672
+ if progress_callback:
673
+ progress_callback((i + 1) / total_pages)
674
+ page = doc.load_page(i)
675
+ pix = page.get_pixmap(
676
+ matrix=fitz.Matrix(2, 2)
677
+ ) # Better resolution for OCR
678
+
679
+ with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
680
+ tmp_path = tmp.name
681
+
682
+ try:
683
+ pix.save(tmp_path)
684
+ res = self.get_data(tmp_path, airline=airline)
685
+ if res:
686
+ results.append(res)
687
+ finally:
688
+ if os.path.exists(tmp_path):
689
+ try:
690
+ os.remove(tmp_path)
691
+ except Exception as delete_error:
692
+ logger.warning(
693
+ f"Could not remove temp file {tmp_path}: {delete_error}"
694
+ )
695
+ doc.close()
696
+ except Exception as e:
697
+ logger.error(f"PDF processing error: {e}")
698
+ raise e
699
+ return results
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ pandas
2
+ gradio
3
+ opencv-python
4
+ numpy
5
+ easyocr
6
+ PyMuPDF
7
+ pycountry
8
+ passporteye
9
+ openpyxl