Spaces:
Runtime error
Runtime error
hmfayyaz commited on
Commit ·
2ab1707
0
Parent(s):
init commit
Browse files- .gitattributes +11 -0
- .gitignore +8 -0
- README.md +110 -0
- app.py +150 -0
- config/settings.py +218 -0
- main.py +98 -0
- requirements.txt +14 -0
- setup.py +28 -0
- share_app.py +37 -0
- src/__init__.py +0 -0
- src/extractor.py +185 -0
- src/formats.py +41 -0
- src/utils.py +72 -0
- src/validators.py +34 -0
- tests/test_extractor.py +20 -0
.gitattributes
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.weights filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.pdf filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.xlsx filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.DS_Store
|
| 4 |
+
venv/
|
| 5 |
+
.env
|
| 6 |
+
output/
|
| 7 |
+
data/output/
|
| 8 |
+
|
README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Passport OCR Tool
|
| 2 |
+
|
| 3 |
+
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.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
- **Multi-format Support**: Handles images (JPG, PNG) and PDFs.
|
| 8 |
+
- **Batch Processing**: Process single files or entire directories.
|
| 9 |
+
- **Data Export**: Save results to Excel (`.xlsx`) or CSV.
|
| 10 |
+
- **Validation**: Basic validation of extracted fields.
|
| 11 |
+
- **Web Interface**: User-friendly web app for easy demonstration.
|
| 12 |
+
- **Local Processing**: Runs entirely on your local machine.
|
| 13 |
+
|
| 14 |
+
## Prerequisites
|
| 15 |
+
|
| 16 |
+
### 1. Python
|
| 17 |
+
Ensure you have Python 3.7+ installed.
|
| 18 |
+
|
| 19 |
+
### 2. System Dependencies (Poppler)
|
| 20 |
+
This tool requires `poppler` for PDF processing.
|
| 21 |
+
|
| 22 |
+
- **macOS** (using Homebrew):
|
| 23 |
+
```bash
|
| 24 |
+
brew install poppler
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
- **Ubuntu/Debian**:
|
| 28 |
+
```bash
|
| 29 |
+
sudo apt-get install poppler-utils
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
- **Windows**:
|
| 33 |
+
1. Download the latest binary from [@oschwartz10612 version](https://github.com/oschwartz10612/poppler-windows/releases/).
|
| 34 |
+
2. Extract the zip file.
|
| 35 |
+
3. Add the `bin/` folder to your System PATH environment variable.
|
| 36 |
+
|
| 37 |
+
## Installation
|
| 38 |
+
|
| 39 |
+
1. Clone the repository:
|
| 40 |
+
```bash
|
| 41 |
+
git clone <repository-url>
|
| 42 |
+
cd passport-ocr-tool
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
2. Create a virtual environment (recommended):
|
| 46 |
+
```bash
|
| 47 |
+
python -m venv venv
|
| 48 |
+
source venv/bin/activate # On Windows: venv\Scripts\activate
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
3. Install Python dependencies:
|
| 52 |
+
```bash
|
| 53 |
+
pip install -r requirements.txt
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
## Usage
|
| 57 |
+
|
| 58 |
+
### 1. Web App (Recommended for Demos)
|
| 59 |
+
Launch the easy-to-use web interface:
|
| 60 |
+
|
| 61 |
+
```bash
|
| 62 |
+
streamlit run app.py
|
| 63 |
+
```
|
| 64 |
+
This will open the tool in your default web browser (usually at `http://localhost:8501`). You can drag and drop files and download the results as Excel/CSV.
|
| 65 |
+
|
| 66 |
+
### 2. Command Line Interface (CLI)
|
| 67 |
+
|
| 68 |
+
Run the tool using `main.py`:
|
| 69 |
+
|
| 70 |
+
```bash
|
| 71 |
+
# Process a single image
|
| 72 |
+
python main.py --input path/to/passport.jpg
|
| 73 |
+
|
| 74 |
+
# Process a directory of images/PDFs
|
| 75 |
+
python main.py --input path/to/folder --output my_results
|
| 76 |
+
|
| 77 |
+
# Export to CSV
|
| 78 |
+
python main.py --input path/to/folder --format csv
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
### Command Line Arguments
|
| 82 |
+
|
| 83 |
+
- `--input`, `-i`: Path to input file or directory (Required).
|
| 84 |
+
- `--output`, `-o`: Output filename (default: `passport_data`).
|
| 85 |
+
- `--format`, `-f`: Output format: `excel` or `csv` (default: `excel`).
|
| 86 |
+
- `--gpu`: Enable GPU acceleration for OCR (requires CUDA).
|
| 87 |
+
|
| 88 |
+
## Project Structure
|
| 89 |
+
|
| 90 |
+
```
|
| 91 |
+
passport-ocr-tool/
|
| 92 |
+
├── src/
|
| 93 |
+
│ ├── extractor.py # Core extraction logic
|
| 94 |
+
│ ├── utils.py # Helper functions
|
| 95 |
+
│ ├── validators.py # Data validation
|
| 96 |
+
│ └── formats.py # Export handlers
|
| 97 |
+
├── config/
|
| 98 |
+
│ └── settings.py # Configuration
|
| 99 |
+
├── app.py # Streamlit Web App
|
| 100 |
+
├── main.py # CLI Entry point
|
| 101 |
+
└── ...
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
## Troubleshooting
|
| 105 |
+
|
| 106 |
+
- **PDF Error**: "Unable to get page count" or "poppler is not installed".
|
| 107 |
+
- Ensure `poppler` is installed and in your PATH.
|
| 108 |
+
- **OCR Accuracy**:
|
| 109 |
+
- Ensure images are clear and high resolution.
|
| 110 |
+
- The MRZ zone (bottom lines) must be visible and unobstructed.
|
app.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import os
|
| 3 |
+
import tempfile
|
| 4 |
+
import pandas as pd
|
| 5 |
+
import time
|
| 6 |
+
from src.extractor import PassportExtractor
|
| 7 |
+
from src.validators import validate_passport_data
|
| 8 |
+
|
| 9 |
+
# Set page configuration
|
| 10 |
+
st.set_page_config(
|
| 11 |
+
page_title="Passport OCR Tool",
|
| 12 |
+
page_icon="🛂",
|
| 13 |
+
layout="wide"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
# Initialize Extractor (cached to avoid reloading model)
|
| 17 |
+
@st.cache_resource
|
| 18 |
+
def get_extractor():
|
| 19 |
+
return PassportExtractor(use_gpu=False)
|
| 20 |
+
|
| 21 |
+
extractor = get_extractor()
|
| 22 |
+
|
| 23 |
+
def save_uploaded_file(uploaded_file):
|
| 24 |
+
"""Save uploaded file to a temporary location and return the path."""
|
| 25 |
+
try:
|
| 26 |
+
suffix = os.path.splitext(uploaded_file.name)[1]
|
| 27 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_file:
|
| 28 |
+
tmp_file.write(uploaded_file.getvalue())
|
| 29 |
+
return tmp_file.name
|
| 30 |
+
except Exception as e:
|
| 31 |
+
st.error(f"Error saving file: {e}")
|
| 32 |
+
return None
|
| 33 |
+
|
| 34 |
+
def main():
|
| 35 |
+
st.title("🛂 Passport OCR Extractor")
|
| 36 |
+
st.markdown("""
|
| 37 |
+
Upload passport images (JPG, PNG) or PDFs to extract MRZ data automatically.
|
| 38 |
+
The tool extracts details like Name, Surname, Passport Number, and Date of Birth.
|
| 39 |
+
""")
|
| 40 |
+
|
| 41 |
+
# Sidebar for configuration
|
| 42 |
+
st.sidebar.header("Configuration")
|
| 43 |
+
enable_validation = st.sidebar.checkbox("Enable Data Validation", value=True)
|
| 44 |
+
|
| 45 |
+
# File Uploader
|
| 46 |
+
uploaded_files = st.file_uploader(
|
| 47 |
+
"Choose passport files",
|
| 48 |
+
type=['png', 'jpg', 'jpeg', 'pdf'],
|
| 49 |
+
accept_multiple_files=True
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
if uploaded_files:
|
| 53 |
+
st.info(f"Loaded {len(uploaded_files)} files. Click 'Process Files' to start.")
|
| 54 |
+
|
| 55 |
+
if st.button("Process Files"):
|
| 56 |
+
results = []
|
| 57 |
+
progress_bar = st.progress(0)
|
| 58 |
+
status_text = st.empty()
|
| 59 |
+
|
| 60 |
+
for idx, uploaded_file in enumerate(uploaded_files):
|
| 61 |
+
status_text.text(f"Processing {uploaded_file.name}...")
|
| 62 |
+
|
| 63 |
+
# Save to temp file because extractor needs a path
|
| 64 |
+
temp_path = save_uploaded_file(uploaded_file)
|
| 65 |
+
|
| 66 |
+
if temp_path:
|
| 67 |
+
try:
|
| 68 |
+
ext = os.path.splitext(temp_path)[1].lower()
|
| 69 |
+
file_results = []
|
| 70 |
+
|
| 71 |
+
if ext == '.pdf':
|
| 72 |
+
file_results = extractor.process_pdf(temp_path)
|
| 73 |
+
else:
|
| 74 |
+
data = extractor.get_data(temp_path)
|
| 75 |
+
if data:
|
| 76 |
+
file_results = [data]
|
| 77 |
+
|
| 78 |
+
# Add validation if enabled
|
| 79 |
+
if enable_validation:
|
| 80 |
+
for res in file_results:
|
| 81 |
+
errors = validate_passport_data(res)
|
| 82 |
+
res['validation_errors'] = "; ".join(errors) if errors else "Valid"
|
| 83 |
+
|
| 84 |
+
# Add original filename for reference (since we used a temp file)
|
| 85 |
+
for res in file_results:
|
| 86 |
+
res['original_filename'] = uploaded_file.name
|
| 87 |
+
|
| 88 |
+
results.extend(file_results)
|
| 89 |
+
|
| 90 |
+
except Exception as e:
|
| 91 |
+
st.error(f"Failed to process {uploaded_file.name}: {e}")
|
| 92 |
+
finally:
|
| 93 |
+
# Cleanup temp file
|
| 94 |
+
if os.path.exists(temp_path):
|
| 95 |
+
os.remove(temp_path)
|
| 96 |
+
|
| 97 |
+
# Update progress
|
| 98 |
+
progress_bar.progress((idx + 1) / len(uploaded_files))
|
| 99 |
+
|
| 100 |
+
status_text.text("Processing complete!")
|
| 101 |
+
|
| 102 |
+
if results:
|
| 103 |
+
st.success(f"Successfully extracted {len(results)} records.")
|
| 104 |
+
|
| 105 |
+
# Create DataFrame
|
| 106 |
+
df = pd.DataFrame(results)
|
| 107 |
+
|
| 108 |
+
# Reorder columns for better readability if possible
|
| 109 |
+
cols = ['surname', 'name', 'passport_number', 'nationality', 'date_of_birth', 'sex', 'expiration_date', 'validation_errors', 'original_filename']
|
| 110 |
+
# Add missing cols to list if they exist in df
|
| 111 |
+
all_cols = cols + [c for c in df.columns if c not in cols]
|
| 112 |
+
# Filter valid columns only
|
| 113 |
+
final_cols = [c for c in all_cols if c in df.columns]
|
| 114 |
+
|
| 115 |
+
df = df[final_cols]
|
| 116 |
+
|
| 117 |
+
# Display Data
|
| 118 |
+
st.dataframe(df)
|
| 119 |
+
|
| 120 |
+
# Download Buttons
|
| 121 |
+
col1, col2 = st.columns(2)
|
| 122 |
+
|
| 123 |
+
# CSV Download
|
| 124 |
+
csv = df.to_csv(index=False).encode('utf-8')
|
| 125 |
+
col1.download_button(
|
| 126 |
+
label="Download CSV",
|
| 127 |
+
data=csv,
|
| 128 |
+
file_name="passport_data.csv",
|
| 129 |
+
mime="text/csv",
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
# Excel Download (requires openpyxl)
|
| 133 |
+
# Streamlit might need a BytesIO buffer for Excel
|
| 134 |
+
import io
|
| 135 |
+
buffer = io.BytesIO()
|
| 136 |
+
with pd.ExcelWriter(buffer, engine='openpyxl') as writer:
|
| 137 |
+
df.to_excel(writer, index=False, sheet_name='Sheet1')
|
| 138 |
+
|
| 139 |
+
col2.download_button(
|
| 140 |
+
label="Download Excel",
|
| 141 |
+
data=buffer.getvalue(),
|
| 142 |
+
file_name="passport_data.xlsx",
|
| 143 |
+
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
else:
|
| 147 |
+
st.warning("No data extracted from the provided files.")
|
| 148 |
+
|
| 149 |
+
if __name__ == "__main__":
|
| 150 |
+
main()
|
config/settings.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
# Base directory
|
| 4 |
+
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 5 |
+
|
| 6 |
+
# Input/Output directories
|
| 7 |
+
INPUT_DIR = os.path.join(BASE_DIR, 'data', 'input')
|
| 8 |
+
OUTPUT_DIR = os.path.join(BASE_DIR, 'data', 'output')
|
| 9 |
+
TEMP_DIR = os.path.join(BASE_DIR, 'temp_processing')
|
| 10 |
+
|
| 11 |
+
# Ensure directories exist
|
| 12 |
+
os.makedirs(INPUT_DIR, exist_ok=True)
|
| 13 |
+
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 14 |
+
|
| 15 |
+
# Allowed file extensions
|
| 16 |
+
ALLOWED_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.pdf'}
|
| 17 |
+
|
| 18 |
+
# OCR Settings
|
| 19 |
+
OCR_LANGUAGES = ['en']
|
| 20 |
+
USE_GPU = False # Set to True if you have CUDA installed
|
| 21 |
+
|
| 22 |
+
# Country Codes (ISO 3166-1 alpha-3)
|
| 23 |
+
# This is a subset. In a real production app, consider loading this from a standard library or full JSON file.
|
| 24 |
+
COUNTRY_CODES = [
|
| 25 |
+
{"name": "Afghanistan", "alpha-3": "AFG"},
|
| 26 |
+
{"name": "Albania", "alpha-3": "ALB"},
|
| 27 |
+
{"name": "Algeria", "alpha-3": "DZA"},
|
| 28 |
+
{"name": "Andorra", "alpha-3": "AND"},
|
| 29 |
+
{"name": "Angola", "alpha-3": "AGO"},
|
| 30 |
+
{"name": "Argentina", "alpha-3": "ARG"},
|
| 31 |
+
{"name": "Armenia", "alpha-3": "ARM"},
|
| 32 |
+
{"name": "Australia", "alpha-3": "AUS"},
|
| 33 |
+
{"name": "Austria", "alpha-3": "AUT"},
|
| 34 |
+
{"name": "Azerbaijan", "alpha-3": "AZE"},
|
| 35 |
+
{"name": "Bahamas", "alpha-3": "BHS"},
|
| 36 |
+
{"name": "Bahrain", "alpha-3": "BHR"},
|
| 37 |
+
{"name": "Bangladesh", "alpha-3": "BGD"},
|
| 38 |
+
{"name": "Barbados", "alpha-3": "BRB"},
|
| 39 |
+
{"name": "Belarus", "alpha-3": "BLR"},
|
| 40 |
+
{"name": "Belgium", "alpha-3": "BEL"},
|
| 41 |
+
{"name": "Belize", "alpha-3": "BLZ"},
|
| 42 |
+
{"name": "Benin", "alpha-3": "BEN"},
|
| 43 |
+
{"name": "Bhutan", "alpha-3": "BTN"},
|
| 44 |
+
{"name": "Bolivia", "alpha-3": "BOL"},
|
| 45 |
+
{"name": "Bosnia and Herzegovina", "alpha-3": "BIH"},
|
| 46 |
+
{"name": "Botswana", "alpha-3": "BWA"},
|
| 47 |
+
{"name": "Brazil", "alpha-3": "BRA"},
|
| 48 |
+
{"name": "Brunei Darussalam", "alpha-3": "BRN"},
|
| 49 |
+
{"name": "Bulgaria", "alpha-3": "BGR"},
|
| 50 |
+
{"name": "Burkina Faso", "alpha-3": "BFA"},
|
| 51 |
+
{"name": "Burundi", "alpha-3": "BDI"},
|
| 52 |
+
{"name": "Cabo Verde", "alpha-3": "CPV"},
|
| 53 |
+
{"name": "Cambodia", "alpha-3": "KHM"},
|
| 54 |
+
{"name": "Cameroon", "alpha-3": "CMR"},
|
| 55 |
+
{"name": "Canada", "alpha-3": "CAN"},
|
| 56 |
+
{"name": "Central African Republic", "alpha-3": "CAF"},
|
| 57 |
+
{"name": "Chad", "alpha-3": "TCD"},
|
| 58 |
+
{"name": "Chile", "alpha-3": "CHL"},
|
| 59 |
+
{"name": "China", "alpha-3": "CHN"},
|
| 60 |
+
{"name": "Colombia", "alpha-3": "COL"},
|
| 61 |
+
{"name": "Comoros", "alpha-3": "COM"},
|
| 62 |
+
{"name": "Congo", "alpha-3": "COG"},
|
| 63 |
+
{"name": "Congo (Democratic Republic of the)", "alpha-3": "COD"},
|
| 64 |
+
{"name": "Costa Rica", "alpha-3": "CRI"},
|
| 65 |
+
{"name": "Croatia", "alpha-3": "HRV"},
|
| 66 |
+
{"name": "Cuba", "alpha-3": "CUB"},
|
| 67 |
+
{"name": "Cyprus", "alpha-3": "CYP"},
|
| 68 |
+
{"name": "Czechia", "alpha-3": "CZE"},
|
| 69 |
+
{"name": "Denmark", "alpha-3": "DNK"},
|
| 70 |
+
{"name": "Djibouti", "alpha-3": "DJI"},
|
| 71 |
+
{"name": "Dominica", "alpha-3": "DMA"},
|
| 72 |
+
{"name": "Dominican Republic", "alpha-3": "DOM"},
|
| 73 |
+
{"name": "Ecuador", "alpha-3": "ECU"},
|
| 74 |
+
{"name": "Egypt", "alpha-3": "EGY"},
|
| 75 |
+
{"name": "El Salvador", "alpha-3": "SLV"},
|
| 76 |
+
{"name": "Equatorial Guinea", "alpha-3": "GNQ"},
|
| 77 |
+
{"name": "Eritrea", "alpha-3": "ERI"},
|
| 78 |
+
{"name": "Estonia", "alpha-3": "EST"},
|
| 79 |
+
{"name": "Eswatini", "alpha-3": "SWZ"},
|
| 80 |
+
{"name": "Ethiopia", "alpha-3": "ETH"},
|
| 81 |
+
{"name": "Fiji", "alpha-3": "FJI"},
|
| 82 |
+
{"name": "Finland", "alpha-3": "FIN"},
|
| 83 |
+
{"name": "France", "alpha-3": "FRA"},
|
| 84 |
+
{"name": "Gabon", "alpha-3": "GAB"},
|
| 85 |
+
{"name": "Gambia", "alpha-3": "GMB"},
|
| 86 |
+
{"name": "Georgia", "alpha-3": "GEO"},
|
| 87 |
+
{"name": "Germany", "alpha-3": "DEU"},
|
| 88 |
+
{"name": "Ghana", "alpha-3": "GHA"},
|
| 89 |
+
{"name": "Greece", "alpha-3": "GRC"},
|
| 90 |
+
{"name": "Grenada", "alpha-3": "GRD"},
|
| 91 |
+
{"name": "Guatemala", "alpha-3": "GTM"},
|
| 92 |
+
{"name": "Guinea", "alpha-3": "GIN"},
|
| 93 |
+
{"name": "Guinea-Bissau", "alpha-3": "GNB"},
|
| 94 |
+
{"name": "Guyana", "alpha-3": "GUY"},
|
| 95 |
+
{"name": "Haiti", "alpha-3": "HTI"},
|
| 96 |
+
{"name": "Honduras", "alpha-3": "HND"},
|
| 97 |
+
{"name": "Hungary", "alpha-3": "HUN"},
|
| 98 |
+
{"name": "Iceland", "alpha-3": "ISL"},
|
| 99 |
+
{"name": "India", "alpha-3": "IND"},
|
| 100 |
+
{"name": "Indonesia", "alpha-3": "IDN"},
|
| 101 |
+
{"name": "Iran", "alpha-3": "IRN"},
|
| 102 |
+
{"name": "Iraq", "alpha-3": "IRQ"},
|
| 103 |
+
{"name": "Ireland", "alpha-3": "IRL"},
|
| 104 |
+
{"name": "Israel", "alpha-3": "ISR"},
|
| 105 |
+
{"name": "Italy", "alpha-3": "ITA"},
|
| 106 |
+
{"name": "Jamaica", "alpha-3": "JAM"},
|
| 107 |
+
{"name": "Japan", "alpha-3": "JPN"},
|
| 108 |
+
{"name": "Jordan", "alpha-3": "JOR"},
|
| 109 |
+
{"name": "Kazakhstan", "alpha-3": "KAZ"},
|
| 110 |
+
{"name": "Kenya", "alpha-3": "KEN"},
|
| 111 |
+
{"name": "Kiribati", "alpha-3": "KIR"},
|
| 112 |
+
{"name": "Korea (North)", "alpha-3": "PRK"},
|
| 113 |
+
{"name": "Korea (South)", "alpha-3": "KOR"},
|
| 114 |
+
{"name": "Kuwait", "alpha-3": "KWT"},
|
| 115 |
+
{"name": "Kyrgyzstan", "alpha-3": "KGZ"},
|
| 116 |
+
{"name": "Laos", "alpha-3": "LAO"},
|
| 117 |
+
{"name": "Latvia", "alpha-3": "LVA"},
|
| 118 |
+
{"name": "Lebanon", "alpha-3": "LBN"},
|
| 119 |
+
{"name": "Lesotho", "alpha-3": "LSO"},
|
| 120 |
+
{"name": "Liberia", "alpha-3": "LBR"},
|
| 121 |
+
{"name": "Libya", "alpha-3": "LBY"},
|
| 122 |
+
{"name": "Liechtenstein", "alpha-3": "LIE"},
|
| 123 |
+
{"name": "Lithuania", "alpha-3": "LTU"},
|
| 124 |
+
{"name": "Luxembourg", "alpha-3": "LUX"},
|
| 125 |
+
{"name": "Madagascar", "alpha-3": "MDG"},
|
| 126 |
+
{"name": "Malawi", "alpha-3": "MWI"},
|
| 127 |
+
{"name": "Malaysia", "alpha-3": "MYS"},
|
| 128 |
+
{"name": "Maldives", "alpha-3": "MDV"},
|
| 129 |
+
{"name": "Mali", "alpha-3": "MLI"},
|
| 130 |
+
{"name": "Malta", "alpha-3": "MLT"},
|
| 131 |
+
{"name": "Marshall Islands", "alpha-3": "MHL"},
|
| 132 |
+
{"name": "Mauritania", "alpha-3": "MRT"},
|
| 133 |
+
{"name": "Mauritius", "alpha-3": "MUS"},
|
| 134 |
+
{"name": "Mexico", "alpha-3": "MEX"},
|
| 135 |
+
{"name": "Micronesia", "alpha-3": "FSM"},
|
| 136 |
+
{"name": "Moldova", "alpha-3": "MDA"},
|
| 137 |
+
{"name": "Monaco", "alpha-3": "MCO"},
|
| 138 |
+
{"name": "Mongolia", "alpha-3": "MNG"},
|
| 139 |
+
{"name": "Montenegro", "alpha-3": "MNE"},
|
| 140 |
+
{"name": "Morocco", "alpha-3": "MAR"},
|
| 141 |
+
{"name": "Mozambique", "alpha-3": "MOZ"},
|
| 142 |
+
{"name": "Myanmar", "alpha-3": "MMR"},
|
| 143 |
+
{"name": "Namibia", "alpha-3": "NAM"},
|
| 144 |
+
{"name": "Nauru", "alpha-3": "NRU"},
|
| 145 |
+
{"name": "Nepal", "alpha-3": "NPL"},
|
| 146 |
+
{"name": "Netherlands", "alpha-3": "NLD"},
|
| 147 |
+
{"name": "New Zealand", "alpha-3": "NZL"},
|
| 148 |
+
{"name": "Nicaragua", "alpha-3": "NIC"},
|
| 149 |
+
{"name": "Niger", "alpha-3": "NER"},
|
| 150 |
+
{"name": "Nigeria", "alpha-3": "NGA"},
|
| 151 |
+
{"name": "North Macedonia", "alpha-3": "MKD"},
|
| 152 |
+
{"name": "Norway", "alpha-3": "NOR"},
|
| 153 |
+
{"name": "Oman", "alpha-3": "OMN"},
|
| 154 |
+
{"name": "Pakistan", "alpha-3": "PAK"},
|
| 155 |
+
{"name": "Palau", "alpha-3": "PLW"},
|
| 156 |
+
{"name": "Panama", "alpha-3": "PAN"},
|
| 157 |
+
{"name": "Papua New Guinea", "alpha-3": "PNG"},
|
| 158 |
+
{"name": "Paraguay", "alpha-3": "PRY"},
|
| 159 |
+
{"name": "Peru", "alpha-3": "PER"},
|
| 160 |
+
{"name": "Philippines", "alpha-3": "PHL"},
|
| 161 |
+
{"name": "Poland", "alpha-3": "POL"},
|
| 162 |
+
{"name": "Portugal", "alpha-3": "PRT"},
|
| 163 |
+
{"name": "Qatar", "alpha-3": "QAT"},
|
| 164 |
+
{"name": "Romania", "alpha-3": "ROU"},
|
| 165 |
+
{"name": "Russia", "alpha-3": "RUS"},
|
| 166 |
+
{"name": "Rwanda", "alpha-3": "RWA"},
|
| 167 |
+
{"name": "Saint Kitts and Nevis", "alpha-3": "KNA"},
|
| 168 |
+
{"name": "Saint Lucia", "alpha-3": "LCA"},
|
| 169 |
+
{"name": "Saint Vincent and the Grenadines", "alpha-3": "VCT"},
|
| 170 |
+
{"name": "Samoa", "alpha-3": "WSM"},
|
| 171 |
+
{"name": "San Marino", "alpha-3": "SMR"},
|
| 172 |
+
{"name": "Sao Tome and Principe", "alpha-3": "STP"},
|
| 173 |
+
{"name": "Saudi Arabia", "alpha-3": "SAU"},
|
| 174 |
+
{"name": "Senegal", "alpha-3": "SEN"},
|
| 175 |
+
{"name": "Serbia", "alpha-3": "SRB"},
|
| 176 |
+
{"name": "Seychelles", "alpha-3": "SYC"},
|
| 177 |
+
{"name": "Sierra Leone", "alpha-3": "SLE"},
|
| 178 |
+
{"name": "Singapore", "alpha-3": "SGP"},
|
| 179 |
+
{"name": "Slovakia", "alpha-3": "SVK"},
|
| 180 |
+
{"name": "Slovenia", "alpha-3": "SVN"},
|
| 181 |
+
{"name": "Solomon Islands", "alpha-3": "SLB"},
|
| 182 |
+
{"name": "Somalia", "alpha-3": "SOM"},
|
| 183 |
+
{"name": "South Africa", "alpha-3": "ZAF"},
|
| 184 |
+
{"name": "South Sudan", "alpha-3": "SSD"},
|
| 185 |
+
{"name": "Spain", "alpha-3": "ESP"},
|
| 186 |
+
{"name": "Sri Lanka", "alpha-3": "LKA"},
|
| 187 |
+
{"name": "Sudan", "alpha-3": "SDN"},
|
| 188 |
+
{"name": "Suriname", "alpha-3": "SUR"},
|
| 189 |
+
{"name": "Sweden", "alpha-3": "SWE"},
|
| 190 |
+
{"name": "Switzerland", "alpha-3": "CHE"},
|
| 191 |
+
{"name": "Syria", "alpha-3": "SYR"},
|
| 192 |
+
{"name": "Taiwan", "alpha-3": "TWN"},
|
| 193 |
+
{"name": "Tajikistan", "alpha-3": "TJK"},
|
| 194 |
+
{"name": "Tanzania", "alpha-3": "TZA"},
|
| 195 |
+
{"name": "Thailand", "alpha-3": "THA"},
|
| 196 |
+
{"name": "Timor-Leste", "alpha-3": "TLS"},
|
| 197 |
+
{"name": "Togo", "alpha-3": "TGO"},
|
| 198 |
+
{"name": "Tonga", "alpha-3": "TON"},
|
| 199 |
+
{"name": "Trinidad and Tobago", "alpha-3": "TTO"},
|
| 200 |
+
{"name": "Tunisia", "alpha-3": "TUN"},
|
| 201 |
+
{"name": "Turkey", "alpha-3": "TUR"},
|
| 202 |
+
{"name": "Turkmenistan", "alpha-3": "TKM"},
|
| 203 |
+
{"name": "Tuvalu", "alpha-3": "TUV"},
|
| 204 |
+
{"name": "Uganda", "alpha-3": "UGA"},
|
| 205 |
+
{"name": "Ukraine", "alpha-3": "UKR"},
|
| 206 |
+
{"name": "United Arab Emirates", "alpha-3": "ARE"},
|
| 207 |
+
{"name": "United Kingdom", "alpha-3": "GBR"},
|
| 208 |
+
{"name": "United States of America", "alpha-3": "USA"},
|
| 209 |
+
{"name": "Uruguay", "alpha-3": "URY"},
|
| 210 |
+
{"name": "Uzbekistan", "alpha-3": "UZB"},
|
| 211 |
+
{"name": "Vanuatu", "alpha-3": "VUT"},
|
| 212 |
+
{"name": "Vatican City", "alpha-3": "VAT"},
|
| 213 |
+
{"name": "Venezuela", "alpha-3": "VEN"},
|
| 214 |
+
{"name": "Vietnam", "alpha-3": "VNM"},
|
| 215 |
+
{"name": "Yemen", "alpha-3": "YEM"},
|
| 216 |
+
{"name": "Zambia", "alpha-3": "ZMB"},
|
| 217 |
+
{"name": "Zimbabwe", "alpha-3": "ZWE"}
|
| 218 |
+
]
|
main.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
from tqdm import tqdm
|
| 5 |
+
from src.extractor import PassportExtractor
|
| 6 |
+
from src.formats import export_to_spreadsheet
|
| 7 |
+
from src.validators import validate_passport_data
|
| 8 |
+
from src.utils import setup_logger
|
| 9 |
+
from config.settings import ALLOWED_EXTENSIONS
|
| 10 |
+
|
| 11 |
+
# Setup Logger
|
| 12 |
+
logger = setup_logger()
|
| 13 |
+
|
| 14 |
+
def is_valid_file(filename):
|
| 15 |
+
ext = os.path.splitext(filename)[1].lower()
|
| 16 |
+
return ext in ALLOWED_EXTENSIONS
|
| 17 |
+
|
| 18 |
+
def main():
|
| 19 |
+
parser = argparse.ArgumentParser(description="Passport OCR Tool - Extract data from passport images/PDFs.")
|
| 20 |
+
parser.add_argument('--input', '-i', required=True, help="Path to input file or directory")
|
| 21 |
+
parser.add_argument('--output', '-o', default='passport_data', help="Output filename (without extension)")
|
| 22 |
+
parser.add_argument('--format', '-f', choices=['excel', 'csv'], default='excel', help="Output format (excel or csv)")
|
| 23 |
+
parser.add_argument('--gpu', action='store_true', help="Use GPU for OCR")
|
| 24 |
+
|
| 25 |
+
args = parser.parse_args()
|
| 26 |
+
|
| 27 |
+
input_path = os.path.abspath(args.input)
|
| 28 |
+
output_path = os.path.abspath(os.path.join('data', 'output', args.output)) # Default to data/output if relative
|
| 29 |
+
|
| 30 |
+
# Adjust output path if user provided a specific path
|
| 31 |
+
if os.path.dirname(args.output):
|
| 32 |
+
output_path = os.path.abspath(args.output)
|
| 33 |
+
|
| 34 |
+
if not os.path.exists(input_path):
|
| 35 |
+
logger.error(f"Input path does not exist: {input_path}")
|
| 36 |
+
sys.exit(1)
|
| 37 |
+
|
| 38 |
+
files_to_process = []
|
| 39 |
+
if os.path.isfile(input_path):
|
| 40 |
+
if is_valid_file(input_path):
|
| 41 |
+
files_to_process.append(input_path)
|
| 42 |
+
else:
|
| 43 |
+
logger.error(f"Unsupported file format: {input_path}")
|
| 44 |
+
sys.exit(1)
|
| 45 |
+
else:
|
| 46 |
+
for root, _, files in os.walk(input_path):
|
| 47 |
+
for file in files:
|
| 48 |
+
if is_valid_file(file):
|
| 49 |
+
files_to_process.append(os.path.join(root, file))
|
| 50 |
+
|
| 51 |
+
if not files_to_process:
|
| 52 |
+
logger.warning("No valid files found to process.")
|
| 53 |
+
sys.exit(0)
|
| 54 |
+
|
| 55 |
+
logger.info(f"Found {len(files_to_process)} files to process.")
|
| 56 |
+
|
| 57 |
+
# Initialize Extractor
|
| 58 |
+
extractor = PassportExtractor(use_gpu=args.gpu)
|
| 59 |
+
|
| 60 |
+
extracted_results = []
|
| 61 |
+
|
| 62 |
+
# Process files
|
| 63 |
+
for file_path in tqdm(files_to_process, desc="Processing files"):
|
| 64 |
+
ext = os.path.splitext(file_path)[1].lower()
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
if ext == '.pdf':
|
| 68 |
+
results = extractor.process_pdf(file_path)
|
| 69 |
+
extracted_results.extend(results)
|
| 70 |
+
else:
|
| 71 |
+
result = extractor.get_data(file_path)
|
| 72 |
+
if result:
|
| 73 |
+
extracted_results.append(result)
|
| 74 |
+
except Exception as e:
|
| 75 |
+
logger.error(f"Failed to process {file_path}: {e}")
|
| 76 |
+
|
| 77 |
+
# Validate and Summarize
|
| 78 |
+
valid_count = 0
|
| 79 |
+
for res in extracted_results:
|
| 80 |
+
errors = validate_passport_data(res)
|
| 81 |
+
if not errors:
|
| 82 |
+
valid_count += 1
|
| 83 |
+
else:
|
| 84 |
+
res['validation_errors'] = "; ".join(errors)
|
| 85 |
+
# logger.warning(f"Validation issues for {res.get('source_file')}: {errors}")
|
| 86 |
+
|
| 87 |
+
logger.info(f"Processing complete. Extracted {len(extracted_results)} records ({valid_count} valid).")
|
| 88 |
+
|
| 89 |
+
# Export
|
| 90 |
+
if extracted_results:
|
| 91 |
+
success = export_to_spreadsheet(extracted_results, output_path, format=args.format)
|
| 92 |
+
if success:
|
| 93 |
+
logger.info(f"Successfully saved data to {output_path}.{args.format if not output_path.endswith(args.format) else ''}")
|
| 94 |
+
else:
|
| 95 |
+
logger.warning("No data extracted.")
|
| 96 |
+
|
| 97 |
+
if __name__ == "__main__":
|
| 98 |
+
main()
|
requirements.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
passporteye>=2.1.0
|
| 2 |
+
easyocr>=1.6.0
|
| 3 |
+
matplotlib>=3.5.0
|
| 4 |
+
opencv-python>=4.5.0
|
| 5 |
+
python-dateutil>=2.8.0
|
| 6 |
+
pdf2image>=1.16.0
|
| 7 |
+
pandas>=1.3.0
|
| 8 |
+
openpyxl>=3.0.0
|
| 9 |
+
tqdm>=4.60.0
|
| 10 |
+
Pillow>=9.0.0
|
| 11 |
+
numpy>=1.21.0
|
| 12 |
+
streamlit>=1.25.0
|
| 13 |
+
watchdog>=3.0.0
|
| 14 |
+
pyngrok
|
setup.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from setuptools import setup, find_packages
|
| 2 |
+
|
| 3 |
+
setup(
|
| 4 |
+
name="passport-ocr-tool",
|
| 5 |
+
version="1.0.0",
|
| 6 |
+
description="A tool to extract data from passports (Images/PDFs) and export to Excel/CSV.",
|
| 7 |
+
author="Your Name",
|
| 8 |
+
packages=find_packages(),
|
| 9 |
+
install_requires=[
|
| 10 |
+
'passporteye',
|
| 11 |
+
'easyocr',
|
| 12 |
+
'matplotlib',
|
| 13 |
+
'opencv-python',
|
| 14 |
+
'python-dateutil',
|
| 15 |
+
'pdf2image',
|
| 16 |
+
'pandas',
|
| 17 |
+
'openpyxl',
|
| 18 |
+
'tqdm',
|
| 19 |
+
'Pillow',
|
| 20 |
+
'numpy'
|
| 21 |
+
],
|
| 22 |
+
entry_points={
|
| 23 |
+
'console_scripts': [
|
| 24 |
+
'passport-ocr=main:main',
|
| 25 |
+
],
|
| 26 |
+
},
|
| 27 |
+
python_requires='>=3.7',
|
| 28 |
+
)
|
share_app.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
import threading
|
| 4 |
+
from pyngrok import ngrok
|
| 5 |
+
|
| 6 |
+
def run_streamlit():
|
| 7 |
+
os.system("python3 -m streamlit run app.py")
|
| 8 |
+
|
| 9 |
+
def start_ngrok():
|
| 10 |
+
print("Starting Streamlit app...")
|
| 11 |
+
# Give streamlit a moment to start
|
| 12 |
+
time.sleep(5)
|
| 13 |
+
|
| 14 |
+
try:
|
| 15 |
+
# Open a HTTP tunnel on the default port 8501
|
| 16 |
+
public_url = ngrok.connect(8501).public_url
|
| 17 |
+
print("\n" + "="*60)
|
| 18 |
+
print(f" \033[92m>>> YOUR LIVE LINK: {public_url} <<<\033[0m")
|
| 19 |
+
print(" Share this link with your client. The app must stay running.")
|
| 20 |
+
print("="*60 + "\n")
|
| 21 |
+
|
| 22 |
+
# Keep the script running
|
| 23 |
+
input("Press Enter to exit...\n")
|
| 24 |
+
except Exception as e:
|
| 25 |
+
print(f"\nError starting ngrok: {e}")
|
| 26 |
+
print("\nNOTE: You may need a free ngrok account.")
|
| 27 |
+
print("1. Go to https://dashboard.ngrok.com/signup")
|
| 28 |
+
print("2. Get your Authtoken")
|
| 29 |
+
print("3. Run: ngrok config add-authtoken <your_token>")
|
| 30 |
+
|
| 31 |
+
if __name__ == "__main__":
|
| 32 |
+
# Start Streamlit in a separate thread
|
| 33 |
+
thread = threading.Thread(target=run_streamlit)
|
| 34 |
+
thread.daemon = True
|
| 35 |
+
thread.start()
|
| 36 |
+
|
| 37 |
+
start_ngrok()
|
src/__init__.py
ADDED
|
File without changes
|
src/extractor.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import cv2
|
| 3 |
+
import numpy as np
|
| 4 |
+
import easyocr
|
| 5 |
+
import warnings
|
| 6 |
+
import ssl
|
| 7 |
+
from passporteye import read_mrz
|
| 8 |
+
from pdf2image import convert_from_path
|
| 9 |
+
from PIL import Image
|
| 10 |
+
import string as st
|
| 11 |
+
|
| 12 |
+
# Fix for SSL certificate errors on Mac when downloading EasyOCR models
|
| 13 |
+
try:
|
| 14 |
+
_create_unverified_https_context = ssl._create_unverified_context
|
| 15 |
+
except AttributeError:
|
| 16 |
+
pass
|
| 17 |
+
else:
|
| 18 |
+
ssl._create_default_https_context = _create_unverified_https_context
|
| 19 |
+
|
| 20 |
+
from src.utils import (
|
| 21 |
+
clean_string,
|
| 22 |
+
clean_mrz_line,
|
| 23 |
+
parse_date,
|
| 24 |
+
get_country_name,
|
| 25 |
+
get_sex,
|
| 26 |
+
setup_logger
|
| 27 |
+
)
|
| 28 |
+
from config.settings import USE_GPU, OCR_LANGUAGES, TEMP_DIR
|
| 29 |
+
|
| 30 |
+
# Suppress warnings
|
| 31 |
+
warnings.filterwarnings('ignore')
|
| 32 |
+
|
| 33 |
+
logger = setup_logger(__name__)
|
| 34 |
+
|
| 35 |
+
class PassportExtractor:
|
| 36 |
+
def __init__(self, use_gpu=USE_GPU, languages=None):
|
| 37 |
+
self.languages = languages if languages else OCR_LANGUAGES
|
| 38 |
+
|
| 39 |
+
# Set model storage directory to project/data/models to avoid permission issues
|
| 40 |
+
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 41 |
+
model_dir = os.path.join(base_dir, 'data', 'models')
|
| 42 |
+
os.makedirs(model_dir, exist_ok=True)
|
| 43 |
+
|
| 44 |
+
logger.info(f"Initializing EasyOCR Reader (GPU={use_gpu})...")
|
| 45 |
+
self.reader = easyocr.Reader(self.languages, gpu=use_gpu, model_storage_directory=model_dir)
|
| 46 |
+
logger.info("EasyOCR Reader initialized.")
|
| 47 |
+
|
| 48 |
+
def extract_mrz_from_roi(self, img_path):
|
| 49 |
+
"""
|
| 50 |
+
Extracts MRZ lines using PassportEye to find ROI, then EasyOCR to read text.
|
| 51 |
+
Returns (line1, line2, mrz_object).
|
| 52 |
+
"""
|
| 53 |
+
try:
|
| 54 |
+
# Analyze image with PassportEye
|
| 55 |
+
mrz = read_mrz(img_path, save_roi=True)
|
| 56 |
+
|
| 57 |
+
if not mrz:
|
| 58 |
+
logger.warning(f"PassportEye failed to detect MRZ in {img_path}")
|
| 59 |
+
return None, None, None
|
| 60 |
+
|
| 61 |
+
# Get ROI (Region of Interest)
|
| 62 |
+
roi = mrz.aux['roi']
|
| 63 |
+
|
| 64 |
+
# Ensure ROI is uint8 for OpenCV
|
| 65 |
+
if roi.dtype != np.uint8:
|
| 66 |
+
if roi.max() <= 1.0:
|
| 67 |
+
roi = (roi * 255).astype(np.uint8)
|
| 68 |
+
else:
|
| 69 |
+
roi = roi.astype(np.uint8)
|
| 70 |
+
|
| 71 |
+
# Preprocess ROI for EasyOCR
|
| 72 |
+
# Original code: saved to tmp.png (gray), read back, resized to (1110, 140)
|
| 73 |
+
# We will try to do this in memory using OpenCV
|
| 74 |
+
|
| 75 |
+
# roi is likely a numpy array (H, W) or (H, W, C). PassportEye usually returns grayscale for ROI?
|
| 76 |
+
# Let's normalize to BGR for OpenCV consistency if needed, or keep grayscale.
|
| 77 |
+
|
| 78 |
+
# Resize to improve OCR accuracy as per original code logic
|
| 79 |
+
# Note: (1110, 140) is the target size (Width, Height)
|
| 80 |
+
img_resized = cv2.resize(roi, (1110, 140))
|
| 81 |
+
|
| 82 |
+
# Define allowed characters for MRZ
|
| 83 |
+
allow = st.ascii_letters + st.digits + "< "
|
| 84 |
+
|
| 85 |
+
# Run EasyOCR
|
| 86 |
+
code = self.reader.readtext(img_resized, detail=0, allowlist=allow)
|
| 87 |
+
|
| 88 |
+
if len(code) < 2:
|
| 89 |
+
logger.warning(f"EasyOCR found fewer than 2 lines in ROI for {img_path}")
|
| 90 |
+
return None, None, mrz
|
| 91 |
+
|
| 92 |
+
line1 = clean_mrz_line(code[0])
|
| 93 |
+
line2 = clean_mrz_line(code[1])
|
| 94 |
+
|
| 95 |
+
# Correct sex at index 20 of line 2 if available from mrz object
|
| 96 |
+
# MRZ object might have parsed it correctly even if EasyOCR missed it
|
| 97 |
+
if mrz.sex and len(line2) > 20:
|
| 98 |
+
l2_list = list(line2)
|
| 99 |
+
l2_list[20] = mrz.sex
|
| 100 |
+
line2 = "".join(l2_list)
|
| 101 |
+
|
| 102 |
+
return line1, line2, mrz
|
| 103 |
+
|
| 104 |
+
except Exception as e:
|
| 105 |
+
logger.error(f"Error in extract_mrz_from_roi: {e}")
|
| 106 |
+
return None, None, None
|
| 107 |
+
|
| 108 |
+
def get_data(self, img_path):
|
| 109 |
+
"""
|
| 110 |
+
Extracts full passport data from an image file.
|
| 111 |
+
Returns a dictionary of extracted fields.
|
| 112 |
+
"""
|
| 113 |
+
if not os.path.exists(img_path):
|
| 114 |
+
logger.error(f"File not found: {img_path}")
|
| 115 |
+
return None
|
| 116 |
+
|
| 117 |
+
line1, line2, mrz = self.extract_mrz_from_roi(img_path)
|
| 118 |
+
|
| 119 |
+
if mrz is None:
|
| 120 |
+
return None
|
| 121 |
+
|
| 122 |
+
data = {}
|
| 123 |
+
# Use PassportEye's parsing where possible, fallback/clean as needed
|
| 124 |
+
data['surname'] = mrz.surname.replace("<<", " ").strip().upper() if mrz.surname else ""
|
| 125 |
+
data['name'] = mrz.names.replace("<<", " ").strip().upper() if mrz.names else ""
|
| 126 |
+
data['sex'] = get_sex(mrz.sex)
|
| 127 |
+
data['date_of_birth'] = parse_date(mrz.date_of_birth) if mrz.date_of_birth else ""
|
| 128 |
+
data['nationality'] = get_country_name(mrz.nationality)
|
| 129 |
+
data['passport_type'] = clean_string(mrz.type)
|
| 130 |
+
data['passport_number'] = clean_string(mrz.number)
|
| 131 |
+
data['issuing_country'] = get_country_name(mrz.country)
|
| 132 |
+
data['expiration_date'] = parse_date(mrz.expiration_date)
|
| 133 |
+
data['personal_number'] = clean_string(mrz.personal_number)
|
| 134 |
+
|
| 135 |
+
# Construct full MRZ string
|
| 136 |
+
# Prefer the OCR'd lines if they exist, otherwise fallback?
|
| 137 |
+
# The original code returned (line1 or "") + (line2 or "")
|
| 138 |
+
data['mrz_full_string'] = (line1 or "") + (line2 or "")
|
| 139 |
+
data['source_file'] = os.path.basename(img_path)
|
| 140 |
+
|
| 141 |
+
return data
|
| 142 |
+
|
| 143 |
+
def process_pdf(self, pdf_path):
|
| 144 |
+
"""
|
| 145 |
+
Converts PDF pages to images and extracts data from each.
|
| 146 |
+
Returns a list of data dictionaries.
|
| 147 |
+
"""
|
| 148 |
+
extracted_data = []
|
| 149 |
+
|
| 150 |
+
# Ensure temp dir exists
|
| 151 |
+
os.makedirs(TEMP_DIR, exist_ok=True)
|
| 152 |
+
|
| 153 |
+
logger.info(f"Processing PDF: {pdf_path}")
|
| 154 |
+
try:
|
| 155 |
+
pages = convert_from_path(pdf_path)
|
| 156 |
+
except Exception as e:
|
| 157 |
+
logger.error(f"Failed to convert PDF {pdf_path}: {e}")
|
| 158 |
+
return []
|
| 159 |
+
|
| 160 |
+
for i, page in enumerate(pages):
|
| 161 |
+
temp_img_path = os.path.join(TEMP_DIR, f"temp_page_{i+1}.png")
|
| 162 |
+
page.save(temp_img_path, "PNG")
|
| 163 |
+
|
| 164 |
+
logger.info(f"Processing page {i+1}...")
|
| 165 |
+
result = self.get_data(temp_img_path)
|
| 166 |
+
|
| 167 |
+
# Special MRZ fix for PDF from original code
|
| 168 |
+
# It seems to re-clean the combined string.
|
| 169 |
+
if result and result.get("mrz_full_string"):
|
| 170 |
+
full_mrz = result["mrz_full_string"]
|
| 171 |
+
if len(full_mrz) >= 88: # 44 * 2
|
| 172 |
+
l1 = full_mrz[:44]
|
| 173 |
+
l2 = full_mrz[44:]
|
| 174 |
+
l1 = clean_mrz_line(l1)
|
| 175 |
+
l2 = clean_mrz_line(l2)
|
| 176 |
+
result["mrz_full_string"] = l1 + l2
|
| 177 |
+
|
| 178 |
+
if result:
|
| 179 |
+
extracted_data.append(result)
|
| 180 |
+
|
| 181 |
+
# Cleanup temp file
|
| 182 |
+
if os.path.exists(temp_img_path):
|
| 183 |
+
os.remove(temp_img_path)
|
| 184 |
+
|
| 185 |
+
return extracted_data
|
src/formats.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import os
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
def export_to_spreadsheet(data_list, output_file, format='excel'):
|
| 8 |
+
"""
|
| 9 |
+
Exports a list of dictionaries to a spreadsheet (Excel or CSV).
|
| 10 |
+
"""
|
| 11 |
+
if not data_list:
|
| 12 |
+
logger.warning("No data to export.")
|
| 13 |
+
return False
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
df = pd.DataFrame(data_list)
|
| 17 |
+
|
| 18 |
+
# Ensure output directory exists
|
| 19 |
+
os.makedirs(os.path.dirname(output_file), exist_ok=True)
|
| 20 |
+
|
| 21 |
+
if format.lower() == 'excel' or output_file.endswith('.xlsx'):
|
| 22 |
+
if not output_file.endswith('.xlsx'):
|
| 23 |
+
output_file += '.xlsx'
|
| 24 |
+
df.to_excel(output_file, index=False)
|
| 25 |
+
logger.info(f"Data exported to {output_file}")
|
| 26 |
+
|
| 27 |
+
elif format.lower() == 'csv' or output_file.endswith('.csv'):
|
| 28 |
+
if not output_file.endswith('.csv'):
|
| 29 |
+
output_file += '.csv'
|
| 30 |
+
df.to_csv(output_file, index=False)
|
| 31 |
+
logger.info(f"Data exported to {output_file}")
|
| 32 |
+
|
| 33 |
+
else:
|
| 34 |
+
logger.error(f"Unsupported format: {format}")
|
| 35 |
+
return False
|
| 36 |
+
|
| 37 |
+
return True
|
| 38 |
+
|
| 39 |
+
except Exception as e:
|
| 40 |
+
logger.error(f"Failed to export data: {e}")
|
| 41 |
+
return False
|
src/utils.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import string as st
|
| 2 |
+
from dateutil import parser
|
| 3 |
+
import logging
|
| 4 |
+
import sys
|
| 5 |
+
from config.settings import COUNTRY_CODES
|
| 6 |
+
|
| 7 |
+
def setup_logger(name=__name__):
|
| 8 |
+
"""Sets up a logger with standard formatting."""
|
| 9 |
+
logger = logging.getLogger(name)
|
| 10 |
+
if not logger.handlers:
|
| 11 |
+
handler = logging.StreamHandler(sys.stdout)
|
| 12 |
+
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
| 13 |
+
handler.setFormatter(formatter)
|
| 14 |
+
logger.addHandler(handler)
|
| 15 |
+
logger.setLevel(logging.INFO)
|
| 16 |
+
return logger
|
| 17 |
+
|
| 18 |
+
logger = setup_logger(__name__)
|
| 19 |
+
|
| 20 |
+
def parse_date(date_obj, iob=True):
|
| 21 |
+
"""Parses a date object or string into DD/MM/YYYY format."""
|
| 22 |
+
try:
|
| 23 |
+
date_str = date_obj.isoformat() if hasattr(date_obj, 'isoformat') else str(date_obj)
|
| 24 |
+
# Passport dates are often YYMMDD, but dateutil usually handles it if formatted correctly.
|
| 25 |
+
# However, MRZ dates are tricky. PassportEye usually returns YYMMDD.
|
| 26 |
+
# parser.parse might struggle with 2-digit years without context, but let's trust existing logic first.
|
| 27 |
+
date = parser.parse(date_str, yearfirst=True).date()
|
| 28 |
+
return date.strftime('%d/%m/%Y')
|
| 29 |
+
except (ValueError, TypeError) as e:
|
| 30 |
+
logger.debug(f"Date parsing failed for {date_obj}: {e}")
|
| 31 |
+
return str(date_obj)
|
| 32 |
+
|
| 33 |
+
def clean_string(text):
|
| 34 |
+
"""Removes non-alphanumeric characters and converts to uppercase."""
|
| 35 |
+
if not text:
|
| 36 |
+
return ""
|
| 37 |
+
return ''.join(i for i in text if i.isalnum()).upper()
|
| 38 |
+
|
| 39 |
+
def clean_mrz_line(line: str) -> str:
|
| 40 |
+
"""Fix bad spacing or bad OCR for MRZ lines."""
|
| 41 |
+
if not line:
|
| 42 |
+
return ""
|
| 43 |
+
|
| 44 |
+
line = line.upper().replace(" ", "")
|
| 45 |
+
|
| 46 |
+
# Remove accidental characters except allowed
|
| 47 |
+
allowed = set(st.ascii_uppercase + st.digits + "<")
|
| 48 |
+
line = "".join([c for c in line if c in allowed])
|
| 49 |
+
|
| 50 |
+
# Ensure 44 length (standard TD3 MRZ length)
|
| 51 |
+
# Note: TD1/TD2 might be different lengths (30 or 36), but this logic enforces 44.
|
| 52 |
+
# We will keep existing logic for consistency but be aware of other formats.
|
| 53 |
+
if len(line) < 44:
|
| 54 |
+
line += "<" * (44 - len(line))
|
| 55 |
+
return line[:44]
|
| 56 |
+
|
| 57 |
+
def get_country_name(country_code):
|
| 58 |
+
"""Resolves 3-letter country code to full name."""
|
| 59 |
+
country_code = str(country_code).upper()
|
| 60 |
+
for c in COUNTRY_CODES:
|
| 61 |
+
if c['alpha-3'] == country_code:
|
| 62 |
+
return c['name'].upper()
|
| 63 |
+
return country_code
|
| 64 |
+
|
| 65 |
+
def get_sex(code):
|
| 66 |
+
"""Standardizes sex code."""
|
| 67 |
+
code = str(code).upper() if code else ''
|
| 68 |
+
if code in ['M', 'F']:
|
| 69 |
+
return code
|
| 70 |
+
if code == '0':
|
| 71 |
+
return 'M' # Fallback based on existing logic
|
| 72 |
+
return code
|
src/validators.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import datetime
|
| 2 |
+
|
| 3 |
+
def validate_passport_data(data):
|
| 4 |
+
"""
|
| 5 |
+
Validates extracted passport data.
|
| 6 |
+
Returns a list of validation warnings/errors.
|
| 7 |
+
"""
|
| 8 |
+
errors = []
|
| 9 |
+
|
| 10 |
+
if not data:
|
| 11 |
+
return ["No data to validate"]
|
| 12 |
+
|
| 13 |
+
# Check required fields
|
| 14 |
+
required_fields = ['surname', 'name', 'passport_number', 'nationality']
|
| 15 |
+
for field in required_fields:
|
| 16 |
+
if not data.get(field):
|
| 17 |
+
errors.append(f"Missing required field: {field}")
|
| 18 |
+
|
| 19 |
+
# Validate dates (basic check if they look like DD/MM/YYYY)
|
| 20 |
+
date_fields = ['date_of_birth', 'expiration_date']
|
| 21 |
+
for field in date_fields:
|
| 22 |
+
val = data.get(field)
|
| 23 |
+
if val:
|
| 24 |
+
try:
|
| 25 |
+
datetime.datetime.strptime(val, '%d/%m/%Y')
|
| 26 |
+
except ValueError:
|
| 27 |
+
errors.append(f"Invalid date format for {field}: {val} (expected DD/MM/YYYY)")
|
| 28 |
+
|
| 29 |
+
# Validate MRZ length roughly (should be around 88 chars for TD3)
|
| 30 |
+
mrz = data.get('mrz_full_string', '')
|
| 31 |
+
if len(mrz) < 80:
|
| 32 |
+
errors.append(f"MRZ string seems too short ({len(mrz)} chars)")
|
| 33 |
+
|
| 34 |
+
return errors
|
tests/test_extractor.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
import os
|
| 3 |
+
from src.extractor import PassportExtractor
|
| 4 |
+
|
| 5 |
+
class TestPassportExtractor(unittest.TestCase):
|
| 6 |
+
def test_initialization(self):
|
| 7 |
+
"""Test if the extractor can be initialized."""
|
| 8 |
+
try:
|
| 9 |
+
extractor = PassportExtractor(use_gpu=False)
|
| 10 |
+
self.assertIsNotNone(extractor.reader)
|
| 11 |
+
except Exception as e:
|
| 12 |
+
self.fail(f"Initialization failed: {e}")
|
| 13 |
+
|
| 14 |
+
def test_utils_import(self):
|
| 15 |
+
"""Test if utils can be imported correctly."""
|
| 16 |
+
from src.utils import clean_string
|
| 17 |
+
self.assertEqual(clean_string("Hello 123!"), "HELLO123")
|
| 18 |
+
|
| 19 |
+
if __name__ == '__main__':
|
| 20 |
+
unittest.main()
|