Commit ·
091afb2
0
Parent(s):
clean version without LFS
Browse files- .env +1 -0
- .gitignore +0 -0
- Dockerfile +37 -0
- IAM_train.py +332 -0
- README.md +459 -0
- app.py +1234 -0
- bridge.py +376 -0
- calibrate_fields.py +196 -0
- calibrated_fields.py +7 -0
- check_cer.py +331 -0
- compare_checkpoints.py +34 -0
- compare_live_cer.py +158 -0
- create_test_images.py +50 -0
- crnn_model.py +119 -0
- dataset.py +401 -0
- debug_and_retrain.py +20 -0
- extract_actual_data.py +203 -0
- field_extractor.py +735 -0
- finetune.py +233 -0
- fix_annotations.py +40 -0
- fix_data.py +770 -0
- generate_dummy_forms.py +375 -0
- generate_form_samples.py +389 -0
- generate_ph_names.py +350 -0
- inference.py +395 -0
- pipeline.py +234 -0
- prepare_emnist.py +97 -0
- requirements.txt +26 -0
- template_matcher.py +1569 -0
- test_flask.html +299 -0
- train.py +438 -0
- train_emnist.py +15 -0
- train_mnist.py +42 -0
- train_with_emnist.py +169 -0
- utils.py +397 -0
.env
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
POPPLER_PATH=C:\Program Files\poppler-25.12.0\Library\bin
|
.gitignore
ADDED
|
Binary file (196 Bytes). View file
|
|
|
Dockerfile
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONUNBUFFERED=1
|
| 4 |
+
ENV DEBIAN_FRONTEND=noninteractive
|
| 5 |
+
ENV PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True
|
| 6 |
+
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 10 |
+
libglib2.0-0 \
|
| 11 |
+
libsm6 \
|
| 12 |
+
libxext6 \
|
| 13 |
+
libxrender1 \
|
| 14 |
+
libgl1 \
|
| 15 |
+
libgomp1 \
|
| 16 |
+
poppler-utils \
|
| 17 |
+
tesseract-ocr \
|
| 18 |
+
tesseract-ocr-eng \
|
| 19 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 20 |
+
|
| 21 |
+
COPY requirements.txt .
|
| 22 |
+
|
| 23 |
+
RUN pip install --no-cache-dir \
|
| 24 |
+
torch \
|
| 25 |
+
torchvision \
|
| 26 |
+
--index-url https://download.pytorch.org/whl/cpu
|
| 27 |
+
|
| 28 |
+
RUN pip install --no-cache-dir paddlepaddle
|
| 29 |
+
|
| 30 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 31 |
+
|
| 32 |
+
RUN python -c "from paddleocr import PaddleOCR; PaddleOCR(lang='en'); print('PaddleOCR models cached')"
|
| 33 |
+
|
| 34 |
+
COPY . .
|
| 35 |
+
|
| 36 |
+
EXPOSE 7860
|
| 37 |
+
CMD ["python", "-u", "app.py"]
|
IAM_train.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
IAM_train.py
|
| 3 |
+
============
|
| 4 |
+
Fine-tune the CRNN model using the IAM Handwriting Word Database.
|
| 5 |
+
Builds on top of EMNIST-trained model (best_model_emnist.pth).
|
| 6 |
+
|
| 7 |
+
FIXES vs old version:
|
| 8 |
+
- IMG_WIDTH 400 -> 512 (must match pipeline)
|
| 9 |
+
- Added log_softmax before CTCLoss (was missing — caused catastrophic forgetting)
|
| 10 |
+
- Phase 1: CNN FROZEN — only RNN+FC trained
|
| 11 |
+
- Phase 2: Full model at very low LR
|
| 12 |
+
- Loads from best_model_emnist.pth, falls back to best_model.pth
|
| 13 |
+
- Uses get_crnn_model() with correct architecture from checkpoint config
|
| 14 |
+
|
| 15 |
+
DATASET:
|
| 16 |
+
Download from: https://www.kaggle.com/datasets/nibinv23/iam-handwriting-word-database
|
| 17 |
+
Expected structure:
|
| 18 |
+
data/IAM/iam_words/
|
| 19 |
+
words/ <- word image folders (a01, a02, ...)
|
| 20 |
+
words.txt <- annotation file
|
| 21 |
+
|
| 22 |
+
USAGE:
|
| 23 |
+
python IAM_train.py --prepare # convert IAM -> annotation JSON
|
| 24 |
+
python IAM_train.py --train # fine-tune model
|
| 25 |
+
python IAM_train.py --prepare --train # do both
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
import os
|
| 29 |
+
import sys
|
| 30 |
+
import json
|
| 31 |
+
import argparse
|
| 32 |
+
import random
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
|
| 35 |
+
import torch
|
| 36 |
+
import torch.nn.functional as F
|
| 37 |
+
import torch.optim as optim
|
| 38 |
+
from torch.utils.data import DataLoader, ConcatDataset
|
| 39 |
+
|
| 40 |
+
sys.path.append('.')
|
| 41 |
+
from crnn_model import get_crnn_model
|
| 42 |
+
from dataset import CivilRegistryDataset, collate_fn
|
| 43 |
+
|
| 44 |
+
# ─────────────────────────────────────────────
|
| 45 |
+
# CONFIG
|
| 46 |
+
# ─────────────────────────────────────────────
|
| 47 |
+
IAM_ROOT = "data/IAM/iam_words"
|
| 48 |
+
IAM_WORDS_TXT = f"{IAM_ROOT}/words.txt"
|
| 49 |
+
IAM_WORDS_DIR = f"{IAM_ROOT}/words"
|
| 50 |
+
|
| 51 |
+
TRAIN_ANN = "data/iam_train_annotations.json"
|
| 52 |
+
IAM_VAL_ANN = "data/iam_val_annotations.json" # written by --prepare (IAM word images)
|
| 53 |
+
SYNTH_VAL_ANN = "data/val_annotations.json" # real civil registry val set — never overwritten
|
| 54 |
+
TRAIN_IMG_DIR = "data/train/iam"
|
| 55 |
+
VAL_IMG_DIR = "data/val/iam"
|
| 56 |
+
|
| 57 |
+
IMG_HEIGHT = 64
|
| 58 |
+
IMG_WIDTH = 512 # FIXED: was 400 — must match pipeline
|
| 59 |
+
BATCH_SIZE = 32
|
| 60 |
+
VAL_SPLIT = 0.1
|
| 61 |
+
MAX_SAMPLES = 50000
|
| 62 |
+
|
| 63 |
+
# Load from EMNIST checkpoint, fall back to synthetic if not found
|
| 64 |
+
CHECKPOINT_IN = "checkpoints/best_model_emnist.pth"
|
| 65 |
+
CHECKPOINT_IN2 = "checkpoints/best_model.pth" # fallback
|
| 66 |
+
CHECKPOINT_OUT = "checkpoints/best_model_iam.pth"
|
| 67 |
+
|
| 68 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ─────────────────────────────────────────────
|
| 72 |
+
# STEP 1 — PREPARE
|
| 73 |
+
# ─────────────────────────────────────────────
|
| 74 |
+
def prepare_iam():
|
| 75 |
+
from PIL import Image
|
| 76 |
+
|
| 77 |
+
print("\n" + "=" * 50)
|
| 78 |
+
print("STEP 1 — Preparing IAM dataset")
|
| 79 |
+
print("=" * 50)
|
| 80 |
+
|
| 81 |
+
if not os.path.exists(IAM_WORDS_TXT):
|
| 82 |
+
print(f"ERROR: {IAM_WORDS_TXT} not found!")
|
| 83 |
+
print("Download from: https://www.kaggle.com/datasets/nibinv23/iam-handwriting-word-database")
|
| 84 |
+
print("Expected structure:")
|
| 85 |
+
print(" data/IAM/iam_words/words.txt")
|
| 86 |
+
print(" data/IAM/iam_words/words/")
|
| 87 |
+
sys.exit(1)
|
| 88 |
+
|
| 89 |
+
os.makedirs(TRAIN_IMG_DIR, exist_ok=True)
|
| 90 |
+
os.makedirs(VAL_IMG_DIR, exist_ok=True)
|
| 91 |
+
|
| 92 |
+
entries = []
|
| 93 |
+
print(f" Reading {IAM_WORDS_TXT} ...")
|
| 94 |
+
with open(IAM_WORDS_TXT, "r") as f:
|
| 95 |
+
for line in f:
|
| 96 |
+
line = line.strip()
|
| 97 |
+
if not line or line.startswith("#"):
|
| 98 |
+
continue
|
| 99 |
+
parts = line.split(" ")
|
| 100 |
+
if len(parts) < 9:
|
| 101 |
+
continue
|
| 102 |
+
word_id = parts[0]
|
| 103 |
+
seg_result = parts[1]
|
| 104 |
+
text = parts[-1]
|
| 105 |
+
if seg_result != "ok":
|
| 106 |
+
continue
|
| 107 |
+
if len(text) < 1 or len(text) > 32:
|
| 108 |
+
continue
|
| 109 |
+
parts_id = word_id.split("-")
|
| 110 |
+
img_path = os.path.join(
|
| 111 |
+
IAM_WORDS_DIR,
|
| 112 |
+
parts_id[0],
|
| 113 |
+
f"{parts_id[0]}-{parts_id[1]}",
|
| 114 |
+
f"{word_id}.png"
|
| 115 |
+
)
|
| 116 |
+
if not os.path.exists(img_path):
|
| 117 |
+
continue
|
| 118 |
+
entries.append((img_path, text))
|
| 119 |
+
|
| 120 |
+
print(f" Found {len(entries)} valid word entries")
|
| 121 |
+
|
| 122 |
+
if MAX_SAMPLES and len(entries) > MAX_SAMPLES:
|
| 123 |
+
random.shuffle(entries)
|
| 124 |
+
entries = entries[:MAX_SAMPLES]
|
| 125 |
+
print(f" Limiting to {MAX_SAMPLES} samples")
|
| 126 |
+
|
| 127 |
+
random.shuffle(entries)
|
| 128 |
+
split_idx = int(len(entries) * (1 - VAL_SPLIT))
|
| 129 |
+
train_entries = entries[:split_idx]
|
| 130 |
+
val_entries = entries[split_idx:]
|
| 131 |
+
print(f" Train: {len(train_entries)} | Val: {len(val_entries)}")
|
| 132 |
+
print(" Copying and resizing images...")
|
| 133 |
+
|
| 134 |
+
def process_entries(entry_list, out_dir, prefix):
|
| 135 |
+
annotations = []
|
| 136 |
+
for i, (src_path, text) in enumerate(entry_list):
|
| 137 |
+
try:
|
| 138 |
+
img = Image.open(src_path).convert("RGB")
|
| 139 |
+
img = img.resize((IMG_WIDTH, IMG_HEIGHT)) # FIXED: 512x64
|
| 140 |
+
fname = f"iam_{prefix}_{i:06d}.jpg"
|
| 141 |
+
out_path = os.path.join(out_dir, fname)
|
| 142 |
+
img.save(out_path, quality=90)
|
| 143 |
+
annotations.append({"image_path": f"iam/{fname}", "text": text})
|
| 144 |
+
except Exception:
|
| 145 |
+
continue
|
| 146 |
+
if i % 5000 == 0:
|
| 147 |
+
print(f" {i}/{len(entry_list)} processed...")
|
| 148 |
+
return annotations
|
| 149 |
+
|
| 150 |
+
train_ann = process_entries(train_entries, TRAIN_IMG_DIR, "train")
|
| 151 |
+
val_ann = process_entries(val_entries, VAL_IMG_DIR, "val")
|
| 152 |
+
|
| 153 |
+
with open(TRAIN_ANN, "w") as f:
|
| 154 |
+
json.dump(train_ann, f, indent=2)
|
| 155 |
+
with open(IAM_VAL_ANN, "w") as f:
|
| 156 |
+
json.dump(val_ann, f, indent=2)
|
| 157 |
+
|
| 158 |
+
print(f"\n Train annotations -> {TRAIN_ANN} ({len(train_ann)} entries)")
|
| 159 |
+
print(f" Val annotations -> {IAM_VAL_ANN} ({len(val_ann)} entries)")
|
| 160 |
+
print("\n Done! Now run: python IAM_train.py --train")
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ─────────────────────────────────────────────
|
| 164 |
+
# STEP 2 — TRAIN
|
| 165 |
+
# ─────────────────────────────────────────────
|
| 166 |
+
def train_iam():
|
| 167 |
+
print("\n" + "=" * 55)
|
| 168 |
+
print("STEP 2 — Fine-tuning CRNN with IAM dataset")
|
| 169 |
+
print("=" * 55)
|
| 170 |
+
print(f" Device : {DEVICE}")
|
| 171 |
+
|
| 172 |
+
for ann_file in [TRAIN_ANN, SYNTH_VAL_ANN]:
|
| 173 |
+
if not os.path.exists(ann_file):
|
| 174 |
+
print(f"ERROR: {ann_file} not found! Run --prepare first.")
|
| 175 |
+
sys.exit(1)
|
| 176 |
+
|
| 177 |
+
train_dataset = CivilRegistryDataset(
|
| 178 |
+
data_dir="data/train", annotations_file=TRAIN_ANN,
|
| 179 |
+
img_height=IMG_HEIGHT, img_width=IMG_WIDTH, augment=True
|
| 180 |
+
)
|
| 181 |
+
# FIXED: mix synthetic data in so the model never forgets Filipino multi-word sequences
|
| 182 |
+
synth_dataset = CivilRegistryDataset(
|
| 183 |
+
data_dir="data/train", annotations_file="data/train_annotations.json",
|
| 184 |
+
img_height=IMG_HEIGHT, img_width=IMG_WIDTH, augment=True
|
| 185 |
+
)
|
| 186 |
+
mixed_train = ConcatDataset([train_dataset, synth_dataset])
|
| 187 |
+
val_dataset = CivilRegistryDataset(
|
| 188 |
+
data_dir="data/val", annotations_file=SYNTH_VAL_ANN,
|
| 189 |
+
img_height=IMG_HEIGHT, img_width=IMG_WIDTH, augment=False
|
| 190 |
+
)
|
| 191 |
+
print(f" IAM train : {len(train_dataset)}")
|
| 192 |
+
print(f" Synthetic train: {len(synth_dataset)}")
|
| 193 |
+
print(f" Mixed train : {len(mixed_train)}")
|
| 194 |
+
print(f" Val : {len(val_dataset)}")
|
| 195 |
+
|
| 196 |
+
train_loader = DataLoader(mixed_train, batch_size=BATCH_SIZE,
|
| 197 |
+
shuffle=True, num_workers=0, collate_fn=collate_fn)
|
| 198 |
+
val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE,
|
| 199 |
+
shuffle=False, num_workers=0, collate_fn=collate_fn)
|
| 200 |
+
|
| 201 |
+
# ── Load checkpoint (EMNIST preferred, synthetic fallback) ──
|
| 202 |
+
ckpt_path = CHECKPOINT_IN if os.path.exists(CHECKPOINT_IN) else CHECKPOINT_IN2
|
| 203 |
+
if not os.path.exists(ckpt_path):
|
| 204 |
+
print(f"ERROR: No checkpoint found at {CHECKPOINT_IN} or {CHECKPOINT_IN2}")
|
| 205 |
+
print("Run: python train.py then python train_with_emnist.py")
|
| 206 |
+
sys.exit(1)
|
| 207 |
+
|
| 208 |
+
print(f" Loading: {ckpt_path}")
|
| 209 |
+
ckpt = torch.load(ckpt_path, map_location=DEVICE, weights_only=False)
|
| 210 |
+
config = ckpt.get('config', {})
|
| 211 |
+
|
| 212 |
+
model = get_crnn_model(
|
| 213 |
+
model_type = config.get('model_type', 'standard'),
|
| 214 |
+
img_height = config.get('img_height', 64),
|
| 215 |
+
num_chars = train_dataset.num_chars,
|
| 216 |
+
hidden_size = config.get('hidden_size', 128),
|
| 217 |
+
num_lstm_layers = config.get('num_lstm_layers', 1),
|
| 218 |
+
).to(DEVICE)
|
| 219 |
+
|
| 220 |
+
missing, _ = model.load_state_dict(ckpt['model_state_dict'], strict=False)
|
| 221 |
+
if missing:
|
| 222 |
+
print(f" Note: {len(missing)} layers re-initialized")
|
| 223 |
+
print(f" Loaded epoch {ckpt.get('epoch', 'N/A')} "
|
| 224 |
+
f"val_loss={ckpt.get('val_loss', ckpt.get('val_cer', 0)):.4f}")
|
| 225 |
+
|
| 226 |
+
criterion = torch.nn.CTCLoss(blank=0, reduction='mean', zero_infinity=True)
|
| 227 |
+
os.makedirs("checkpoints", exist_ok=True)
|
| 228 |
+
|
| 229 |
+
def run_epoch(loader, training, optimizer=None):
|
| 230 |
+
model.train() if training else model.eval()
|
| 231 |
+
total, n = 0, 0
|
| 232 |
+
ctx = torch.enable_grad() if training else torch.no_grad()
|
| 233 |
+
with ctx:
|
| 234 |
+
for images, targets, target_lengths, _ in loader:
|
| 235 |
+
images = images.to(DEVICE)
|
| 236 |
+
batch_size = images.size(0)
|
| 237 |
+
if training:
|
| 238 |
+
optimizer.zero_grad()
|
| 239 |
+
# CRITICAL: log_softmax before CTCLoss
|
| 240 |
+
outputs = F.log_softmax(model(images), dim=2)
|
| 241 |
+
seq_len = outputs.size(0)
|
| 242 |
+
input_lengths = torch.full((batch_size,), seq_len, dtype=torch.long)
|
| 243 |
+
loss = criterion(outputs, targets, input_lengths, target_lengths)
|
| 244 |
+
if not torch.isnan(loss) and not torch.isinf(loss):
|
| 245 |
+
if training:
|
| 246 |
+
loss.backward()
|
| 247 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 5)
|
| 248 |
+
optimizer.step()
|
| 249 |
+
total += loss.item()
|
| 250 |
+
n += 1
|
| 251 |
+
return total / max(n, 1)
|
| 252 |
+
|
| 253 |
+
def run_phase(num, epochs, lr, freeze_cnn, patience):
|
| 254 |
+
print(f"\n{'='*55}")
|
| 255 |
+
print(f" PHASE {num} — "
|
| 256 |
+
f"{'CNN FROZEN (RNN+FC only)' if freeze_cnn else 'FULL MODEL (all layers)'}"
|
| 257 |
+
f" LR={lr}")
|
| 258 |
+
print(f"{'='*55}")
|
| 259 |
+
|
| 260 |
+
for name, param in model.named_parameters():
|
| 261 |
+
param.requires_grad = not (freeze_cnn and 'cnn' in name)
|
| 262 |
+
|
| 263 |
+
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 264 |
+
print(f" Trainable params : {trainable:,}")
|
| 265 |
+
|
| 266 |
+
opt = optim.Adam(
|
| 267 |
+
filter(lambda p: p.requires_grad, model.parameters()), lr=lr)
|
| 268 |
+
sched = optim.lr_scheduler.ReduceLROnPlateau(opt, patience=3, factor=0.5)
|
| 269 |
+
best = float('inf')
|
| 270 |
+
counter = 0
|
| 271 |
+
|
| 272 |
+
for epoch in range(1, epochs + 1):
|
| 273 |
+
tr = run_epoch(train_loader, True, opt)
|
| 274 |
+
vl = run_epoch(val_loader, False, None)
|
| 275 |
+
sched.step(vl)
|
| 276 |
+
|
| 277 |
+
if vl < best:
|
| 278 |
+
best = vl
|
| 279 |
+
counter = 0
|
| 280 |
+
torch.save({
|
| 281 |
+
'model_state_dict': model.state_dict(),
|
| 282 |
+
'config': config,
|
| 283 |
+
'char_to_idx': train_dataset.char_to_idx,
|
| 284 |
+
'idx_to_char': train_dataset.idx_to_char,
|
| 285 |
+
'epoch': epoch,
|
| 286 |
+
'val_loss': vl, # FIXED: renamed from val_cer — this is val loss, not CER%
|
| 287 |
+
}, CHECKPOINT_OUT)
|
| 288 |
+
print(f" Epoch {epoch:02d}/{epochs} "
|
| 289 |
+
f"Train={tr:.4f} Val={vl:.4f} <- saved")
|
| 290 |
+
else:
|
| 291 |
+
counter += 1
|
| 292 |
+
print(f" Epoch {epoch:02d}/{epochs} "
|
| 293 |
+
f"Train={tr:.4f} Val={vl:.4f} "
|
| 294 |
+
f"(patience {counter}/{patience})")
|
| 295 |
+
if counter >= patience:
|
| 296 |
+
print(f" Early stopping at epoch {epoch}.")
|
| 297 |
+
break
|
| 298 |
+
return best
|
| 299 |
+
|
| 300 |
+
# Phase 1: Freeze CNN
|
| 301 |
+
p1 = run_phase(1, epochs=30, lr=1e-4, freeze_cnn=True, patience=7)
|
| 302 |
+
# Phase 2: Full model, very low LR
|
| 303 |
+
p2 = run_phase(2, epochs=20, lr=1e-6, freeze_cnn=False, patience=5)
|
| 304 |
+
|
| 305 |
+
print(f"\n{'='*55}")
|
| 306 |
+
print(f"IAM fine-tuning complete!")
|
| 307 |
+
print(f" Phase 1 best val loss : {p1:.4f}")
|
| 308 |
+
print(f" Phase 2 best val loss : {p2:.4f}")
|
| 309 |
+
print(f" Saved : {CHECKPOINT_OUT}")
|
| 310 |
+
print(f"\nNext step: collect physical certificate scans")
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
# ─────────────────────────────────────────────
|
| 314 |
+
# MAIN
|
| 315 |
+
# ─────────────────────────────────────────────
|
| 316 |
+
if __name__ == "__main__":
|
| 317 |
+
parser = argparse.ArgumentParser()
|
| 318 |
+
parser.add_argument("--prepare", action="store_true")
|
| 319 |
+
parser.add_argument("--train", action="store_true")
|
| 320 |
+
args = parser.parse_args()
|
| 321 |
+
|
| 322 |
+
if not args.prepare and not args.train:
|
| 323 |
+
print("Usage:")
|
| 324 |
+
print(" python IAM_train.py --prepare # prepare dataset")
|
| 325 |
+
print(" python IAM_train.py --train # train model")
|
| 326 |
+
print(" python IAM_train.py --prepare --train # do both")
|
| 327 |
+
sys.exit(0)
|
| 328 |
+
|
| 329 |
+
if args.prepare:
|
| 330 |
+
prepare_iam()
|
| 331 |
+
if args.train:
|
| 332 |
+
train_iam()
|
README.md
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: LCR OCR API
|
| 3 |
+
emoji: 📄
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_file: app.py
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Local Civil Registry Document Digitization and Data Extraction
|
| 12 |
+
|
| 13 |
+
## Using CRNN+CTC, Multinomial Naive Bayes, and Named Entity Recognition
|
| 14 |
+
|
| 15 |
+
**Thesis Project by:**
|
| 16 |
+
- Shane Mark C. Blanco
|
| 17 |
+
- Princess A. Pasamonte
|
| 18 |
+
- Irish Faith G. Ramirez
|
| 19 |
+
|
| 20 |
+
**Institution:** Tarlac State University, College of Computer Studies
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
## 📋 Project Overview
|
| 25 |
+
|
| 26 |
+
This system automates the digitization and data extraction of Philippine Civil Registry documents using advanced machine learning algorithms:
|
| 27 |
+
|
| 28 |
+
### Target Documents:
|
| 29 |
+
- **Form 1A** - Birth Certificate
|
| 30 |
+
- **Form 2A** - Death Certificate
|
| 31 |
+
- **Form 3A** - Marriage Certificate
|
| 32 |
+
- **Form 90** - Application of Marriage License
|
| 33 |
+
|
| 34 |
+
### Key Features:
|
| 35 |
+
✅ OCR for printed and handwritten text
|
| 36 |
+
✅ Automatic document classification
|
| 37 |
+
✅ Named entity extraction (names, dates, places)
|
| 38 |
+
✅ Auto-fill digital forms
|
| 39 |
+
✅ MySQL database storage
|
| 40 |
+
✅ Searchable digital archive
|
| 41 |
+
✅ Data visualization dashboard
|
| 42 |
+
|
| 43 |
+
---
|
| 44 |
+
|
| 45 |
+
## 🏗️ System Architecture
|
| 46 |
+
|
| 47 |
+
```
|
| 48 |
+
Input: Scanned Civil Registry Form
|
| 49 |
+
↓
|
| 50 |
+
1. Image Preprocessing
|
| 51 |
+
↓
|
| 52 |
+
2. CRNN+CTC → Text Recognition
|
| 53 |
+
↓
|
| 54 |
+
3. Multinomial Naive Bayes → Document Classification
|
| 55 |
+
↓
|
| 56 |
+
4. spaCy NER → Entity Extraction
|
| 57 |
+
↓
|
| 58 |
+
5. Data Validation & Storage → MySQL Database
|
| 59 |
+
↓
|
| 60 |
+
Output: Digitized & Searchable Record
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
---
|
| 64 |
+
|
| 65 |
+
## 🚀 Quick Start
|
| 66 |
+
|
| 67 |
+
### Prerequisites
|
| 68 |
+
|
| 69 |
+
- Python 3.8+
|
| 70 |
+
- CUDA-capable GPU (recommended) or CPU
|
| 71 |
+
- 8GB RAM minimum
|
| 72 |
+
|
| 73 |
+
### Installation
|
| 74 |
+
|
| 75 |
+
```bash
|
| 76 |
+
# 1. Clone or download the project
|
| 77 |
+
cd civil_registry_ocr
|
| 78 |
+
|
| 79 |
+
# 2. Create virtual environment
|
| 80 |
+
python -m venv venv
|
| 81 |
+
source venv/bin/activate # Linux/Mac
|
| 82 |
+
venv\Scripts\activate # Windows
|
| 83 |
+
|
| 84 |
+
# 3. Install dependencies
|
| 85 |
+
pip install -r requirements.txt
|
| 86 |
+
|
| 87 |
+
# 4. Download spaCy model
|
| 88 |
+
python -m spacy download en_core_web_sm
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
### Quick Test
|
| 92 |
+
|
| 93 |
+
```python
|
| 94 |
+
from inference import CivilRegistryOCR
|
| 95 |
+
|
| 96 |
+
# Load model
|
| 97 |
+
ocr = CivilRegistryOCR('checkpoints/best_model.pth')
|
| 98 |
+
|
| 99 |
+
# Recognize text
|
| 100 |
+
text = ocr.predict('test_images/sample_name.jpg')
|
| 101 |
+
print(f"Recognized: {text}")
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
---
|
| 105 |
+
|
| 106 |
+
## 📁 Project Files
|
| 107 |
+
|
| 108 |
+
### Core Implementation Files:
|
| 109 |
+
|
| 110 |
+
1. **crnn_model.py** - CRNN+CTC neural network architecture
|
| 111 |
+
2. **dataset.py** - Data loading and preprocessing
|
| 112 |
+
3. **train.py** - Model training script
|
| 113 |
+
4. **inference.py** - Prediction and inference
|
| 114 |
+
5. **utils.py** - Helper functions and metrics
|
| 115 |
+
6. **requirements.txt** - Python dependencies
|
| 116 |
+
7. **IMPLEMENTATION_GUIDE.md** - Detailed implementation guide
|
| 117 |
+
|
| 118 |
+
### Additional Components (To be created):
|
| 119 |
+
|
| 120 |
+
8. **document_classifier.py** - Multinomial Naive Bayes classifier
|
| 121 |
+
9. **ner_extractor.py** - Named Entity Recognition
|
| 122 |
+
10. **web_app.py** - Web application (Flask/FastAPI)
|
| 123 |
+
11. **database.py** - MySQL integration
|
| 124 |
+
|
| 125 |
+
---
|
| 126 |
+
|
| 127 |
+
## 📊 Training the Model
|
| 128 |
+
|
| 129 |
+
### 1. Prepare Your Data
|
| 130 |
+
|
| 131 |
+
Organize images and labels:
|
| 132 |
+
```
|
| 133 |
+
data/
|
| 134 |
+
train/
|
| 135 |
+
form1a/
|
| 136 |
+
name_001.jpg
|
| 137 |
+
name_001.txt
|
| 138 |
+
form2a/
|
| 139 |
+
...
|
| 140 |
+
val/
|
| 141 |
+
...
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
### 2. Create Annotations
|
| 145 |
+
|
| 146 |
+
```python
|
| 147 |
+
from dataset import create_annotation_file
|
| 148 |
+
|
| 149 |
+
create_annotation_file('data/train', 'data/train_annotations.json')
|
| 150 |
+
create_annotation_file('data/val', 'data/val_annotations.json')
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
### 3. Train Model
|
| 154 |
+
|
| 155 |
+
```bash
|
| 156 |
+
python train.py
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
Monitor metrics:
|
| 160 |
+
- Character Error Rate (CER)
|
| 161 |
+
- Word Error Rate (WER)
|
| 162 |
+
- Training/Validation Loss
|
| 163 |
+
|
| 164 |
+
### 4. Evaluate
|
| 165 |
+
|
| 166 |
+
```python
|
| 167 |
+
from utils import calculate_cer, calculate_wer
|
| 168 |
+
|
| 169 |
+
predictions = [ocr.predict(img) for img in test_images]
|
| 170 |
+
cer = calculate_cer(predictions, ground_truths)
|
| 171 |
+
print(f"CER: {cer:.2f}%")
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
---
|
| 175 |
+
|
| 176 |
+
## 🌐 Web Application
|
| 177 |
+
|
| 178 |
+
### Start the Server
|
| 179 |
+
|
| 180 |
+
```bash
|
| 181 |
+
python web_app.py
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
### API Endpoints
|
| 185 |
+
|
| 186 |
+
**POST /api/ocr** - Process document
|
| 187 |
+
```bash
|
| 188 |
+
curl -X POST -F "file=@birth_cert.jpg" http://localhost:8000/api/ocr
|
| 189 |
+
```
|
| 190 |
+
|
| 191 |
+
**Response:**
|
| 192 |
+
```json
|
| 193 |
+
{
|
| 194 |
+
"text": "Juan Dela Cruz\n01/15/1990\nTarlac City",
|
| 195 |
+
"form_type": "form1a",
|
| 196 |
+
"entities": {
|
| 197 |
+
"persons": ["Juan Dela Cruz"],
|
| 198 |
+
"dates": ["01/15/1990"],
|
| 199 |
+
"locations": ["Tarlac City"]
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
---
|
| 205 |
+
|
| 206 |
+
## 🎯 Expected Performance
|
| 207 |
+
|
| 208 |
+
Based on thesis objectives:
|
| 209 |
+
|
| 210 |
+
### CRNN+CTC Model:
|
| 211 |
+
- **Target CER:** < 5%
|
| 212 |
+
- **Target Accuracy:** > 95%
|
| 213 |
+
- Handles both printed and handwritten text
|
| 214 |
+
|
| 215 |
+
### Document Classifier (MNB):
|
| 216 |
+
- **Target Accuracy:** > 90%
|
| 217 |
+
- Fast classification (< 100ms)
|
| 218 |
+
|
| 219 |
+
### NER (spaCy):
|
| 220 |
+
- **F1 Score:** > 85%
|
| 221 |
+
- Extracts: Names, Dates, Places
|
| 222 |
+
|
| 223 |
+
---
|
| 224 |
+
|
| 225 |
+
## 🧪 Testing
|
| 226 |
+
|
| 227 |
+
### ISO 25010 Evaluation
|
| 228 |
+
|
| 229 |
+
**Usability Testing:**
|
| 230 |
+
```python
|
| 231 |
+
# Metrics to measure:
|
| 232 |
+
- Task completion rate
|
| 233 |
+
- Average time per task
|
| 234 |
+
- User satisfaction score (SUS)
|
| 235 |
+
```
|
| 236 |
+
|
| 237 |
+
**Reliability Testing:**
|
| 238 |
+
```python
|
| 239 |
+
# Metrics to measure:
|
| 240 |
+
- System uptime %
|
| 241 |
+
- Error rate
|
| 242 |
+
- Recovery time
|
| 243 |
+
```
|
| 244 |
+
|
| 245 |
+
### Confusion Matrix
|
| 246 |
+
|
| 247 |
+
```python
|
| 248 |
+
from sklearn.metrics import confusion_matrix
|
| 249 |
+
import seaborn as sns
|
| 250 |
+
|
| 251 |
+
cm = confusion_matrix(true_labels, predicted_labels)
|
| 252 |
+
sns.heatmap(cm, annot=True)
|
| 253 |
+
```
|
| 254 |
+
|
| 255 |
+
---
|
| 256 |
+
|
| 257 |
+
## 💾 Database Schema
|
| 258 |
+
|
| 259 |
+
### Birth Certificates Table
|
| 260 |
+
```sql
|
| 261 |
+
CREATE TABLE birth_certificates (
|
| 262 |
+
id INT PRIMARY KEY AUTO_INCREMENT,
|
| 263 |
+
child_name VARCHAR(255),
|
| 264 |
+
date_of_birth DATE,
|
| 265 |
+
place_of_birth VARCHAR(255),
|
| 266 |
+
sex CHAR(1),
|
| 267 |
+
father_name VARCHAR(255),
|
| 268 |
+
mother_name VARCHAR(255),
|
| 269 |
+
raw_text TEXT,
|
| 270 |
+
form_image LONGBLOB,
|
| 271 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 272 |
+
);
|
| 273 |
+
```
|
| 274 |
+
|
| 275 |
+
---
|
| 276 |
+
|
| 277 |
+
## 📈 System Requirements
|
| 278 |
+
|
| 279 |
+
### Minimum:
|
| 280 |
+
- CPU: Intel i5 or equivalent
|
| 281 |
+
- RAM: 8GB
|
| 282 |
+
- Storage: 10GB
|
| 283 |
+
- OS: Windows 10, Ubuntu 18.04, macOS 10.14
|
| 284 |
+
|
| 285 |
+
### Recommended:
|
| 286 |
+
- CPU: Intel i7 or equivalent
|
| 287 |
+
- GPU: NVIDIA GTX 1060 or better
|
| 288 |
+
- RAM: 16GB
|
| 289 |
+
- Storage: 50GB SSD
|
| 290 |
+
|
| 291 |
+
---
|
| 292 |
+
|
| 293 |
+
## 🔒 Data Privacy & Security
|
| 294 |
+
|
| 295 |
+
Following Philippine Data Privacy Act (RA 10173):
|
| 296 |
+
|
| 297 |
+
- ✅ Encrypted data transmission
|
| 298 |
+
- ✅ Access control and authentication
|
| 299 |
+
- ✅ Audit logging
|
| 300 |
+
- ✅ Regular security updates
|
| 301 |
+
- ✅ Data retention policies
|
| 302 |
+
|
| 303 |
+
---
|
| 304 |
+
|
| 305 |
+
## 📚 Key Algorithms
|
| 306 |
+
|
| 307 |
+
### 1. CRNN+CTC
|
| 308 |
+
**Purpose:** Text recognition from images
|
| 309 |
+
**Strengths:** Handles variable-length sequences, no character segmentation needed
|
| 310 |
+
**Reference:** Shi et al. (2016)
|
| 311 |
+
|
| 312 |
+
### 2. Multinomial Naive Bayes
|
| 313 |
+
**Purpose:** Document classification
|
| 314 |
+
**Strengths:** Fast, efficient, works well with text data
|
| 315 |
+
**Reference:** McCallum & Nigam (1998)
|
| 316 |
+
|
| 317 |
+
### 3. Named Entity Recognition
|
| 318 |
+
**Purpose:** Extract entities (names, dates, places)
|
| 319 |
+
**Strengths:** Pre-trained, accurate, easy to use
|
| 320 |
+
**Reference:** spaCy (Honnibal & Montani, 2017)
|
| 321 |
+
|
| 322 |
+
---
|
| 323 |
+
|
| 324 |
+
## 🛠️ Troubleshooting
|
| 325 |
+
|
| 326 |
+
### Low Accuracy?
|
| 327 |
+
1. Increase training data (target: 10,000+ samples)
|
| 328 |
+
2. Use data augmentation
|
| 329 |
+
3. Train longer (100+ epochs)
|
| 330 |
+
4. Clean your dataset
|
| 331 |
+
|
| 332 |
+
### Out of Memory?
|
| 333 |
+
1. Reduce batch size
|
| 334 |
+
2. Use smaller image dimensions
|
| 335 |
+
3. Use gradient accumulation
|
| 336 |
+
4. Enable mixed precision
|
| 337 |
+
|
| 338 |
+
### Slow Inference?
|
| 339 |
+
1. Use GPU if available
|
| 340 |
+
2. Batch process images
|
| 341 |
+
3. Optimize model (ONNX)
|
| 342 |
+
4. Cache frequent results
|
| 343 |
+
|
| 344 |
+
---
|
| 345 |
+
|
| 346 |
+
## 📖 Documentation
|
| 347 |
+
|
| 348 |
+
- **IMPLEMENTATION_GUIDE.md** - Complete step-by-step guide
|
| 349 |
+
- **API_DOCUMENTATION.md** - API reference (to be created)
|
| 350 |
+
- **USER_MANUAL.md** - End-user guide (to be created)
|
| 351 |
+
|
| 352 |
+
---
|
| 353 |
+
|
| 354 |
+
## 🎓 Academic References
|
| 355 |
+
|
| 356 |
+
### Key Papers:
|
| 357 |
+
|
| 358 |
+
1. **CRNN**
|
| 359 |
+
Shi, B., Bai, X., & Yao, C. (2016). An end-to-end trainable neural network for image-based sequence recognition and its application to scene text recognition. *IEEE TPAMI*.
|
| 360 |
+
|
| 361 |
+
2. **CTC Loss**
|
| 362 |
+
Graves, A., et al. (2006). Connectionist temporal classification: Labelling unsegmented sequence data with recurrent neural networks. *ICML*.
|
| 363 |
+
|
| 364 |
+
3. **Naive Bayes**
|
| 365 |
+
McCallum, A., & Nigam, K. (1998). A comparison of event models for naive bayes text classification. *AAAI Workshop*.
|
| 366 |
+
|
| 367 |
+
4. **spaCy**
|
| 368 |
+
Honnibal, M., & Montani, I. (2017). spaCy 2: Natural language understanding with Bloom embeddings, convolutional neural networks and incremental parsing.
|
| 369 |
+
|
| 370 |
+
---
|
| 371 |
+
|
| 372 |
+
## 👥 Contributors
|
| 373 |
+
|
| 374 |
+
**Researchers:**
|
| 375 |
+
- Shane Mark C. Blanco
|
| 376 |
+
- Princess A. Pasamonte
|
| 377 |
+
- Irish Faith G. Ramirez
|
| 378 |
+
|
| 379 |
+
**Advisers:**
|
| 380 |
+
- Mr. Rengel V. Corpuz (Technical Adviser)
|
| 381 |
+
- Mr. Joselito T. Tan (Subject Teacher)
|
| 382 |
+
|
| 383 |
+
**Institution:**
|
| 384 |
+
Tarlac State University
|
| 385 |
+
College of Computer Studies
|
| 386 |
+
Bachelor of Science in Computer Science
|
| 387 |
+
|
| 388 |
+
---
|
| 389 |
+
|
| 390 |
+
## 📞 Support
|
| 391 |
+
|
| 392 |
+
For questions regarding this implementation:
|
| 393 |
+
|
| 394 |
+
1. Review IMPLEMENTATION_GUIDE.md
|
| 395 |
+
2. Check code documentation
|
| 396 |
+
3. Consult with thesis advisers
|
| 397 |
+
|
| 398 |
+
---
|
| 399 |
+
|
| 400 |
+
## 📄 License
|
| 401 |
+
|
| 402 |
+
This project is for academic purposes as part of a thesis requirement.
|
| 403 |
+
|
| 404 |
+
---
|
| 405 |
+
|
| 406 |
+
## ✅ Implementation Checklist
|
| 407 |
+
|
| 408 |
+
### Phase 1: Setup ✓
|
| 409 |
+
- [x] Install dependencies
|
| 410 |
+
- [x] Set up project structure
|
| 411 |
+
- [x] Prepare development environment
|
| 412 |
+
|
| 413 |
+
### Phase 2: Data Preparation
|
| 414 |
+
- [ ] Collect civil registry form images
|
| 415 |
+
- [ ] Create annotations
|
| 416 |
+
- [ ] Split into train/val/test sets
|
| 417 |
+
|
| 418 |
+
### Phase 3: Model Development
|
| 419 |
+
- [ ] Train CRNN+CTC model
|
| 420 |
+
- [ ] Train document classifier
|
| 421 |
+
- [ ] Integrate NER system
|
| 422 |
+
|
| 423 |
+
### Phase 4: Web Application
|
| 424 |
+
- [ ] Develop Flask/FastAPI backend
|
| 425 |
+
- [ ] Create frontend interface
|
| 426 |
+
- [ ] Implement database integration
|
| 427 |
+
|
| 428 |
+
### Phase 5: Testing
|
| 429 |
+
- [ ] Accuracy testing
|
| 430 |
+
- [ ] Black-box testing
|
| 431 |
+
- [ ] ISO 25010 evaluation
|
| 432 |
+
- [ ] User acceptance testing
|
| 433 |
+
|
| 434 |
+
### Phase 6: Deployment
|
| 435 |
+
- [ ] Optimize for production
|
| 436 |
+
- [ ] Set up server
|
| 437 |
+
- [ ] Deploy application
|
| 438 |
+
- [ ] Monitor performance
|
| 439 |
+
|
| 440 |
+
---
|
| 441 |
+
|
| 442 |
+
## 🎯 Success Metrics
|
| 443 |
+
|
| 444 |
+
Target metrics for thesis evaluation:
|
| 445 |
+
|
| 446 |
+
| Metric | Target | Status |
|
| 447 |
+
|--------|--------|--------|
|
| 448 |
+
| OCR Accuracy | > 95% | Pending |
|
| 449 |
+
| CER | < 5% | Pending |
|
| 450 |
+
| Classifier Accuracy | > 90% | Pending |
|
| 451 |
+
| NER F1 Score | > 85% | Pending |
|
| 452 |
+
| Response Time | < 2s | Pending |
|
| 453 |
+
| System Uptime | > 99% | Pending |
|
| 454 |
+
|
| 455 |
+
---
|
| 456 |
+
|
| 457 |
+
**Good luck with your thesis defense! 🎓✨**
|
| 458 |
+
|
| 459 |
+
For detailed implementation instructions, see **IMPLEMENTATION_GUIDE.md**
|
app.py
ADDED
|
@@ -0,0 +1,1234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app.py
|
| 2 |
+
# ============================================================
|
| 3 |
+
# Flask API — Civil Registry Pipeline
|
| 4 |
+
#
|
| 5 |
+
# TWO MODES (switch via USE_REAL_PIPELINE below):
|
| 6 |
+
#
|
| 7 |
+
# USE_REAL_PIPELINE = False → fake data (safe, always works)
|
| 8 |
+
# USE_REAL_PIPELINE = True → calls pipeline.py (real models)
|
| 9 |
+
#
|
| 10 |
+
# HOW TO ENABLE THE REAL PIPELINE:
|
| 11 |
+
# 1. Set USE_REAL_PIPELINE = True
|
| 12 |
+
# 2. Set PIPELINE_REPO_PATH to the absolute path of your repo
|
| 13 |
+
# e.g. r"C:\Users\YourName\Documents\thesis-repo"
|
| 14 |
+
# 3. Make sure venv has all model dependencies installed
|
| 15 |
+
# 4. Run: python app.py
|
| 16 |
+
# ============================================================
|
| 17 |
+
|
| 18 |
+
from flask import Flask, request, jsonify
|
| 19 |
+
from flask_cors import CORS
|
| 20 |
+
import os
|
| 21 |
+
import sys
|
| 22 |
+
import traceback
|
| 23 |
+
from datetime import datetime
|
| 24 |
+
|
| 25 |
+
# ── sys.path setup ────────────────────────────────────────────
|
| 26 |
+
_BASE = os.path.dirname(os.path.abspath(__file__))
|
| 27 |
+
for _p in [
|
| 28 |
+
_BASE,
|
| 29 |
+
os.path.join(_BASE, 'CRNN+CTC'),
|
| 30 |
+
os.path.join(_BASE, 'MNB'),
|
| 31 |
+
os.path.join(_BASE, 'spacyNER'),
|
| 32 |
+
]:
|
| 33 |
+
if _p not in sys.path:
|
| 34 |
+
sys.path.insert(0, _p)
|
| 35 |
+
|
| 36 |
+
app = Flask(__name__)
|
| 37 |
+
CORS(app)
|
| 38 |
+
|
| 39 |
+
# ── CONFIGURATION ─────────────────────────────────────────────
|
| 40 |
+
USE_REAL_PIPELINE = False
|
| 41 |
+
USE_TEMPLATE_MATCHING = True
|
| 42 |
+
PIPELINE_REPO_PATH = r"C:\xampp\htdocs\python"
|
| 43 |
+
# ─────────────────────────────────────────────────────────────
|
| 44 |
+
|
| 45 |
+
# ── Load template matcher ─────────────────────────────────────
|
| 46 |
+
try:
|
| 47 |
+
from template_matcher import (
|
| 48 |
+
extract_fields,
|
| 49 |
+
pdf_to_image,
|
| 50 |
+
detect_form_type,
|
| 51 |
+
_get_crnn,
|
| 52 |
+
_get_paddleocr,
|
| 53 |
+
)
|
| 54 |
+
_template_matcher_ok = True
|
| 55 |
+
print("[app.py] Template matcher loaded")
|
| 56 |
+
|
| 57 |
+
print("[app.py] Preloading CRNN+CTC model...")
|
| 58 |
+
_get_crnn()
|
| 59 |
+
print("[app.py] CRNN+CTC preloaded.")
|
| 60 |
+
|
| 61 |
+
print("[app.py] Preloading PaddleOCR...")
|
| 62 |
+
_get_paddleocr()
|
| 63 |
+
print("[app.py] PaddleOCR preloaded.")
|
| 64 |
+
|
| 65 |
+
except Exception as _tm_err:
|
| 66 |
+
_template_matcher_ok = False
|
| 67 |
+
print(f"[app.py] Template matcher unavailable: {_tm_err}")
|
| 68 |
+
|
| 69 |
+
# ── Load bridge (MNB + spaCyNER) ──────────────────────────────
|
| 70 |
+
_bridge = None
|
| 71 |
+
try:
|
| 72 |
+
from bridge import CivilRegistryBridge
|
| 73 |
+
print("[app.py] Loading MNB + spaCyNER bridge...")
|
| 74 |
+
_bridge = CivilRegistryBridge()
|
| 75 |
+
print("[app.py] Bridge (MNB + spaCyNER) ready.")
|
| 76 |
+
except Exception as _br_err:
|
| 77 |
+
print(f"[app.py] Bridge unavailable (MNB/NER disabled): {_br_err}")
|
| 78 |
+
|
| 79 |
+
TEMP_DIR = os.environ.get('TEMP_DIR', os.path.join('/tmp', 'uploads', 'temp'))
|
| 80 |
+
|
| 81 |
+
# ── Load real pipeline (only if enabled) ─────────────────────
|
| 82 |
+
_pipeline = None
|
| 83 |
+
_pipeline_error = None
|
| 84 |
+
|
| 85 |
+
if USE_REAL_PIPELINE:
|
| 86 |
+
try:
|
| 87 |
+
if PIPELINE_REPO_PATH not in sys.path:
|
| 88 |
+
sys.path.insert(0, PIPELINE_REPO_PATH)
|
| 89 |
+
from pipeline import CivilRegistryPipeline
|
| 90 |
+
print("[app.py] Loading pipeline models — this may take a moment...")
|
| 91 |
+
_pipeline = CivilRegistryPipeline()
|
| 92 |
+
print("[app.py] ✅ Pipeline ready")
|
| 93 |
+
except Exception:
|
| 94 |
+
_pipeline_error = traceback.format_exc()
|
| 95 |
+
print(f"[app.py] ❌ Pipeline failed to load:\n{_pipeline_error}")
|
| 96 |
+
print("[app.py] ⚠️ Falling back to fake data")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ── /process endpoint ─────────────────────────────────────────
|
| 100 |
+
@app.route('/process', methods=['POST'])
|
| 101 |
+
def process_document():
|
| 102 |
+
if 'file' not in request.files:
|
| 103 |
+
return jsonify({'status': 'error', 'message': 'No file provided'}), 400
|
| 104 |
+
|
| 105 |
+
file = request.files['file']
|
| 106 |
+
file2 = request.files.get('file2')
|
| 107 |
+
form_hint = request.form.get('form_hint', '1A')
|
| 108 |
+
|
| 109 |
+
hint_to_type = {
|
| 110 |
+
'1A': 'birth',
|
| 111 |
+
'2A': 'death',
|
| 112 |
+
'3A': 'marriage',
|
| 113 |
+
'90': 'marriage',
|
| 114 |
+
}
|
| 115 |
+
form_type = hint_to_type.get(form_hint, 'birth')
|
| 116 |
+
|
| 117 |
+
os.makedirs(TEMP_DIR, exist_ok=True)
|
| 118 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
| 119 |
+
|
| 120 |
+
ext = os.path.splitext(file.filename)[1] or '.pdf'
|
| 121 |
+
saved_path = os.path.join(TEMP_DIR, f'upload_{timestamp}{ext}')
|
| 122 |
+
file.save(saved_path)
|
| 123 |
+
|
| 124 |
+
saved_path2 = None
|
| 125 |
+
if file2 and file2.filename:
|
| 126 |
+
ext2 = os.path.splitext(file2.filename)[1] or '.pdf'
|
| 127 |
+
saved_path2 = os.path.join(TEMP_DIR, f'upload_{timestamp}_bride{ext2}')
|
| 128 |
+
file2.save(saved_path2)
|
| 129 |
+
|
| 130 |
+
try:
|
| 131 |
+
if USE_REAL_PIPELINE and _pipeline is not None:
|
| 132 |
+
fields, confidence, form_class = _run_real_pipeline(
|
| 133 |
+
saved_path, form_hint, form_type, file2_path=saved_path2
|
| 134 |
+
)
|
| 135 |
+
elif USE_TEMPLATE_MATCHING and _template_matcher_ok:
|
| 136 |
+
fields, confidence, form_class = _run_template_pipeline(
|
| 137 |
+
saved_path, form_hint, file2_path=saved_path2
|
| 138 |
+
)
|
| 139 |
+
else:
|
| 140 |
+
fields, confidence, form_class = _run_fake_pipeline(form_hint)
|
| 141 |
+
|
| 142 |
+
except Exception as e:
|
| 143 |
+
tb = traceback.format_exc()
|
| 144 |
+
print(f"[app.py] ❌ Processing error:\n{tb}")
|
| 145 |
+
is_user_error = isinstance(e, ValueError)
|
| 146 |
+
return jsonify({
|
| 147 |
+
'status': 'error',
|
| 148 |
+
'message': str(e),
|
| 149 |
+
'trace': '' if is_user_error else tb,
|
| 150 |
+
}), 200 if is_user_error else 500
|
| 151 |
+
|
| 152 |
+
finally:
|
| 153 |
+
try:
|
| 154 |
+
os.remove(saved_path)
|
| 155 |
+
except Exception:
|
| 156 |
+
pass
|
| 157 |
+
if saved_path2:
|
| 158 |
+
try:
|
| 159 |
+
os.remove(saved_path2)
|
| 160 |
+
except Exception:
|
| 161 |
+
pass
|
| 162 |
+
|
| 163 |
+
preview_file = f'form_{form_class}_{timestamp}.html'
|
| 164 |
+
preview_path = os.path.join(TEMP_DIR, preview_file)
|
| 165 |
+
with open(preview_path, 'w', encoding='utf-8') as fh:
|
| 166 |
+
fh.write(_build_preview_html(form_class, fields))
|
| 167 |
+
|
| 168 |
+
mode_label = "pipeline" if (USE_REAL_PIPELINE and _pipeline) else "template/fake"
|
| 169 |
+
|
| 170 |
+
return jsonify({
|
| 171 |
+
'status': 'success',
|
| 172 |
+
'form_class': form_class,
|
| 173 |
+
'raw_text': f'Processed via {mode_label} — Form {form_class}',
|
| 174 |
+
'fields': fields,
|
| 175 |
+
'confidence': confidence,
|
| 176 |
+
'saved_file': preview_file,
|
| 177 |
+
'preview_url': f'/uploads/temp/{preview_file}',
|
| 178 |
+
})
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
# ── /status endpoint ──────────────────────────────────────────
|
| 182 |
+
@app.route('/status', methods=['GET'])
|
| 183 |
+
def status():
|
| 184 |
+
return jsonify({
|
| 185 |
+
'mode': 'real_pipeline' if (USE_REAL_PIPELINE and _pipeline) else 'fake_data',
|
| 186 |
+
'pipeline_ready': _pipeline is not None,
|
| 187 |
+
'pipeline_error': _pipeline_error,
|
| 188 |
+
'repo_path': PIPELINE_REPO_PATH if USE_REAL_PIPELINE else None,
|
| 189 |
+
})
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
# ── /debug endpoint ───────────────────────────────────────────
|
| 193 |
+
@app.route('/debug', methods=['GET'])
|
| 194 |
+
def debug():
|
| 195 |
+
try:
|
| 196 |
+
import pipeline as _pl_module # noqa: F401
|
| 197 |
+
return jsonify({'import': 'ok', 'sys_path': sys.path[:6]})
|
| 198 |
+
except Exception:
|
| 199 |
+
return jsonify({
|
| 200 |
+
'import': 'FAILED',
|
| 201 |
+
'trace': traceback.format_exc(),
|
| 202 |
+
'sys_path': sys.path[:6]
|
| 203 |
+
}), 500
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
# ═════════════════════════════════════════════════════════════
|
| 207 |
+
# REAL PIPELINE
|
| 208 |
+
# ═════════════════════════════════════════════════════════════
|
| 209 |
+
def _run_real_pipeline(file_path, form_hint, form_type, file2_path=None):
|
| 210 |
+
if form_hint == '90':
|
| 211 |
+
raw_groom = _pipeline.process_pdf(file_path, form_type='marriage')
|
| 212 |
+
groom_fields, groom_conf = _map_pipeline_output_form90(raw_groom, role='groom')
|
| 213 |
+
|
| 214 |
+
bride_fields = {}
|
| 215 |
+
bride_conf = {}
|
| 216 |
+
if file2_path:
|
| 217 |
+
raw_bride = _pipeline.process_pdf(file2_path, form_type='marriage')
|
| 218 |
+
bride_fields, bride_conf = _map_pipeline_output_form90(raw_bride, role='bride')
|
| 219 |
+
|
| 220 |
+
fields = {**bride_fields, **groom_fields}
|
| 221 |
+
confidence = {**bride_conf, **groom_conf}
|
| 222 |
+
|
| 223 |
+
for key in [
|
| 224 |
+
'registry_no', 'city_municipality', 'date_issuance', 'license_no',
|
| 225 |
+
'marriage_day', 'marriage_month', 'marriage_year',
|
| 226 |
+
'marriage_venue', 'marriage_city',
|
| 227 |
+
'groom_first', 'groom_middle', 'groom_last', 'groom_age',
|
| 228 |
+
'groom_citizenship', 'groom_mother_first', 'groom_mother_last',
|
| 229 |
+
'groom_father_first', 'groom_father_last',
|
| 230 |
+
'bride_first', 'bride_middle', 'bride_last', 'bride_age',
|
| 231 |
+
'bride_citizenship', 'bride_mother_first', 'bride_mother_last',
|
| 232 |
+
'bride_father_first', 'bride_father_last',
|
| 233 |
+
]:
|
| 234 |
+
fields.setdefault(key, '')
|
| 235 |
+
|
| 236 |
+
return fields, confidence, '90'
|
| 237 |
+
|
| 238 |
+
raw_result = _pipeline.process_pdf(file_path, form_type=form_type)
|
| 239 |
+
actual_class = getattr(raw_result, 'form_class', None) or form_hint
|
| 240 |
+
class_map = {'form1a': '1A', 'form2a': '2A', 'form3a': '3A', 'form90': '90'}
|
| 241 |
+
form_class = class_map.get(str(actual_class).lower(), form_hint)
|
| 242 |
+
|
| 243 |
+
fields, confidence = _map_pipeline_output(raw_result, form_class)
|
| 244 |
+
return fields, confidence, form_class
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def _map_pipeline_output(raw: dict, form_hint: str):
|
| 248 |
+
confidence = {k: 0.90 for k in raw.keys()}
|
| 249 |
+
|
| 250 |
+
if form_hint == '1A':
|
| 251 |
+
fields = {
|
| 252 |
+
'registry_no': raw.get('registry_number') or raw.get('registry_no', ''),
|
| 253 |
+
'city_municipality': raw.get('city_municipality') or raw.get('city', ''),
|
| 254 |
+
'province': raw.get('province', ''),
|
| 255 |
+
'date_issuance': raw.get('date_issuance') or raw.get('date', ''),
|
| 256 |
+
'child_first': raw.get('child_first') or raw.get('name_of_child_first', ''),
|
| 257 |
+
'child_middle': raw.get('child_middle') or raw.get('name_of_child_middle', ''),
|
| 258 |
+
'child_last': raw.get('child_last') or raw.get('name_of_child_last', ''),
|
| 259 |
+
'sex': raw.get('sex', ''),
|
| 260 |
+
'dob_day': raw.get('dob_day') or raw.get('date_of_birth_day', ''),
|
| 261 |
+
'dob_month': raw.get('dob_month') or raw.get('date_of_birth_month', ''),
|
| 262 |
+
'dob_year': raw.get('dob_year') or raw.get('date_of_birth_year', ''),
|
| 263 |
+
'pob_hospital': raw.get('pob_hospital') or raw.get('place_of_birth_hospital', ''),
|
| 264 |
+
'pob_city': raw.get('pob_city') or raw.get('place_of_birth_city', ''),
|
| 265 |
+
'pob_province': raw.get('pob_province') or raw.get('place_of_birth_province', ''),
|
| 266 |
+
'mother_first': raw.get('mother_first') or raw.get('mother_name_first', ''),
|
| 267 |
+
'mother_middle': raw.get('mother_middle') or raw.get('mother_name_middle', ''),
|
| 268 |
+
'mother_last': raw.get('mother_last') or raw.get('mother_name_last', ''),
|
| 269 |
+
'mother_citizenship': raw.get('mother_citizenship') or raw.get('mother_nationality', ''),
|
| 270 |
+
'mother_age': raw.get('mother_age', ''),
|
| 271 |
+
'father_first': raw.get('father_first') or raw.get('father_name_first', ''),
|
| 272 |
+
'father_middle': raw.get('father_middle') or raw.get('father_name_middle', ''),
|
| 273 |
+
'father_last': raw.get('father_last') or raw.get('father_name_last', ''),
|
| 274 |
+
'father_citizenship': raw.get('father_citizenship') or raw.get('father_nationality', ''),
|
| 275 |
+
'parents_marriage_day': raw.get('parents_marriage_day', ''),
|
| 276 |
+
'parents_marriage_month': raw.get('parents_marriage_month', ''),
|
| 277 |
+
'parents_marriage_year': raw.get('parents_marriage_year', ''),
|
| 278 |
+
'parents_marriage_city': raw.get('parents_marriage_city', ''),
|
| 279 |
+
'parents_marriage_province': raw.get('parents_marriage_province', ''),
|
| 280 |
+
'date_submitted': raw.get('date_submitted') or raw.get('date_of_registration', ''),
|
| 281 |
+
'prepared_by': raw.get('prepared_by', ''),
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
elif form_hint == '2A':
|
| 285 |
+
fields = {
|
| 286 |
+
'registry_no': raw.get('registry_number') or raw.get('registry_no', ''),
|
| 287 |
+
'city_municipality': raw.get('city_municipality') or raw.get('city', ''),
|
| 288 |
+
'province': raw.get('province', ''),
|
| 289 |
+
'date_issuance': raw.get('date_issuance') or raw.get('date', ''),
|
| 290 |
+
'deceased_first': raw.get('deceased_first') or raw.get('name_of_deceased_first', ''),
|
| 291 |
+
'deceased_middle': raw.get('deceased_middle') or raw.get('name_of_deceased_middle', ''),
|
| 292 |
+
'deceased_last': raw.get('deceased_last') or raw.get('name_of_deceased_last', ''),
|
| 293 |
+
'sex': raw.get('sex', ''),
|
| 294 |
+
'age_years': raw.get('age_years') or raw.get('age', ''),
|
| 295 |
+
'civil_status': raw.get('civil_status', ''),
|
| 296 |
+
'citizenship': raw.get('citizenship') or raw.get('nationality', ''),
|
| 297 |
+
'dod_day': raw.get('dod_day') or raw.get('date_of_death_day', ''),
|
| 298 |
+
'dod_month': raw.get('dod_month') or raw.get('date_of_death_month', ''),
|
| 299 |
+
'dod_year': raw.get('dod_year') or raw.get('date_of_death_year', ''),
|
| 300 |
+
'pod_hospital': raw.get('pod_hospital') or raw.get('place_of_death_hospital', ''),
|
| 301 |
+
'pod_city': raw.get('pod_city') or raw.get('place_of_death_city', ''),
|
| 302 |
+
'pod_province': raw.get('pod_province') or raw.get('place_of_death_province', ''),
|
| 303 |
+
'cause_immediate': raw.get('cause_immediate') or raw.get('cause_of_death', ''),
|
| 304 |
+
'cause_antecedent': raw.get('cause_antecedent', ''),
|
| 305 |
+
'cause_underlying': raw.get('cause_underlying', ''),
|
| 306 |
+
'date_submitted': raw.get('date_submitted') or raw.get('date_of_registration', ''),
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
else:
|
| 310 |
+
fields = {
|
| 311 |
+
'registry_no': raw.get('registry_number') or raw.get('registry_no', ''),
|
| 312 |
+
'city_municipality': raw.get('city_municipality') or raw.get('city', ''),
|
| 313 |
+
'province': raw.get('province', ''),
|
| 314 |
+
'date_issuance': raw.get('date_issuance') or raw.get('date', ''),
|
| 315 |
+
'husband_first': raw.get('husband_first') or raw.get('husband_name_first', ''),
|
| 316 |
+
'husband_middle': raw.get('husband_middle') or raw.get('husband_name_middle', ''),
|
| 317 |
+
'husband_last': raw.get('husband_last') or raw.get('husband_name_last', ''),
|
| 318 |
+
'husband_age': raw.get('husband_age', ''),
|
| 319 |
+
'husband_citizenship': raw.get('husband_citizenship') or raw.get('husband_nationality', ''),
|
| 320 |
+
'husband_mother_first': raw.get('husband_mother_first', ''),
|
| 321 |
+
'husband_mother_last': raw.get('husband_mother_last', ''),
|
| 322 |
+
'husband_mother_citizenship': raw.get('husband_mother_citizenship', ''),
|
| 323 |
+
'husband_father_first': raw.get('husband_father_first', ''),
|
| 324 |
+
'husband_father_last': raw.get('husband_father_last', ''),
|
| 325 |
+
'husband_father_citizenship': raw.get('husband_father_citizenship', ''),
|
| 326 |
+
'wife_first': raw.get('wife_first') or raw.get('wife_name_first', ''),
|
| 327 |
+
'wife_middle': raw.get('wife_middle') or raw.get('wife_name_middle', ''),
|
| 328 |
+
'wife_last': raw.get('wife_last') or raw.get('wife_name_last', ''),
|
| 329 |
+
'wife_age': raw.get('wife_age', ''),
|
| 330 |
+
'wife_citizenship': raw.get('wife_citizenship') or raw.get('wife_nationality', ''),
|
| 331 |
+
'wife_mother_first': raw.get('wife_mother_first', ''),
|
| 332 |
+
'wife_mother_last': raw.get('wife_mother_last', ''),
|
| 333 |
+
'wife_mother_citizenship': raw.get('wife_mother_citizenship', ''),
|
| 334 |
+
'wife_father_first': raw.get('wife_father_first', ''),
|
| 335 |
+
'wife_father_last': raw.get('wife_father_last', ''),
|
| 336 |
+
'wife_father_citizenship': raw.get('wife_father_citizenship', ''),
|
| 337 |
+
'marriage_day': raw.get('marriage_day') or raw.get('date_of_marriage_day', ''),
|
| 338 |
+
'marriage_month': raw.get('marriage_month') or raw.get('date_of_marriage_month', ''),
|
| 339 |
+
'marriage_year': raw.get('marriage_year') or raw.get('date_of_marriage_year', ''),
|
| 340 |
+
'marriage_venue': raw.get('marriage_venue', ''),
|
| 341 |
+
'marriage_city': raw.get('marriage_city', ''),
|
| 342 |
+
'marriage_province': raw.get('marriage_province', ''),
|
| 343 |
+
'date_submitted': raw.get('date_submitted') or raw.get('date_of_registration', ''),
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
for k, v in raw.items():
|
| 347 |
+
if k not in fields and v:
|
| 348 |
+
fields[k] = v
|
| 349 |
+
|
| 350 |
+
return fields, confidence
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def _map_pipeline_output_form90(raw: dict, role: str):
|
| 354 |
+
confidence = {k: 0.90 for k in raw.keys()}
|
| 355 |
+
|
| 356 |
+
husband = raw.get('husband') or {}
|
| 357 |
+
wife = raw.get('wife') or {}
|
| 358 |
+
if not isinstance(husband, dict):
|
| 359 |
+
husband = {}
|
| 360 |
+
if not isinstance(wife, dict):
|
| 361 |
+
wife = {}
|
| 362 |
+
|
| 363 |
+
dom_raw = raw.get('date_of_marriage') or ''
|
| 364 |
+
dom_parts = [p.strip() for p in str(dom_raw).split(',') if p.strip()]
|
| 365 |
+
marriage_day = dom_parts[0] if len(dom_parts) > 0 else ''
|
| 366 |
+
marriage_month = dom_parts[1] if len(dom_parts) > 1 else ''
|
| 367 |
+
marriage_year = dom_parts[2] if len(dom_parts) > 2 else ''
|
| 368 |
+
|
| 369 |
+
pom_raw = raw.get('place_of_marriage') or ''
|
| 370 |
+
pom_parts = [p.strip() for p in str(pom_raw).split(',') if p.strip()]
|
| 371 |
+
marriage_venue = pom_parts[0] if len(pom_parts) > 0 else ''
|
| 372 |
+
marriage_city = pom_parts[1] if len(pom_parts) > 1 else ''
|
| 373 |
+
|
| 374 |
+
shared = {
|
| 375 |
+
'registry_no': str(raw.get('registry_number') or '').strip(),
|
| 376 |
+
'city_municipality': marriage_city,
|
| 377 |
+
'date_issuance': str(raw.get('date_of_registration') or '').strip(),
|
| 378 |
+
'license_no': str(raw.get('license_no') or raw.get('license_number') or '').strip(),
|
| 379 |
+
'marriage_day': marriage_day,
|
| 380 |
+
'marriage_month': marriage_month,
|
| 381 |
+
'marriage_year': marriage_year,
|
| 382 |
+
'marriage_venue': marriage_venue,
|
| 383 |
+
'marriage_city': marriage_city,
|
| 384 |
+
'marriage_province': str(raw.get('province') or '').strip(),
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
if role == 'groom':
|
| 388 |
+
person = husband
|
| 389 |
+
fields = {
|
| 390 |
+
**shared,
|
| 391 |
+
'groom_first': str(person.get('first_name') or person.get('first') or raw.get('groom_first') or '').strip(),
|
| 392 |
+
'groom_middle': str(person.get('middle_name') or person.get('middle') or raw.get('groom_middle') or '').strip(),
|
| 393 |
+
'groom_last': str(person.get('last_name') or person.get('last') or raw.get('groom_last') or '').strip(),
|
| 394 |
+
'groom_age': str(person.get('age') or raw.get('groom_age') or '').strip(),
|
| 395 |
+
'groom_citizenship': str(person.get('citizenship') or person.get('nationality') or raw.get('groom_citizenship') or '').strip(),
|
| 396 |
+
'groom_civil_status': str(person.get('civil_status') or '').strip(),
|
| 397 |
+
'groom_residence': str(person.get('residence') or person.get('address') or '').strip(),
|
| 398 |
+
'groom_mother_first': str(person.get('mother_first') or person.get('mother_name') or '').strip(),
|
| 399 |
+
'groom_mother_last': str(person.get('mother_last') or '').strip(),
|
| 400 |
+
'groom_father_first': str(person.get('father_first') or person.get('father_name') or '').strip(),
|
| 401 |
+
'groom_father_last': str(person.get('father_last') or '').strip(),
|
| 402 |
+
}
|
| 403 |
+
else:
|
| 404 |
+
person = wife
|
| 405 |
+
fields = {
|
| 406 |
+
**shared,
|
| 407 |
+
'bride_first': str(person.get('first_name') or person.get('first') or raw.get('bride_first') or '').strip(),
|
| 408 |
+
'bride_middle': str(person.get('middle_name') or person.get('middle') or raw.get('bride_middle') or '').strip(),
|
| 409 |
+
'bride_last': str(person.get('last_name') or person.get('last') or raw.get('bride_last') or '').strip(),
|
| 410 |
+
'bride_age': str(person.get('age') or raw.get('bride_age') or '').strip(),
|
| 411 |
+
'bride_citizenship': str(person.get('citizenship') or person.get('nationality') or raw.get('bride_citizenship') or '').strip(),
|
| 412 |
+
'bride_civil_status': str(person.get('civil_status') or '').strip(),
|
| 413 |
+
'bride_residence': str(person.get('residence') or person.get('address') or '').strip(),
|
| 414 |
+
'bride_mother_first': str(person.get('mother_first') or person.get('mother_name') or '').strip(),
|
| 415 |
+
'bride_mother_last': str(person.get('mother_last') or '').strip(),
|
| 416 |
+
'bride_father_first': str(person.get('father_first') or person.get('father_name') or '').strip(),
|
| 417 |
+
'bride_father_last': str(person.get('father_last') or '').strip(),
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
fields = {k: v for k, v in fields.items() if v}
|
| 421 |
+
return fields, confidence
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
# ═════════════════════════════════════════════════════════════
|
| 425 |
+
# TEMPLATE MATCHING PIPELINE
|
| 426 |
+
# ═════════════════════════════════════════════════════════════
|
| 427 |
+
def _run_template_pipeline(file_path, form_hint, file2_path=None):
|
| 428 |
+
img_path = file_path
|
| 429 |
+
if file_path.lower().endswith('.pdf'):
|
| 430 |
+
img_path = pdf_to_image(file_path) or file_path
|
| 431 |
+
|
| 432 |
+
hint_to_source = {'1A': '102', '2A': '103', '3A': '97', '90': '90'}
|
| 433 |
+
source_type = hint_to_source.get(form_hint, '102')
|
| 434 |
+
|
| 435 |
+
detected_source = detect_form_type(img_path)
|
| 436 |
+
source_to_hint = {'102': '1A', '103': '2A', '97': '3A', '90': '90'}
|
| 437 |
+
detected_hint = source_to_hint.get(detected_source, '1A')
|
| 438 |
+
|
| 439 |
+
CERT_HINTS = {'1A', '2A', '3A'}
|
| 440 |
+
LICENSE_HINTS = {'90'}
|
| 441 |
+
|
| 442 |
+
if form_hint in CERT_HINTS and detected_hint in LICENSE_HINTS:
|
| 443 |
+
raise ValueError(
|
| 444 |
+
'Wrong form uploaded. This appears to be a Marriage License (Form 90). '
|
| 445 |
+
'Please upload it under Marriage License instead.'
|
| 446 |
+
)
|
| 447 |
+
if form_hint in LICENSE_HINTS and detected_hint in CERT_HINTS:
|
| 448 |
+
raise ValueError(
|
| 449 |
+
f'Wrong form uploaded. This appears to be a Form {detected_hint} (Certification). '
|
| 450 |
+
f'Please upload it under Certifications instead.'
|
| 451 |
+
)
|
| 452 |
+
|
| 453 |
+
if form_hint == '1A':
|
| 454 |
+
form_hint = detected_hint
|
| 455 |
+
source_type = detected_source
|
| 456 |
+
|
| 457 |
+
raw = extract_fields(img_path, source_type)
|
| 458 |
+
|
| 459 |
+
if isinstance(raw, dict) and raw.get('status') == 'error':
|
| 460 |
+
print('[app.py] Blank page detected — skipping extraction')
|
| 461 |
+
raise ValueError('Blank page detected. Please try another file.')
|
| 462 |
+
|
| 463 |
+
if form_hint == '90' and file2_path:
|
| 464 |
+
img_path2 = file2_path
|
| 465 |
+
if file2_path.lower().endswith('.pdf'):
|
| 466 |
+
img_path2 = pdf_to_image(file2_path) or file2_path
|
| 467 |
+
raw2 = extract_fields(img_path2, '90')
|
| 468 |
+
if isinstance(raw2, dict) and raw2.get('status') == 'error':
|
| 469 |
+
print(f'[app.py] Bride file aborted: {raw2["message"]}')
|
| 470 |
+
else:
|
| 471 |
+
raw = {**raw, **raw2}
|
| 472 |
+
|
| 473 |
+
fields = _map_template_output(raw, form_hint)
|
| 474 |
+
form_class = form_hint if form_hint in ('1A', '2A', '3A', '90') else '1A'
|
| 475 |
+
|
| 476 |
+
if _bridge is not None:
|
| 477 |
+
try:
|
| 478 |
+
ner_text = _raw_to_ner_text(raw, source_type)
|
| 479 |
+
|
| 480 |
+
if source_type == '90':
|
| 481 |
+
mnb_result = {
|
| 482 |
+
'label': 'Form 90 - Application for Marriage License',
|
| 483 |
+
'form_code': 'form90',
|
| 484 |
+
'confidence': 1.0
|
| 485 |
+
}
|
| 486 |
+
else:
|
| 487 |
+
mnb_result = _bridge.mnb.classify_full(ner_text)
|
| 488 |
+
|
| 489 |
+
print(f'[app.py] MNB: {mnb_result["label"]} ({mnb_result["confidence"]:.1%})')
|
| 490 |
+
|
| 491 |
+
if source_type == '102':
|
| 492 |
+
ner_form = _bridge.filler.fill_form_1a(ner_text)
|
| 493 |
+
elif source_type == '103':
|
| 494 |
+
ner_form = _bridge.filler.fill_form_2a(ner_text)
|
| 495 |
+
elif source_type == '97':
|
| 496 |
+
ner_form = _bridge.filler.fill_form_3a(ner_text)
|
| 497 |
+
else:
|
| 498 |
+
ner_form = _bridge.filler.fill_form_90(ner_text, ner_text)
|
| 499 |
+
|
| 500 |
+
ner_fields = _ner_to_fields(ner_form, raw, form_hint)
|
| 501 |
+
|
| 502 |
+
TRUST_TEMPLATE_KEYS = {
|
| 503 |
+
'groom_name', 'bride_name',
|
| 504 |
+
'groom_age', 'bride_age', 'husband_age', 'wife_age',
|
| 505 |
+
'groom_dob', 'bride_dob', 'husband_dob', 'wife_dob',
|
| 506 |
+
'registry_no', 'license_no', 'date_issuance',
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
def _better(ner_val, tmpl_val, key):
|
| 510 |
+
if key in TRUST_TEMPLATE_KEYS:
|
| 511 |
+
return tmpl_val if tmpl_val else ner_val
|
| 512 |
+
if not ner_val:
|
| 513 |
+
return tmpl_val
|
| 514 |
+
if not tmpl_val:
|
| 515 |
+
return ner_val
|
| 516 |
+
return ner_val if len(ner_val) >= len(tmpl_val) else tmpl_val
|
| 517 |
+
|
| 518 |
+
fields = {k: _better(ner_fields.get(k, ''), v, k) for k, v in fields.items()}
|
| 519 |
+
for k, v in ner_fields.items():
|
| 520 |
+
if k not in fields and v:
|
| 521 |
+
fields[k] = v
|
| 522 |
+
|
| 523 |
+
ner_count = sum(1 for v in ner_fields.values() if v)
|
| 524 |
+
print(f'[app.py] NER enriched {ner_count} fields')
|
| 525 |
+
confidence = {k: mnb_result['confidence'] for k in fields}
|
| 526 |
+
|
| 527 |
+
except Exception as _ner_err:
|
| 528 |
+
print(f'[app.py] NER error (using template only): {_ner_err}')
|
| 529 |
+
confidence = {k: 0.85 for k in fields}
|
| 530 |
+
else:
|
| 531 |
+
confidence = {k: 0.85 for k in fields}
|
| 532 |
+
|
| 533 |
+
non_empty = {k: v for k, v in fields.items() if v}
|
| 534 |
+
print(f'[app.py] form_class={form_class}, {len(non_empty)}/{len(fields)} non-empty fields')
|
| 535 |
+
for k, v in non_empty.items():
|
| 536 |
+
print(f' {k:<30} = {v}')
|
| 537 |
+
|
| 538 |
+
return fields, confidence, form_class
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
def _clean_ocr(text: str) -> str:
|
| 542 |
+
import re
|
| 543 |
+
if not text:
|
| 544 |
+
return text
|
| 545 |
+
text = re.sub(r'\s+', ' ', text).strip()
|
| 546 |
+
text = text.strip('.,;:')
|
| 547 |
+
return text
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def _clean_age(text: str) -> str:
|
| 551 |
+
import re
|
| 552 |
+
nums = re.findall(r'\b\d+\b', text)
|
| 553 |
+
return nums[-1] if nums else _clean_ocr(text)
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
def _clean_civil_status(text: str) -> str:
|
| 557 |
+
t = text.lower().replace(' ', '')
|
| 558 |
+
if any(x in t for x in ['singl', 'fngle', 'fingle', 'single']):
|
| 559 |
+
return 'Single'
|
| 560 |
+
if any(x in t for x in ['marr', 'maried', 'married']):
|
| 561 |
+
return 'Married'
|
| 562 |
+
if any(x in t for x in ['widow', 'widw']):
|
| 563 |
+
return 'Widowed'
|
| 564 |
+
if any(x in t for x in ['separ', 'annul']):
|
| 565 |
+
return 'Separated'
|
| 566 |
+
return _clean_ocr(text)
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
def _map_template_output(raw: dict, form_hint: str) -> dict:
|
| 570 |
+
def g(key, *aliases):
|
| 571 |
+
for k in (key,) + aliases:
|
| 572 |
+
if raw.get(k):
|
| 573 |
+
return raw[k]
|
| 574 |
+
return ''
|
| 575 |
+
|
| 576 |
+
if form_hint == '1A':
|
| 577 |
+
return {
|
| 578 |
+
'registry_no': g('registry_no'),
|
| 579 |
+
'city_municipality': g('city_municipality'),
|
| 580 |
+
'province': g('province'),
|
| 581 |
+
'date_submitted': g('registration_date'),
|
| 582 |
+
'child_first': g('name_first'),
|
| 583 |
+
'child_middle': g('name_middle'),
|
| 584 |
+
'child_last': g('name_last'),
|
| 585 |
+
'sex': g('sex'),
|
| 586 |
+
'dob_day': g('dob_day'),
|
| 587 |
+
'dob_month': g('dob_month'),
|
| 588 |
+
'dob_year': g('dob_year'),
|
| 589 |
+
'pob_city': g('place_of_birth'),
|
| 590 |
+
'mother_first': g('mother_name'),
|
| 591 |
+
'mother_citizenship': g('mother_citizenship'),
|
| 592 |
+
'father_first': g('father_name'),
|
| 593 |
+
'father_citizenship': g('father_citizenship'),
|
| 594 |
+
'parents_marriage_month': g('marriage_date'),
|
| 595 |
+
'parents_marriage_city': g('marriage_place'),
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
elif form_hint == '2A':
|
| 599 |
+
cause = ' / '.join(filter(None, [
|
| 600 |
+
g('cause_immediate'), g('cause_antecedent'), g('cause_underlying')
|
| 601 |
+
]))
|
| 602 |
+
return {
|
| 603 |
+
'registry_no': g('registry_no'),
|
| 604 |
+
'city_municipality': g('city_municipality'),
|
| 605 |
+
'province': g('province'),
|
| 606 |
+
'date_submitted': g('registration_date'),
|
| 607 |
+
'deceased_first': g('deceased_name'),
|
| 608 |
+
'sex': g('sex'),
|
| 609 |
+
'age_years': g('age'),
|
| 610 |
+
'civil_status': g('civil_status'),
|
| 611 |
+
'citizenship': g('citizenship'),
|
| 612 |
+
'dod_full': g('date_of_death'),
|
| 613 |
+
'pod_hospital': g('place_of_death'),
|
| 614 |
+
'cause_immediate': cause,
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
elif form_hint == '3A':
|
| 618 |
+
return {
|
| 619 |
+
'registry_no': g('registry_no'),
|
| 620 |
+
'city_municipality': g('city_municipality'),
|
| 621 |
+
'province': g('province'),
|
| 622 |
+
'date_submitted': g('registration_date'),
|
| 623 |
+
'husband_first': g('husband_name_first'),
|
| 624 |
+
'husband_middle': g('husband_name_middle'),
|
| 625 |
+
'husband_last': g('husband_name_last'),
|
| 626 |
+
'husband_age': g('husband_age'),
|
| 627 |
+
'husband_citizenship': g('husband_citizenship'),
|
| 628 |
+
'husband_mother_first': g('husband_mother_name'),
|
| 629 |
+
'husband_father_first': g('husband_father_name'),
|
| 630 |
+
'husband_mother_citizenship': g('husband_mother_citizenship'),
|
| 631 |
+
'husband_father_citizenship': g('husband_father_citizenship'),
|
| 632 |
+
'wife_first': g('wife_name_first'),
|
| 633 |
+
'wife_middle': g('wife_name_middle'),
|
| 634 |
+
'wife_last': g('wife_name_last'),
|
| 635 |
+
'wife_age': g('wife_age'),
|
| 636 |
+
'wife_citizenship': g('wife_citizenship'),
|
| 637 |
+
'wife_mother_first': g('wife_mother_name'),
|
| 638 |
+
'wife_father_first': g('wife_father_name'),
|
| 639 |
+
'wife_mother_citizenship': g('wife_mother_citizenship'),
|
| 640 |
+
'wife_father_citizenship': g('wife_father_citizenship'),
|
| 641 |
+
'marriage_venue': g('place_of_marriage'),
|
| 642 |
+
'marriage_city': g('city_municipality'),
|
| 643 |
+
'marriage_month': g('date_of_marriage'),
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
else:
|
| 647 |
+
def _join_name(*keys):
|
| 648 |
+
return ' '.join(raw.get(k, '') for k in keys).strip()
|
| 649 |
+
|
| 650 |
+
return {
|
| 651 |
+
'registry_no': g('registry_no'),
|
| 652 |
+
'city_municipality': g('city_municipality'),
|
| 653 |
+
'province': raw.get('province', '').strip(),
|
| 654 |
+
'license_no': g('marriage_license_no'),
|
| 655 |
+
'date_issuance': g('date_issued'),
|
| 656 |
+
'groom_name': _join_name('groom_name_first', 'groom_name_middle', 'groom_name_last'),
|
| 657 |
+
'groom_dob': g('groom_dob'),
|
| 658 |
+
'groom_age': g('groom_age'),
|
| 659 |
+
'groom_place_of_birth': g('groom_place_of_birth'),
|
| 660 |
+
'groom_sex': g('groom_sex'),
|
| 661 |
+
'groom_citizenship': g('groom_citizenship'),
|
| 662 |
+
'groom_civil_status': g('groom_civil_status'),
|
| 663 |
+
'groom_residence': g('groom_residence'),
|
| 664 |
+
'groom_religion': g('groom_religion'),
|
| 665 |
+
'groom_father_first': g('groom_father_name'),
|
| 666 |
+
'groom_father_citizenship': g('groom_father_citizenship'),
|
| 667 |
+
'groom_mother_first': g('groom_mother_name'),
|
| 668 |
+
'groom_mother_citizenship': g('groom_mother_citizenship'),
|
| 669 |
+
'bride_name': _join_name('bride_name_first', 'bride_name_middle', 'bride_name_last'),
|
| 670 |
+
'bride_dob': g('bride_dob'),
|
| 671 |
+
'bride_age': g('bride_age'),
|
| 672 |
+
'bride_place_of_birth': g('bride_place_of_birth'),
|
| 673 |
+
'bride_sex': g('bride_sex'),
|
| 674 |
+
'bride_citizenship': g('bride_citizenship'),
|
| 675 |
+
'bride_civil_status': g('bride_civil_status'),
|
| 676 |
+
'bride_residence': g('bride_residence'),
|
| 677 |
+
'bride_religion': g('bride_religion'),
|
| 678 |
+
'bride_father_first': g('bride_father_name'),
|
| 679 |
+
'bride_father_citizenship': g('bride_father_citizenship'),
|
| 680 |
+
'bride_mother_first': g('bride_mother_name'),
|
| 681 |
+
'bride_mother_citizenship': g('bride_mother_citizenship'),
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
|
| 685 |
+
# ═════════════════════════════════════════════════════════════
|
| 686 |
+
# BRIDGE HELPERS
|
| 687 |
+
# ═════════════════════════════════════════════════════════════
|
| 688 |
+
def _raw_to_ner_text(raw: dict, source_type: str) -> str:
|
| 689 |
+
def g(*keys):
|
| 690 |
+
for k in keys:
|
| 691 |
+
v = raw.get(k, '')
|
| 692 |
+
if v:
|
| 693 |
+
return str(v)
|
| 694 |
+
return ''
|
| 695 |
+
|
| 696 |
+
if source_type == '102':
|
| 697 |
+
return (
|
| 698 |
+
f"Registry No.: {g('registry_no')}\n"
|
| 699 |
+
f"Date of Registration: {g('registration_date')}\n"
|
| 700 |
+
f"1. NAME: {g('name_first')} {g('name_middle')} {g('name_last')}\n"
|
| 701 |
+
f"2. SEX: {g('sex')}\n"
|
| 702 |
+
f"3. DATE OF BIRTH: {g('dob_month')} {g('dob_day')}, {g('dob_year')}\n"
|
| 703 |
+
f"4. PLACE OF BIRTH: {g('place_of_birth')}\n"
|
| 704 |
+
f"MOTHER:\n"
|
| 705 |
+
f"7. MAIDEN NAME: {g('mother_name')}\n"
|
| 706 |
+
f"8. CITIZENSHIP/NATIONALITY: {g('mother_citizenship')}\n"
|
| 707 |
+
f"FATHER:\n"
|
| 708 |
+
f"14. NAME: {g('father_name')}\n"
|
| 709 |
+
f"15. CITIZENSHIP/NATIONALITY: {g('father_citizenship')}\n"
|
| 710 |
+
f"MARRIAGE OF PARENTS:\n"
|
| 711 |
+
f"20a. DATE: {g('marriage_date')}\n"
|
| 712 |
+
f"20b. PLACE: {g('marriage_place')}\n"
|
| 713 |
+
)
|
| 714 |
+
|
| 715 |
+
elif source_type == '103':
|
| 716 |
+
return (
|
| 717 |
+
f"Registry No.: {g('registry_no')}\n"
|
| 718 |
+
f"Date of Registration: {g('registration_date')}\n"
|
| 719 |
+
f"1. NAME (First): {g('deceased_name')}\n"
|
| 720 |
+
f"2. SEX: {g('sex')}\n"
|
| 721 |
+
f"4. AGE: {g('age')}\n"
|
| 722 |
+
f"9. CIVIL STATUS: {g('civil_status')}\n"
|
| 723 |
+
f"7. CITIZENSHIP/NATIONALITY: {g('citizenship')}\n"
|
| 724 |
+
f"6. DATE OF DEATH: {g('date_of_death')}\n"
|
| 725 |
+
f"5. PLACE OF DEATH: {g('place_of_death')}\n"
|
| 726 |
+
f"17. CAUSE OF DEATH: {g('cause_immediate')}\n"
|
| 727 |
+
f"Antecedent cause: {g('cause_antecedent')}\n"
|
| 728 |
+
f"Underlying cause: {g('cause_underlying')}\n"
|
| 729 |
+
)
|
| 730 |
+
|
| 731 |
+
elif source_type == '97':
|
| 732 |
+
return (
|
| 733 |
+
f"Registry No.: {g('registry_no')}\n"
|
| 734 |
+
f"Date of Registration: {g('registration_date')}\n"
|
| 735 |
+
f"HUSBAND:\n"
|
| 736 |
+
f"1. NAME (First): {g('husband_name_first')} (Middle): {g('husband_name_middle')} (Last): {g('husband_name_last')}\n"
|
| 737 |
+
f"2b. AGE: {g('husband_age')}\n"
|
| 738 |
+
f"4b. CITIZENSHIP/NATIONALITY: {g('husband_citizenship')}\n"
|
| 739 |
+
f"8. NAME OF FATHER: {g('husband_father_name')}\n"
|
| 740 |
+
f"8b. FATHER CITIZENSHIP/NATIONALITY: {g('husband_father_citizenship')}\n"
|
| 741 |
+
f"10. NAME OF MOTHER: {g('husband_mother_name')}\n"
|
| 742 |
+
f"10b. MOTHER CITIZENSHIP/NATIONALITY: {g('husband_mother_citizenship')}\n"
|
| 743 |
+
f"WIFE:\n"
|
| 744 |
+
f"1. NAME (First): {g('wife_name_first')} (Middle): {g('wife_name_middle')} (Last): {g('wife_name_last')}\n"
|
| 745 |
+
f"2b. AGE: {g('wife_age')}\n"
|
| 746 |
+
f"4b. CITIZENSHIP/NATIONALITY: {g('wife_citizenship')}\n"
|
| 747 |
+
f"8. NAME OF FATHER: {g('wife_father_name')}\n"
|
| 748 |
+
f"8b. FATHER CITIZENSHIP/NATIONALITY: {g('wife_father_citizenship')}\n"
|
| 749 |
+
f"10. NAME OF MOTHER: {g('wife_mother_name')}\n"
|
| 750 |
+
f"10b. MOTHER CITIZENSHIP/NATIONALITY: {g('wife_mother_citizenship')}\n"
|
| 751 |
+
f"15. PLACE OF MARRIAGE: {g('place_of_marriage')}\n"
|
| 752 |
+
f"16. DATE OF MARRIAGE: {g('date_of_marriage')}\n"
|
| 753 |
+
)
|
| 754 |
+
|
| 755 |
+
else:
|
| 756 |
+
return (
|
| 757 |
+
f"GROOM:\n"
|
| 758 |
+
f"1. NAME (First): {g('groom_name_first')} (Middle): {g('groom_name_middle')} (Last): {g('groom_name_last')}\n"
|
| 759 |
+
f"2. DATE OF BIRTH: {g('groom_dob')}\n"
|
| 760 |
+
f"3. PLACE OF BIRTH: {g('groom_place_of_birth')}\n"
|
| 761 |
+
f"4. SEX: {g('groom_sex')}\n"
|
| 762 |
+
f"5. CITIZENSHIP/NATIONALITY: {g('groom_citizenship')}\n"
|
| 763 |
+
f"NAME OF FATHER: {g('groom_father_name')}\n"
|
| 764 |
+
f"FATHER CITIZENSHIP/NATIONALITY: {g('groom_father_citizenship')}\n"
|
| 765 |
+
f"NAME OF MOTHER: {g('groom_mother_name')}\n"
|
| 766 |
+
f"MOTHER CITIZENSHIP/NATIONALITY: {g('groom_mother_citizenship')}\n"
|
| 767 |
+
f"BRIDE:\n"
|
| 768 |
+
f"1. NAME (First): {g('bride_name_first')} (Middle): {g('bride_name_middle')} (Last): {g('bride_name_last')}\n"
|
| 769 |
+
f"2. DATE OF BIRTH: {g('bride_dob')}\n"
|
| 770 |
+
f"3. PLACE OF BIRTH: {g('bride_place_of_birth')}\n"
|
| 771 |
+
f"4. SEX: {g('bride_sex')}\n"
|
| 772 |
+
f"5. CITIZENSHIP/NATIONALITY: {g('bride_citizenship')}\n"
|
| 773 |
+
f"NAME OF FATHER: {g('bride_father_name')}\n"
|
| 774 |
+
f"FATHER CITIZENSHIP/NATIONALITY: {g('bride_father_citizenship')}\n"
|
| 775 |
+
f"NAME OF MOTHER: {g('bride_mother_name')}\n"
|
| 776 |
+
f"MOTHER CITIZENSHIP/NATIONALITY: {g('bride_mother_citizenship')}\n"
|
| 777 |
+
)
|
| 778 |
+
|
| 779 |
+
|
| 780 |
+
def _split_name(full: str):
|
| 781 |
+
parts = (full or '').split()
|
| 782 |
+
if not parts:
|
| 783 |
+
return '', '', ''
|
| 784 |
+
first = parts[0]
|
| 785 |
+
last = parts[-1] if len(parts) > 1 else ''
|
| 786 |
+
mid = ' '.join(parts[1:-1]) if len(parts) > 2 else ''
|
| 787 |
+
return first, mid, last
|
| 788 |
+
|
| 789 |
+
|
| 790 |
+
def _ner_to_fields(form, raw: dict, form_hint: str) -> dict:
|
| 791 |
+
def r(*keys):
|
| 792 |
+
for k in keys:
|
| 793 |
+
v = raw.get(k, '')
|
| 794 |
+
if v:
|
| 795 |
+
return v
|
| 796 |
+
return ''
|
| 797 |
+
|
| 798 |
+
def ga(attr, *fallback_keys):
|
| 799 |
+
v = getattr(form, attr, None) or ''
|
| 800 |
+
return v or r(*fallback_keys)
|
| 801 |
+
|
| 802 |
+
if form_hint == '1A':
|
| 803 |
+
cf, cm, cl = _split_name(getattr(form, 'name_of_child', '') or '')
|
| 804 |
+
return {
|
| 805 |
+
'registry_no': ga('registry_number', 'registry_no'),
|
| 806 |
+
'city_municipality': r('city_municipality'),
|
| 807 |
+
'province': r('province'),
|
| 808 |
+
'date_submitted': ga('date_of_registration', 'registration_date'),
|
| 809 |
+
'child_first': cf or r('name_first'),
|
| 810 |
+
'child_middle': cm or r('name_middle'),
|
| 811 |
+
'child_last': cl or r('name_last'),
|
| 812 |
+
'sex': ga('sex', 'sex'),
|
| 813 |
+
'dob_day': r('dob_day'),
|
| 814 |
+
'dob_month': r('dob_month'),
|
| 815 |
+
'dob_year': r('dob_year'),
|
| 816 |
+
'pob_city': ga('place_of_birth', 'place_of_birth'),
|
| 817 |
+
'mother_first': ga('name_of_mother', 'mother_name'),
|
| 818 |
+
'mother_citizenship': ga('nationality_of_mother', 'mother_citizenship'),
|
| 819 |
+
'father_first': ga('name_of_father', 'father_name'),
|
| 820 |
+
'father_citizenship': ga('nationality_of_father', 'father_citizenship'),
|
| 821 |
+
'parents_marriage_month': ga('date_of_marriage_of_parents', 'marriage_date'),
|
| 822 |
+
'parents_marriage_city': ga('place_of_marriage_of_parents', 'marriage_place'),
|
| 823 |
+
}
|
| 824 |
+
|
| 825 |
+
elif form_hint == '2A':
|
| 826 |
+
cause = ' / '.join(filter(None, [
|
| 827 |
+
getattr(form, 'cause_of_death', ''),
|
| 828 |
+
getattr(form, 'cause_antecedent', ''),
|
| 829 |
+
getattr(form, 'cause_underlying', ''),
|
| 830 |
+
])) or ' / '.join(filter(None, [
|
| 831 |
+
r('cause_immediate'), r('cause_antecedent'), r('cause_underlying')
|
| 832 |
+
]))
|
| 833 |
+
return {
|
| 834 |
+
'registry_no': ga('registry_number', 'registry_no'),
|
| 835 |
+
'city_municipality': r('city_municipality'),
|
| 836 |
+
'province': r('province'),
|
| 837 |
+
'date_submitted': ga('date_of_registration', 'registration_date'),
|
| 838 |
+
'deceased_first': ga('name_of_deceased', 'deceased_name'),
|
| 839 |
+
'sex': ga('sex', 'sex'),
|
| 840 |
+
'age_years': ga('age', 'age'),
|
| 841 |
+
'civil_status': ga('civil_status', 'civil_status'),
|
| 842 |
+
'citizenship': ga('nationality', 'citizenship'),
|
| 843 |
+
'dod_full': ga('date_of_death', 'date_of_death'),
|
| 844 |
+
'pod_hospital': ga('place_of_death', 'place_of_death'),
|
| 845 |
+
'cause_immediate': cause,
|
| 846 |
+
}
|
| 847 |
+
|
| 848 |
+
elif form_hint == '3A':
|
| 849 |
+
h = getattr(form, 'husband', None)
|
| 850 |
+
w = getattr(form, 'wife', None)
|
| 851 |
+
hd = h.to_dict() if h else {}
|
| 852 |
+
wd = w.to_dict() if w else {}
|
| 853 |
+
return {
|
| 854 |
+
'registry_no': ga('registry_number', 'registry_no'),
|
| 855 |
+
'city_municipality': r('city_municipality'),
|
| 856 |
+
'province': r('province'),
|
| 857 |
+
'date_submitted': ga('date_of_registration', 'registration_date'),
|
| 858 |
+
'husband_first': hd.get('name') or r('husband_name_first'),
|
| 859 |
+
'husband_middle': r('husband_name_middle'),
|
| 860 |
+
'husband_last': r('husband_name_last'),
|
| 861 |
+
'husband_age': hd.get('age') or r('husband_age'),
|
| 862 |
+
'husband_citizenship': hd.get('nationality') or r('husband_citizenship'),
|
| 863 |
+
'husband_mother_first': hd.get('name_of_mother') or r('husband_mother_name'),
|
| 864 |
+
'husband_mother_citizenship': hd.get('nationality_of_mother') or r('husband_mother_citizenship'),
|
| 865 |
+
'husband_father_first': hd.get('name_of_father') or r('husband_father_name'),
|
| 866 |
+
'husband_father_citizenship': hd.get('nationality_of_father') or r('husband_father_citizenship'),
|
| 867 |
+
'wife_first': wd.get('name') or r('wife_name_first'),
|
| 868 |
+
'wife_middle': r('wife_name_middle'),
|
| 869 |
+
'wife_last': r('wife_name_last'),
|
| 870 |
+
'wife_age': wd.get('age') or r('wife_age'),
|
| 871 |
+
'wife_citizenship': wd.get('nationality') or r('wife_citizenship'),
|
| 872 |
+
'wife_mother_first': wd.get('name_of_mother') or r('wife_mother_name'),
|
| 873 |
+
'wife_mother_citizenship': wd.get('nationality_of_mother') or r('wife_mother_citizenship'),
|
| 874 |
+
'wife_father_first': wd.get('name_of_father') or r('wife_father_name'),
|
| 875 |
+
'wife_father_citizenship': wd.get('nationality_of_father') or r('wife_father_citizenship'),
|
| 876 |
+
'marriage_venue': ga('place_of_marriage', 'place_of_marriage'),
|
| 877 |
+
'marriage_city': r('city_municipality'),
|
| 878 |
+
'marriage_month': ga('date_of_marriage', 'date_of_marriage'),
|
| 879 |
+
}
|
| 880 |
+
|
| 881 |
+
else:
|
| 882 |
+
groom = getattr(form, 'groom', None)
|
| 883 |
+
bride = getattr(form, 'bride', None)
|
| 884 |
+
gd = groom.to_dict() if groom else {}
|
| 885 |
+
bd = bride.to_dict() if bride else {}
|
| 886 |
+
return {
|
| 887 |
+
'registry_no': r('registry_no'),
|
| 888 |
+
'city_municipality': r('city_municipality'),
|
| 889 |
+
'province': r('province'),
|
| 890 |
+
'license_no': r('marriage_license_no'),
|
| 891 |
+
'date_issuance': r('date_issued'),
|
| 892 |
+
'groom_name': (gd.get('name_of_applicant') or ' '.join(filter(None, [
|
| 893 |
+
r('groom_name_first'), r('groom_name_middle'), r('groom_name_last')
|
| 894 |
+
]))).strip(),
|
| 895 |
+
'groom_dob': gd.get('date_of_birth') or r('groom_dob'),
|
| 896 |
+
'groom_age': gd.get('age') or r('groom_age'),
|
| 897 |
+
'groom_place_of_birth': gd.get('place_of_birth') or r('groom_place_of_birth'),
|
| 898 |
+
'groom_sex': gd.get('sex') or r('groom_sex'),
|
| 899 |
+
'groom_citizenship': gd.get('citizenship') or r('groom_citizenship'),
|
| 900 |
+
'groom_civil_status': gd.get('civil_status', r('groom_civil_status')),
|
| 901 |
+
'groom_residence': gd.get('residence') or r('groom_residence'),
|
| 902 |
+
'groom_religion': gd.get('religion') or r('groom_religion'),
|
| 903 |
+
'groom_father_first': gd.get('name_of_father') or r('groom_father_name'),
|
| 904 |
+
'groom_father_citizenship': gd.get('father_citizenship') or r('groom_father_citizenship'),
|
| 905 |
+
'groom_mother_first': gd.get('maiden_name_of_mother') or r('groom_mother_name'),
|
| 906 |
+
'groom_mother_citizenship': gd.get('mother_citizenship') or r('groom_mother_citizenship'),
|
| 907 |
+
'bride_name': (bd.get('name_of_applicant') or ' '.join(filter(None, [
|
| 908 |
+
r('bride_name_first'), r('bride_name_middle'), r('bride_name_last')
|
| 909 |
+
]))).strip(),
|
| 910 |
+
'bride_dob': bd.get('date_of_birth') or r('bride_dob'),
|
| 911 |
+
'bride_age': bd.get('age') or r('bride_age'),
|
| 912 |
+
'bride_place_of_birth': bd.get('place_of_birth') or r('bride_place_of_birth'),
|
| 913 |
+
'bride_sex': bd.get('sex') or r('bride_sex'),
|
| 914 |
+
'bride_citizenship': bd.get('citizenship') or r('bride_citizenship'),
|
| 915 |
+
'bride_civil_status': bd.get('civil_status', r('bride_civil_status')),
|
| 916 |
+
'bride_residence': bd.get('residence') or r('bride_residence'),
|
| 917 |
+
'bride_religion': bd.get('religion') or r('bride_religion'),
|
| 918 |
+
'bride_father_first': bd.get('name_of_father') or r('bride_father_name'),
|
| 919 |
+
'bride_father_citizenship': bd.get('father_citizenship') or r('bride_father_citizenship'),
|
| 920 |
+
'bride_mother_first': bd.get('maiden_name_of_mother') or r('bride_mother_name'),
|
| 921 |
+
'bride_mother_citizenship': bd.get('mother_citizenship') or r('bride_mother_citizenship'),
|
| 922 |
+
}
|
| 923 |
+
|
| 924 |
+
|
| 925 |
+
# ═════════════════════════════════════════════════════════════
|
| 926 |
+
# FAKE PIPELINE
|
| 927 |
+
# ═════════════════════════════════════════════════════════════
|
| 928 |
+
def _run_fake_pipeline(form_hint):
|
| 929 |
+
if form_hint == '1A':
|
| 930 |
+
fields = {
|
| 931 |
+
'registry_no': '2026-BC-00123',
|
| 932 |
+
'city_municipality': 'Tarlac City',
|
| 933 |
+
'province': 'Tarlac',
|
| 934 |
+
'date_issuance': datetime.now().strftime('%B %d, %Y'),
|
| 935 |
+
'child_first': 'Maria Luisa',
|
| 936 |
+
'child_middle': 'Dela Cruz',
|
| 937 |
+
'child_last': 'Santos',
|
| 938 |
+
'sex': 'Female',
|
| 939 |
+
'dob_day': '10',
|
| 940 |
+
'dob_month': 'January',
|
| 941 |
+
'dob_year': '2026',
|
| 942 |
+
'pob_city': 'Tarlac City',
|
| 943 |
+
'pob_province': 'Tarlac',
|
| 944 |
+
'mother_first': 'Rosa',
|
| 945 |
+
'mother_middle': 'Reyes',
|
| 946 |
+
'mother_last': 'Dela Cruz',
|
| 947 |
+
'mother_citizenship': 'Filipino',
|
| 948 |
+
'mother_age': '28',
|
| 949 |
+
'father_first': 'Juan Pedro',
|
| 950 |
+
'father_middle': '',
|
| 951 |
+
'father_last': 'Santos',
|
| 952 |
+
'father_citizenship': 'Filipino',
|
| 953 |
+
'parents_marriage_day': '12',
|
| 954 |
+
'parents_marriage_month': 'June',
|
| 955 |
+
'parents_marriage_year': '2020',
|
| 956 |
+
'parents_marriage_city': 'Tarlac City',
|
| 957 |
+
'parents_marriage_province': 'Tarlac',
|
| 958 |
+
'date_submitted': 'January 15, 2026',
|
| 959 |
+
'processed_by': 'John Doe',
|
| 960 |
+
'verified_position': 'City Civil Registrar',
|
| 961 |
+
'issued_to': 'Rosa Reyes Dela Cruz',
|
| 962 |
+
'amount_paid': '75.00',
|
| 963 |
+
'or_number': 'OR-2026-00456',
|
| 964 |
+
'date_paid': datetime.now().strftime('%B %d, %Y'),
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
+
elif form_hint == '2A':
|
| 968 |
+
fields = {
|
| 969 |
+
'registry_no': '2026-DC-00045',
|
| 970 |
+
'city_municipality': 'Tarlac City',
|
| 971 |
+
'province': 'Tarlac',
|
| 972 |
+
'date_issuance': datetime.now().strftime('%B %d, %Y'),
|
| 973 |
+
'deceased_first': 'Roberto',
|
| 974 |
+
'deceased_middle': 'Cruz',
|
| 975 |
+
'deceased_last': 'Villanueva',
|
| 976 |
+
'sex': 'Male',
|
| 977 |
+
'age_years': '72',
|
| 978 |
+
'civil_status': 'Married',
|
| 979 |
+
'citizenship': 'Filipino',
|
| 980 |
+
'dod_day': '28',
|
| 981 |
+
'dod_month': 'January',
|
| 982 |
+
'dod_year': '2026',
|
| 983 |
+
'pod_hospital': 'Tarlac Provincial Hospital',
|
| 984 |
+
'pod_city': 'Tarlac City',
|
| 985 |
+
'pod_province': 'Tarlac',
|
| 986 |
+
'cause_immediate': 'Cardiopulmonary Arrest',
|
| 987 |
+
'date_submitted': 'February 1, 2026',
|
| 988 |
+
'processed_by': 'John Doe',
|
| 989 |
+
}
|
| 990 |
+
|
| 991 |
+
elif form_hint == '3A':
|
| 992 |
+
fields = {
|
| 993 |
+
'registry_no': '2026-MC-00112',
|
| 994 |
+
'city_municipality': 'Tarlac City',
|
| 995 |
+
'province': 'Tarlac',
|
| 996 |
+
'date_issuance': datetime.now().strftime('%B %d, %Y'),
|
| 997 |
+
'husband_first': 'Carlos',
|
| 998 |
+
'husband_middle': 'Mendoza',
|
| 999 |
+
'husband_last': 'Reyes',
|
| 1000 |
+
'husband_age': '29',
|
| 1001 |
+
'husband_citizenship': 'Filipino',
|
| 1002 |
+
'husband_mother_first': 'Ana',
|
| 1003 |
+
'husband_father_first': 'Pedro',
|
| 1004 |
+
'husband_mother_citizenship': 'Filipino',
|
| 1005 |
+
'husband_father_citizenship': 'Filipino',
|
| 1006 |
+
'wife_first': 'Elena',
|
| 1007 |
+
'wife_middle': 'Santos',
|
| 1008 |
+
'wife_last': 'Garcia',
|
| 1009 |
+
'wife_age': '27',
|
| 1010 |
+
'wife_citizenship': 'Filipino',
|
| 1011 |
+
'wife_mother_first': 'Luz',
|
| 1012 |
+
'wife_father_first': 'Mario',
|
| 1013 |
+
'wife_mother_citizenship': 'Filipino',
|
| 1014 |
+
'wife_father_citizenship': 'Filipino',
|
| 1015 |
+
'marriage_venue': 'St. Sebastian Cathedral',
|
| 1016 |
+
'marriage_city': 'Tarlac City',
|
| 1017 |
+
'marriage_month': 'January 25, 2026',
|
| 1018 |
+
'date_submitted': 'January 26, 2026',
|
| 1019 |
+
}
|
| 1020 |
+
|
| 1021 |
+
else:
|
| 1022 |
+
fields = {
|
| 1023 |
+
'registry_no': 'ML-2026-00788',
|
| 1024 |
+
'city_municipality': 'Tarlac City',
|
| 1025 |
+
'province': 'Tarlac',
|
| 1026 |
+
'license_no': 'LIC-2026-0455',
|
| 1027 |
+
'date_issuance': 'March 02, 2026',
|
| 1028 |
+
'groom_name': 'Mark Anthony Dela Cruz',
|
| 1029 |
+
'groom_dob': 'May 04, 1998',
|
| 1030 |
+
'groom_age': '27',
|
| 1031 |
+
'groom_place_of_birth': 'Tarlac City',
|
| 1032 |
+
'groom_sex': 'MALE',
|
| 1033 |
+
'groom_citizenship': 'Filipino',
|
| 1034 |
+
'groom_civil_status': 'Single',
|
| 1035 |
+
'groom_residence': 'San Roque, Tarlac',
|
| 1036 |
+
'groom_religion': 'Catholic',
|
| 1037 |
+
'groom_father_first': 'Jose Dela Cruz',
|
| 1038 |
+
'groom_father_citizenship': 'Filipino',
|
| 1039 |
+
'groom_mother_first': 'Maria Santos',
|
| 1040 |
+
'groom_mother_citizenship': 'Filipino',
|
| 1041 |
+
'bride_name': 'Jane Marie Garcia',
|
| 1042 |
+
'bride_dob': 'August 11, 1999',
|
| 1043 |
+
'bride_age': '26',
|
| 1044 |
+
'bride_place_of_birth': 'Capas, Tarlac',
|
| 1045 |
+
'bride_sex': 'FEMALE',
|
| 1046 |
+
'bride_citizenship': 'Filipino',
|
| 1047 |
+
'bride_civil_status': 'Single',
|
| 1048 |
+
'bride_residence': 'Capas, Tarlac',
|
| 1049 |
+
'bride_religion': 'Catholic',
|
| 1050 |
+
'bride_father_first': 'Ramon Garcia',
|
| 1051 |
+
'bride_father_citizenship': 'Filipino',
|
| 1052 |
+
'bride_mother_first': 'Luisa Mendoza',
|
| 1053 |
+
'bride_mother_citizenship': 'Filipino',
|
| 1054 |
+
}
|
| 1055 |
+
|
| 1056 |
+
confidence = {k: 0.95 for k in fields}
|
| 1057 |
+
form_class = form_hint if form_hint in ('1A', '2A', '3A', '90') else '1A'
|
| 1058 |
+
return fields, confidence, form_class
|
| 1059 |
+
|
| 1060 |
+
|
| 1061 |
+
# ═════════════════════════════════════════════════════════════
|
| 1062 |
+
# HTML PREVIEW
|
| 1063 |
+
# ═════════════════════════════════════════════��═══════════════
|
| 1064 |
+
def _build_preview_html(form_class: str, fields: dict) -> str:
|
| 1065 |
+
def row(label, value):
|
| 1066 |
+
return (
|
| 1067 |
+
f'<tr><td class="lbl">{label}</td>'
|
| 1068 |
+
f'<td class="val">{value or ""}</td></tr>'
|
| 1069 |
+
)
|
| 1070 |
+
|
| 1071 |
+
rows = ""
|
| 1072 |
+
title = f"Form {form_class}"
|
| 1073 |
+
|
| 1074 |
+
if form_class == '1A':
|
| 1075 |
+
child = " ".join(filter(None, [
|
| 1076 |
+
fields.get('child_first', ''),
|
| 1077 |
+
fields.get('child_middle', ''),
|
| 1078 |
+
fields.get('child_last', ''),
|
| 1079 |
+
])).strip()
|
| 1080 |
+
rows = (
|
| 1081 |
+
row('Registry No.', fields.get('registry_no', '')) +
|
| 1082 |
+
row('City/Municipality', fields.get('city_municipality', '')) +
|
| 1083 |
+
row('Province', fields.get('province', '')) +
|
| 1084 |
+
row('Date Submitted', fields.get('date_submitted', '')) +
|
| 1085 |
+
'<tr><td colspan="2">CHILD</td></tr>' +
|
| 1086 |
+
row('Name', child) +
|
| 1087 |
+
row('Sex', fields.get('sex', '')) +
|
| 1088 |
+
row('Date of Birth', " ".join(filter(None, [
|
| 1089 |
+
fields.get('dob_month', ''),
|
| 1090 |
+
fields.get('dob_day', ''),
|
| 1091 |
+
fields.get('dob_year', ''),
|
| 1092 |
+
])).strip()) +
|
| 1093 |
+
row('Place of Birth', fields.get('pob_city', '')) +
|
| 1094 |
+
'<tr><td colspan="2">MOTHER</td></tr>' +
|
| 1095 |
+
row('Name', fields.get('mother_first', '')) +
|
| 1096 |
+
row('Citizenship', fields.get('mother_citizenship', '')) +
|
| 1097 |
+
'<tr><td colspan="2">FATHER</td></tr>' +
|
| 1098 |
+
row('Name', fields.get('father_first', '')) +
|
| 1099 |
+
row('Citizenship', fields.get('father_citizenship', ''))
|
| 1100 |
+
)
|
| 1101 |
+
title = child or 'Form 1A — Live Birth'
|
| 1102 |
+
|
| 1103 |
+
elif form_class == '2A':
|
| 1104 |
+
deceased = " ".join(filter(None, [
|
| 1105 |
+
fields.get('deceased_first', ''),
|
| 1106 |
+
fields.get('deceased_middle', ''),
|
| 1107 |
+
fields.get('deceased_last', ''),
|
| 1108 |
+
])).strip()
|
| 1109 |
+
rows = (
|
| 1110 |
+
row('Registry No.', fields.get('registry_no', '')) +
|
| 1111 |
+
row('City/Municipality', fields.get('city_municipality', '')) +
|
| 1112 |
+
row('Province', fields.get('province', '')) +
|
| 1113 |
+
row('Date Submitted', fields.get('date_submitted', '')) +
|
| 1114 |
+
row('Name of Deceased', deceased) +
|
| 1115 |
+
row('Sex', fields.get('sex', '')) +
|
| 1116 |
+
row('Age', fields.get('age_years', '')) +
|
| 1117 |
+
row('Civil Status', fields.get('civil_status', '')) +
|
| 1118 |
+
row('Citizenship', fields.get('citizenship', '')) +
|
| 1119 |
+
row('Date of Death', fields.get('dod_full', '')) +
|
| 1120 |
+
row('Place of Death', fields.get('pod_hospital', '')) +
|
| 1121 |
+
row('Cause of Death', fields.get('cause_immediate', ''))
|
| 1122 |
+
)
|
| 1123 |
+
title = deceased or 'Form 2A — Death Certificate'
|
| 1124 |
+
|
| 1125 |
+
elif form_class == '3A':
|
| 1126 |
+
husband = " ".join(filter(None, [
|
| 1127 |
+
fields.get('husband_first', ''),
|
| 1128 |
+
fields.get('husband_middle', ''),
|
| 1129 |
+
fields.get('husband_last', ''),
|
| 1130 |
+
])).strip()
|
| 1131 |
+
wife = " ".join(filter(None, [
|
| 1132 |
+
fields.get('wife_first', ''),
|
| 1133 |
+
fields.get('wife_middle', ''),
|
| 1134 |
+
fields.get('wife_last', ''),
|
| 1135 |
+
])).strip()
|
| 1136 |
+
rows = (
|
| 1137 |
+
row('Registry No.', fields.get('registry_no', '')) +
|
| 1138 |
+
row('City/Municipality', fields.get('city_municipality', '')) +
|
| 1139 |
+
row('Province', fields.get('province', '')) +
|
| 1140 |
+
row('Date Submitted', fields.get('date_submitted', '')) +
|
| 1141 |
+
'<tr><td colspan="2">HUSBAND</td></tr>' +
|
| 1142 |
+
row('Name', husband) +
|
| 1143 |
+
row('Age', fields.get('husband_age', '')) +
|
| 1144 |
+
row('Citizenship', fields.get('husband_citizenship', '')) +
|
| 1145 |
+
row('Mother', fields.get('husband_mother_first', '')) +
|
| 1146 |
+
row('Father', fields.get('husband_father_first', '')) +
|
| 1147 |
+
'<tr><td colspan="2">WIFE</td></tr>' +
|
| 1148 |
+
row('Name', wife) +
|
| 1149 |
+
row('Age', fields.get('wife_age', '')) +
|
| 1150 |
+
row('Citizenship', fields.get('wife_citizenship', '')) +
|
| 1151 |
+
row('Mother', fields.get('wife_mother_first', '')) +
|
| 1152 |
+
row('Father', fields.get('wife_father_first', '')) +
|
| 1153 |
+
'<tr><td colspan="2">MARRIAGE</td></tr>' +
|
| 1154 |
+
row('Venue', fields.get('marriage_venue', '')) +
|
| 1155 |
+
row('City', fields.get('marriage_city', '')) +
|
| 1156 |
+
row('Date', fields.get('marriage_month', ''))
|
| 1157 |
+
)
|
| 1158 |
+
title = f'Form 3A — {husband} & {wife}' if (husband or wife) else 'Form 3A — Marriage Certificate'
|
| 1159 |
+
|
| 1160 |
+
else:
|
| 1161 |
+
g = fields.get('groom_name', '')
|
| 1162 |
+
b = fields.get('bride_name', '')
|
| 1163 |
+
dom = " ".join(filter(None, [
|
| 1164 |
+
fields.get('marriage_month', ''),
|
| 1165 |
+
fields.get('marriage_day', ''),
|
| 1166 |
+
fields.get('marriage_year', ''),
|
| 1167 |
+
])).strip()
|
| 1168 |
+
pom = " ".join(filter(None, [
|
| 1169 |
+
fields.get('marriage_venue', ''),
|
| 1170 |
+
fields.get('marriage_city', ''),
|
| 1171 |
+
])).strip()
|
| 1172 |
+
|
| 1173 |
+
rows = (
|
| 1174 |
+
row('Registry No.', fields.get('registry_no', '')) +
|
| 1175 |
+
row('City/Municipality', fields.get('city_municipality', '')) +
|
| 1176 |
+
row('Province', fields.get('province', '')) +
|
| 1177 |
+
row('License No.', fields.get('license_no', '')) +
|
| 1178 |
+
row('Date Issuance', fields.get('date_issuance', '')) +
|
| 1179 |
+
'<tr><td colspan="2" style="padding:8px 0;font-weight:bold;background:#f9f9f9;text-align:center;">GROOM</td></tr>' +
|
| 1180 |
+
row('Name', g) +
|
| 1181 |
+
row('Date of Birth', fields.get('groom_dob', '')) +
|
| 1182 |
+
row('Age', fields.get('groom_age', '')) +
|
| 1183 |
+
row('Place of Birth', fields.get('groom_place_of_birth', '')) +
|
| 1184 |
+
row('Sex', fields.get('groom_sex', '')) +
|
| 1185 |
+
row('Citizenship', fields.get('groom_citizenship', '')) +
|
| 1186 |
+
row('Civil Status', fields.get('groom_civil_status', '')) +
|
| 1187 |
+
row('Residence', fields.get('groom_residence', '')) +
|
| 1188 |
+
row('Religion', fields.get('groom_religion', '')) +
|
| 1189 |
+
row('Father', fields.get('groom_father_first', '')) +
|
| 1190 |
+
row('Father Citizenship', fields.get('groom_father_citizenship', '')) +
|
| 1191 |
+
row('Mother', fields.get('groom_mother_first', '')) +
|
| 1192 |
+
row('Mother Citizenship', fields.get('groom_mother_citizenship', '')) +
|
| 1193 |
+
'<tr><td colspan="2" style="padding:8px 0;font-weight:bold;background:#f9f9f9;text-align:center;">BRIDE</td></tr>' +
|
| 1194 |
+
row('Name', b) +
|
| 1195 |
+
row('Date of Birth', fields.get('bride_dob', '')) +
|
| 1196 |
+
row('Age', fields.get('bride_age', '')) +
|
| 1197 |
+
row('Place of Birth', fields.get('bride_place_of_birth', '')) +
|
| 1198 |
+
row('Sex', fields.get('bride_sex', '')) +
|
| 1199 |
+
row('Citizenship', fields.get('bride_citizenship', '')) +
|
| 1200 |
+
row('Civil Status', fields.get('bride_civil_status', '')) +
|
| 1201 |
+
row('Residence', fields.get('bride_residence', '')) +
|
| 1202 |
+
row('Religion', fields.get('bride_religion', '')) +
|
| 1203 |
+
row('Father', fields.get('bride_father_first', '')) +
|
| 1204 |
+
row('Father Citizenship', fields.get('bride_father_citizenship', '')) +
|
| 1205 |
+
row('Mother', fields.get('bride_mother_first', '')) +
|
| 1206 |
+
row('Mother Citizenship', fields.get('bride_mother_citizenship', '')) +
|
| 1207 |
+
'<tr><td colspan="2" style="padding:8px 0;font-weight:bold;background:#f9f9f9;text-align:center;">MARRIAGE</td></tr>' +
|
| 1208 |
+
row('Date of Marriage', dom) +
|
| 1209 |
+
row('Place of Marriage', pom)
|
| 1210 |
+
)
|
| 1211 |
+
title = f'Form 90 — {g} & {b}' if (g or b) else 'Form 90 — Marriage License'
|
| 1212 |
+
|
| 1213 |
+
mode = 'REAL PIPELINE' if (USE_REAL_PIPELINE and _pipeline) else 'FAKE DATA (dev mode)'
|
| 1214 |
+
|
| 1215 |
+
return f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><title>{title}</title>
|
| 1216 |
+
<style>
|
| 1217 |
+
body{{font-family:Arial,sans-serif;font-size:13px;padding:40px 50px;color:#111;}}
|
| 1218 |
+
h2{{font-size:15px;border-bottom:2px solid #333;padding-bottom:8px;margin-bottom:16px;}}
|
| 1219 |
+
.mode{{font-size:11px;color:#888;margin-bottom:12px;}}
|
| 1220 |
+
table{{width:100%;border-collapse:collapse;}}
|
| 1221 |
+
td{{padding:6px 8px;border-bottom:1px dotted #ccc;vertical-align:top;}}
|
| 1222 |
+
td.lbl{{width:220px;color:#555;}}
|
| 1223 |
+
td.val{{font-weight:bold;background:#fffde7;border-bottom:1px solid #f0d000;}}
|
| 1224 |
+
tr td[colspan]{{background:#f5f5f5;font-weight:bold;text-align:center;color:#333;border-bottom:2px solid #ddd;}}
|
| 1225 |
+
</style></head><body>
|
| 1226 |
+
<h2>LCR Form No. {form_class} — {fields.get('city_municipality', '')}</h2>
|
| 1227 |
+
<div class="mode">Mode: {mode}</div>
|
| 1228 |
+
<table>{rows}</table>
|
| 1229 |
+
</body></html>"""
|
| 1230 |
+
|
| 1231 |
+
|
| 1232 |
+
if __name__ == '__main__':
|
| 1233 |
+
port = int(os.environ.get('PORT', 7860))
|
| 1234 |
+
app.run(host='0.0.0.0', port=port, debug=False)
|
bridge.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# bridge.py
|
| 2 |
+
# ============================================================
|
| 3 |
+
# BRIDGE — connects the three algorithms
|
| 4 |
+
#
|
| 5 |
+
# CRNN+CTC (Irish) → field dict from field_extractor.py
|
| 6 |
+
# MNB (Princess) → classifies form type
|
| 7 |
+
# spacyNER (Shane) → extracts + assembles fields
|
| 8 |
+
#
|
| 9 |
+
# DROP THIS FILE in the ROOT of your project:
|
| 10 |
+
#
|
| 11 |
+
# LCR-Document-Digitization-System/
|
| 12 |
+
# ├── CRNN+CTC/
|
| 13 |
+
# ├── MNB/
|
| 14 |
+
# ├── spacyNER/
|
| 15 |
+
# ├── bridge.py ← HERE
|
| 16 |
+
# └── pipeline.py
|
| 17 |
+
#
|
| 18 |
+
# NOTE: nationality = citizenship (same field, different label per form)
|
| 19 |
+
# The _get() helper handles both names automatically.
|
| 20 |
+
# ============================================================
|
| 21 |
+
|
| 22 |
+
import sys
|
| 23 |
+
import os
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
# ── Make all three algorithm folders importable ──────────────
|
| 27 |
+
_ROOT = Path(__file__).resolve().parent
|
| 28 |
+
|
| 29 |
+
for folder in ["CRNN+CTC", "MNB", "spacyNER"]:
|
| 30 |
+
p = str(_ROOT / folder)
|
| 31 |
+
if p not in sys.path:
|
| 32 |
+
sys.path.insert(0, p)
|
| 33 |
+
|
| 34 |
+
if str(_ROOT) not in sys.path:
|
| 35 |
+
sys.path.insert(0, str(_ROOT))
|
| 36 |
+
|
| 37 |
+
# ── Imports ──────────────────────────────────────────────────
|
| 38 |
+
from spacyNER.extractor import CivilRegistryNER
|
| 39 |
+
from spacyNER.autofill import AutoFillEngine
|
| 40 |
+
from MNB.classifier import MNBClassifier
|
| 41 |
+
|
| 42 |
+
# ── Default paths ────────────────────────────────────────────
|
| 43 |
+
NER_MODEL_PATH = str(_ROOT / "spacyNER" / "models" / "civil_registry_model" / "model-best")
|
| 44 |
+
MNB_MODEL_DIR = str(_ROOT / "MNB" / "models")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ════════════════════════════════════════════════════════════
|
| 48 |
+
# HELPER — nationality/citizenship alias
|
| 49 |
+
# Tries multiple key names, returns first non-empty value.
|
| 50 |
+
# nationality = citizenship — same field, different label per form.
|
| 51 |
+
# ════════════════════════════════════════════════════════════
|
| 52 |
+
|
| 53 |
+
def _get(f: dict, *keys, default='') -> str:
|
| 54 |
+
for k in keys:
|
| 55 |
+
v = f.get(k, '')
|
| 56 |
+
if v and str(v).strip():
|
| 57 |
+
return str(v).strip()
|
| 58 |
+
return default
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ════════════════════════════════════════════════════════════
|
| 62 |
+
# CRNN FIELD DICT → TEXT CONVERTERS
|
| 63 |
+
# Turns Irish's field dict into readable text that NER can read.
|
| 64 |
+
# Handles both old field names and new dynamic_field_extractor names.
|
| 65 |
+
# ════════════════════════════════════════════════════════════
|
| 66 |
+
|
| 67 |
+
def crnn_birth_to_text(f: dict) -> str:
|
| 68 |
+
"""Form 102 → Form 1A text.
|
| 69 |
+
Fields needed:
|
| 70 |
+
Registry Number, Date of Registration,
|
| 71 |
+
Name of Child, Sex, Date of Birth, Place of Birth,
|
| 72 |
+
Name of Mother, Nationality/Citizenship of Mother,
|
| 73 |
+
Name of Father, Nationality/Citizenship of Father,
|
| 74 |
+
Date of Marriage of Parents, Place of Marriage of Parents
|
| 75 |
+
"""
|
| 76 |
+
return (
|
| 77 |
+
f"Registry No.: {_get(f, 'registry_number', 'registry_no')}\n"
|
| 78 |
+
f"Date of Registration: {_get(f, 'date_of_registration')}\n"
|
| 79 |
+
f"1. NAME (First): {_get(f, 'child_first_name')} "
|
| 80 |
+
f"(Middle): {_get(f, 'child_middle_name')} "
|
| 81 |
+
f"(Last): {_get(f, 'child_last_name')}\n"
|
| 82 |
+
f"2. SEX: {_get(f, 'sex')}\n"
|
| 83 |
+
f"3. DATE OF BIRTH: {_get(f, 'dob_month')} {_get(f, 'dob_day')}, {_get(f, 'dob_year')}\n"
|
| 84 |
+
f"4. PLACE OF BIRTH: {_get(f, 'place_birth_hospital')} "
|
| 85 |
+
f"{_get(f, 'place_birth_city')} {_get(f, 'place_birth_province')}\n"
|
| 86 |
+
f"MOTHER:\n"
|
| 87 |
+
f"7. MAIDEN NAME (First): {_get(f, 'mother_first_name')} "
|
| 88 |
+
f"(Middle): {_get(f, 'mother_middle_name')} "
|
| 89 |
+
f"(Last): {_get(f, 'mother_last_name')}\n"
|
| 90 |
+
f"8. CITIZENSHIP/NATIONALITY: "
|
| 91 |
+
f"{_get(f, 'nationality_of_mother', 'mother_citizenship', 'mother_nationality')}\n"
|
| 92 |
+
f"FATHER:\n"
|
| 93 |
+
f"14. NAME (First): {_get(f, 'father_first_name')} "
|
| 94 |
+
f"(Middle): {_get(f, 'father_middle_name')} "
|
| 95 |
+
f"(Last): {_get(f, 'father_last_name')}\n"
|
| 96 |
+
f"15. CITIZENSHIP/NATIONALITY: "
|
| 97 |
+
f"{_get(f, 'nationality_of_father', 'father_citizenship', 'father_nationality')}\n"
|
| 98 |
+
f"MARRIAGE OF PARENTS:\n"
|
| 99 |
+
f"20a. DATE: {_get(f, 'parents_marriage_month')} "
|
| 100 |
+
f"{_get(f, 'parents_marriage_day')}, {_get(f, 'parents_marriage_year')}\n"
|
| 101 |
+
f"20b. PLACE: {_get(f, 'parents_marriage_city')} "
|
| 102 |
+
f"{_get(f, 'parents_marriage_province')}\n"
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def crnn_death_to_text(f: dict) -> str:
|
| 107 |
+
"""Form 103 → Form 2A text.
|
| 108 |
+
Fields needed:
|
| 109 |
+
Registry Number, Date of Registration,
|
| 110 |
+
Name of Deceased, Sex, Age, Civil Status,
|
| 111 |
+
Nationality/Citizenship, Date of Death, Place of Death,
|
| 112 |
+
Cause of Death
|
| 113 |
+
"""
|
| 114 |
+
return (
|
| 115 |
+
f"Registry No.: {_get(f, 'registry_number', 'registry_no')}\n"
|
| 116 |
+
f"Date of Registration: {_get(f, 'date_of_registration')}\n"
|
| 117 |
+
f"1. NAME (First): {_get(f, 'deceased_first_name')} "
|
| 118 |
+
f"(Middle): {_get(f, 'deceased_middle_name')} "
|
| 119 |
+
f"(Last): {_get(f, 'deceased_last_name')}\n"
|
| 120 |
+
f"2. SEX: {_get(f, 'sex')}\n"
|
| 121 |
+
f"4. AGE: {_get(f, 'age', 'age_years')}\n"
|
| 122 |
+
f"9. CIVIL STATUS: {_get(f, 'civil_status')}\n"
|
| 123 |
+
f"7. CITIZENSHIP/NATIONALITY: {_get(f, 'nationality', 'citizenship')}\n"
|
| 124 |
+
f"6. DATE OF DEATH: {_get(f, 'dod_month')} {_get(f, 'dod_day')}, {_get(f, 'dod_year')}\n"
|
| 125 |
+
f"5. PLACE OF DEATH: {_get(f, 'place_death_hospital')} "
|
| 126 |
+
f"{_get(f, 'place_death_city')} {_get(f, 'place_death_province')}\n"
|
| 127 |
+
f"17. CAUSE OF DEATH: {_get(f, 'cause_of_death', 'cause_immediate')}\n"
|
| 128 |
+
f"Antecedent cause: {_get(f, 'cause_antecedent')}\n"
|
| 129 |
+
f"Underlying cause: {_get(f, 'cause_underlying')}\n"
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def crnn_marriage_to_text(f: dict) -> str:
|
| 134 |
+
"""Form 97 → Form 3A text.
|
| 135 |
+
Fields needed (both husband and wife):
|
| 136 |
+
Name, Age, Nationality/Citizenship,
|
| 137 |
+
Name of Mother, Nationality/Citizenship of Mother,
|
| 138 |
+
Name of Father, Nationality/Citizenship of Father,
|
| 139 |
+
Registry Number, Date of Registration,
|
| 140 |
+
Date of Marriage, Place of Marriage
|
| 141 |
+
"""
|
| 142 |
+
return (
|
| 143 |
+
f"Registry No.: {_get(f, 'registry_number', 'registry_no')}\n"
|
| 144 |
+
f"Date of Registration: {_get(f, 'date_of_registration')}\n"
|
| 145 |
+
f"HUSBAND:\n"
|
| 146 |
+
f"1. NAME (First): {_get(f, 'husband_first_name')} "
|
| 147 |
+
f"(Middle): {_get(f, 'husband_middle_name')} "
|
| 148 |
+
f"(Last): {_get(f, 'husband_last_name')}\n"
|
| 149 |
+
f"2b. AGE: {_get(f, 'husband_age')}\n"
|
| 150 |
+
f"4b. CITIZENSHIP/NATIONALITY: "
|
| 151 |
+
f"{_get(f, 'husband_nationality', 'husband_citizenship')}\n"
|
| 152 |
+
f"8. NAME OF FATHER (First): {_get(f, 'husband_father_first')} "
|
| 153 |
+
f"(Middle): {_get(f, 'husband_father_middle')} "
|
| 154 |
+
f"(Last): {_get(f, 'husband_father_last')}\n"
|
| 155 |
+
f"8b. FATHER CITIZENSHIP/NATIONALITY: "
|
| 156 |
+
f"{_get(f, 'husband_father_nationality', 'husband_father_citizenship')}\n"
|
| 157 |
+
f"10. NAME OF MOTHER (First): {_get(f, 'husband_mother_first')} "
|
| 158 |
+
f"(Middle): {_get(f, 'husband_mother_middle')} "
|
| 159 |
+
f"(Last): {_get(f, 'husband_mother_last')}\n"
|
| 160 |
+
f"10b. MOTHER CITIZENSHIP/NATIONALITY: "
|
| 161 |
+
f"{_get(f, 'husband_mother_nationality', 'husband_mother_citizenship')}\n"
|
| 162 |
+
f"WIFE:\n"
|
| 163 |
+
f"1. NAME (First): {_get(f, 'wife_first_name')} "
|
| 164 |
+
f"(Middle): {_get(f, 'wife_middle_name')} "
|
| 165 |
+
f"(Last): {_get(f, 'wife_last_name')}\n"
|
| 166 |
+
f"2b. AGE: {_get(f, 'wife_age')}\n"
|
| 167 |
+
f"4b. CITIZENSHIP/NATIONALITY: "
|
| 168 |
+
f"{_get(f, 'wife_nationality', 'wife_citizenship')}\n"
|
| 169 |
+
f"8. NAME OF FATHER (First): {_get(f, 'wife_father_first')} "
|
| 170 |
+
f"(Middle): {_get(f, 'wife_father_middle')} "
|
| 171 |
+
f"(Last): {_get(f, 'wife_father_last')}\n"
|
| 172 |
+
f"8b. FATHER CITIZENSHIP/NATIONALITY: "
|
| 173 |
+
f"{_get(f, 'wife_father_nationality', 'wife_father_citizenship')}\n"
|
| 174 |
+
f"10. NAME OF MOTHER (First): {_get(f, 'wife_mother_first')} "
|
| 175 |
+
f"(Middle): {_get(f, 'wife_mother_middle')} "
|
| 176 |
+
f"(Last): {_get(f, 'wife_mother_last')}\n"
|
| 177 |
+
f"10b. MOTHER CITIZENSHIP/NATIONALITY: "
|
| 178 |
+
f"{_get(f, 'wife_mother_nationality', 'wife_mother_citizenship')}\n"
|
| 179 |
+
f"15. PLACE OF MARRIAGE: "
|
| 180 |
+
f"{_get(f, 'place_marriage_city')} {_get(f, 'place_marriage_province')}\n"
|
| 181 |
+
f"16. DATE OF MARRIAGE: {_get(f, 'date_marriage_month')} "
|
| 182 |
+
f"{_get(f, 'date_marriage_day')}, {_get(f, 'date_marriage_year')}\n"
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def crnn_birth_to_form90_text(f: dict, role: str = 'groom') -> str:
|
| 187 |
+
"""Birth cert of groom or bride → Form 90 text.
|
| 188 |
+
Fields needed:
|
| 189 |
+
Name, Date of Birth, Place of Birth, Sex,
|
| 190 |
+
Citizenship/Nationality,
|
| 191 |
+
Name of Father, Citizenship of Father,
|
| 192 |
+
Name of Mother, Citizenship of Mother
|
| 193 |
+
role: 'groom' or 'bride'
|
| 194 |
+
"""
|
| 195 |
+
return (
|
| 196 |
+
f"{role.upper()}:\n"
|
| 197 |
+
f"1. NAME (First): {_get(f, 'first_name', 'child_first_name')} "
|
| 198 |
+
f"(Middle): {_get(f, 'middle_name', 'child_middle_name')} "
|
| 199 |
+
f"(Last): {_get(f, 'last_name', 'child_last_name')}\n"
|
| 200 |
+
f"2. DATE OF BIRTH: {_get(f, 'dob_month')} {_get(f, 'dob_day')}, {_get(f, 'dob_year')}\n"
|
| 201 |
+
f"3. PLACE OF BIRTH: {_get(f, 'place_birth_hospital')} "
|
| 202 |
+
f"{_get(f, 'place_birth_city')} {_get(f, 'place_birth_province')}\n"
|
| 203 |
+
f"4. SEX: {_get(f, 'sex')}\n"
|
| 204 |
+
f"5. CITIZENSHIP/NATIONALITY: "
|
| 205 |
+
f"{_get(f, 'citizenship', 'nationality', 'nationality_of_mother', 'mother_citizenship')}\n"
|
| 206 |
+
f"NAME OF FATHER (First): {_get(f, 'father_first_name')} "
|
| 207 |
+
f"(Middle): {_get(f, 'father_middle_name')} "
|
| 208 |
+
f"(Last): {_get(f, 'father_last_name')}\n"
|
| 209 |
+
f"FATHER CITIZENSHIP/NATIONALITY: "
|
| 210 |
+
f"{_get(f, 'father_citizenship', 'father_nationality')}\n"
|
| 211 |
+
f"NAME OF MOTHER (First): {_get(f, 'mother_first_name')} "
|
| 212 |
+
f"(Middle): {_get(f, 'mother_middle_name')} "
|
| 213 |
+
f"(Last): {_get(f, 'mother_last_name')}\n"
|
| 214 |
+
f"MOTHER CITIZENSHIP/NATIONALITY: "
|
| 215 |
+
f"{_get(f, 'mother_citizenship', 'mother_nationality')}\n"
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
# ── Auto-detect form type from CRNN field keys ───────────────
|
| 220 |
+
_BIRTH_KEYS = {'child_first_name', 'mother_first_name', 'dob_day',
|
| 221 |
+
'registry_number', 'nationality_of_mother'}
|
| 222 |
+
_DEATH_KEYS = {'deceased_first_name', 'cause_of_death', 'dod_day',
|
| 223 |
+
'cause_immediate', 'nationality'}
|
| 224 |
+
_MARRIAGE_KEYS = {'husband_first_name', 'wife_first_name', 'date_marriage_day',
|
| 225 |
+
'husband_nationality', 'wife_nationality'}
|
| 226 |
+
|
| 227 |
+
_CONVERTERS = {
|
| 228 |
+
'birth': crnn_birth_to_text,
|
| 229 |
+
'death': crnn_death_to_text,
|
| 230 |
+
'marriage': crnn_marriage_to_text,
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
def _detect_form_type(fields: dict) -> str:
|
| 234 |
+
keys = set(fields.keys())
|
| 235 |
+
if keys & _BIRTH_KEYS: return 'birth'
|
| 236 |
+
if keys & _DEATH_KEYS: return 'death'
|
| 237 |
+
if keys & _MARRIAGE_KEYS: return 'marriage'
|
| 238 |
+
return 'birth'
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
# ════════════════════════════════════════════════════════════
|
| 242 |
+
# BRIDGE CLASS
|
| 243 |
+
# ════════════════════════════════════════════════════════════
|
| 244 |
+
|
| 245 |
+
class CivilRegistryBridge:
|
| 246 |
+
"""
|
| 247 |
+
The single connection point between the three algorithms.
|
| 248 |
+
|
| 249 |
+
Usage:
|
| 250 |
+
from bridge import CivilRegistryBridge
|
| 251 |
+
|
| 252 |
+
bridge = CivilRegistryBridge()
|
| 253 |
+
|
| 254 |
+
# Path A — birth / death / marriage cert
|
| 255 |
+
form = bridge.process(crnn_fields, form_hint="birth")
|
| 256 |
+
print(form.to_dict())
|
| 257 |
+
|
| 258 |
+
# Path B — Form 90 (two birth certs)
|
| 259 |
+
form90 = bridge.process_marriage_license(
|
| 260 |
+
groom_crnn_fields,
|
| 261 |
+
bride_crnn_fields
|
| 262 |
+
)
|
| 263 |
+
"""
|
| 264 |
+
|
| 265 |
+
def __init__(self,
|
| 266 |
+
ner_model_path: str = NER_MODEL_PATH,
|
| 267 |
+
mnb_model_dir: str = MNB_MODEL_DIR):
|
| 268 |
+
|
| 269 |
+
# Princess's MNB classifier
|
| 270 |
+
self.mnb = MNBClassifier(model_dir=mnb_model_dir)
|
| 271 |
+
|
| 272 |
+
# Shane's NER extractor
|
| 273 |
+
self.extractor = CivilRegistryNER(model_path=ner_model_path)
|
| 274 |
+
self.filler = AutoFillEngine(self.extractor)
|
| 275 |
+
|
| 276 |
+
# ── Path A — single cert (birth / death / marriage) ──────
|
| 277 |
+
def process(self, crnn_fields: dict, form_hint: str = None):
|
| 278 |
+
"""
|
| 279 |
+
Parameters
|
| 280 |
+
----------
|
| 281 |
+
crnn_fields : dict
|
| 282 |
+
Output from Irish's run_crnn_ocr() / dynamic_field_extractor
|
| 283 |
+
|
| 284 |
+
form_hint : str, optional
|
| 285 |
+
'birth' | 'death' | 'marriage'
|
| 286 |
+
Auto-detected from field keys if not given.
|
| 287 |
+
|
| 288 |
+
Returns
|
| 289 |
+
-------
|
| 290 |
+
Form1A | Form2A | Form3A with all fields populated
|
| 291 |
+
"""
|
| 292 |
+
form_type = form_hint or _detect_form_type(crnn_fields)
|
| 293 |
+
ocr_text = _CONVERTERS.get(form_type, crnn_birth_to_text)(crnn_fields)
|
| 294 |
+
mnb_label = self.mnb.classify_form_type(ocr_text)
|
| 295 |
+
print(f" [Bridge] hint={form_type!r} MNB={mnb_label} NER→running...")
|
| 296 |
+
|
| 297 |
+
# Use MNB classification result to pick the correct form filler
|
| 298 |
+
if mnb_label == 'form2a':
|
| 299 |
+
return self.filler.fill_form_2a(ocr_text)
|
| 300 |
+
elif mnb_label == 'form3a':
|
| 301 |
+
return self.filler.fill_form_3a(ocr_text)
|
| 302 |
+
elif mnb_label == 'form90':
|
| 303 |
+
return self.filler.fill_form_90(ocr_text, ocr_text)
|
| 304 |
+
else:
|
| 305 |
+
return self.filler.fill_form_1a(ocr_text)
|
| 306 |
+
|
| 307 |
+
# ── Path B — Form 90 (two birth certs) ───────────────────
|
| 308 |
+
def process_marriage_license(self,
|
| 309 |
+
groom_crnn_fields: dict,
|
| 310 |
+
bride_crnn_fields: dict):
|
| 311 |
+
"""
|
| 312 |
+
Parameters
|
| 313 |
+
----------
|
| 314 |
+
groom_crnn_fields : dict CRNN output for groom's birth cert
|
| 315 |
+
bride_crnn_fields : dict CRNN output for bride's birth cert
|
| 316 |
+
|
| 317 |
+
Returns
|
| 318 |
+
-------
|
| 319 |
+
Form90 with groom.* and bride.* fields populated
|
| 320 |
+
"""
|
| 321 |
+
groom_text = crnn_birth_to_form90_text(groom_crnn_fields, role='groom')
|
| 322 |
+
bride_text = crnn_birth_to_form90_text(bride_crnn_fields, role='bride')
|
| 323 |
+
|
| 324 |
+
groom_sex = self.mnb.classify_sex(groom_text)
|
| 325 |
+
bride_sex = self.mnb.classify_sex(bride_text)
|
| 326 |
+
print(f" [Bridge] Form90 groom_sex={groom_sex} bride_sex={bride_sex}")
|
| 327 |
+
|
| 328 |
+
return self.filler.fill_form_90(groom_text, bride_text)
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
# ── Quick test — run: python bridge.py ───────────────────────
|
| 332 |
+
if __name__ == "__main__":
|
| 333 |
+
|
| 334 |
+
SAMPLE_BIRTH = {
|
| 335 |
+
"registry_number": "2024-001",
|
| 336 |
+
"date_of_registration": "June 12, 1998",
|
| 337 |
+
"child_first_name": "TASLIAH",
|
| 338 |
+
"child_middle_name": "ABOBACAR",
|
| 339 |
+
"child_last_name": "GOMONSANG",
|
| 340 |
+
"sex": "FEMALE",
|
| 341 |
+
"dob_day": "12",
|
| 342 |
+
"dob_month": "JUNE",
|
| 343 |
+
"dob_year": "1998",
|
| 344 |
+
"place_birth_hospital": "CAMP JAS BLISS",
|
| 345 |
+
"place_birth_city": "MALABANG",
|
| 346 |
+
"place_birth_province": "LANAO DEL SUR",
|
| 347 |
+
"mother_first_name": "H. ASLIAH",
|
| 348 |
+
"mother_middle_name": "SANTICAN",
|
| 349 |
+
"mother_last_name": "ABOBACAR",
|
| 350 |
+
"nationality_of_mother": "FILIPINO", # nationality = citizenship
|
| 351 |
+
"father_first_name": "H. NAEEF",
|
| 352 |
+
"father_middle_name": "MUDAG",
|
| 353 |
+
"father_last_name": "GOMONSANG",
|
| 354 |
+
"nationality_of_father": "FILIPINO", # nationality = citizenship
|
| 355 |
+
"parents_marriage_month": "JANUARY",
|
| 356 |
+
"parents_marriage_day": "5",
|
| 357 |
+
"parents_marriage_year": "1990",
|
| 358 |
+
"parents_marriage_city": "CAMP JAS BLISS MALABANG",
|
| 359 |
+
"parents_marriage_province": "LANAO DEL SUR",
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
print("=" * 55)
|
| 363 |
+
print(" BRIDGE TEST")
|
| 364 |
+
print("=" * 55)
|
| 365 |
+
|
| 366 |
+
bridge = CivilRegistryBridge()
|
| 367 |
+
form = bridge.process(SAMPLE_BIRTH, form_hint="birth")
|
| 368 |
+
|
| 369 |
+
print(f"\n name_of_child → {form.name_of_child!r}")
|
| 370 |
+
print(f" name_of_mother → {form.name_of_mother!r}")
|
| 371 |
+
print(f" name_of_father → {form.name_of_father!r}")
|
| 372 |
+
print(f" date_of_birth → {form.date_of_birth!r}")
|
| 373 |
+
print("\n Full result:")
|
| 374 |
+
for k, v in form.to_dict().items():
|
| 375 |
+
if v:
|
| 376 |
+
print(f" {k:<35} {v}")
|
calibrate_fields.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
calibrate_fields.py
|
| 3 |
+
===================
|
| 4 |
+
Click-to-measure tool for recalibrating field ratios in field_extractor.py.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
python calibrate_fields.py --image your_scan.png --form birth
|
| 8 |
+
|
| 9 |
+
Controls:
|
| 10 |
+
• Click and drag → draw a field box
|
| 11 |
+
• After releasing → enter the field name in the terminal
|
| 12 |
+
• Press S → save all measured ratios to calibrated_fields.py
|
| 13 |
+
• Press Z → undo last box
|
| 14 |
+
• Press Q / ESC → quit without saving
|
| 15 |
+
|
| 16 |
+
Output:
|
| 17 |
+
calibrated_fields.py — copy-paste the dict into field_extractor.py
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
import json
|
| 22 |
+
import cv2
|
| 23 |
+
import numpy as np
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
# ── state ─────────────────────────────────────────────────────────────────────
|
| 27 |
+
drawing = False
|
| 28 |
+
ix, iy = -1, -1
|
| 29 |
+
ex, ey = -1, -1
|
| 30 |
+
boxes = [] # list of (name, rx1, ry1, rx2, ry2)
|
| 31 |
+
form_name = "birth"
|
| 32 |
+
|
| 33 |
+
COLOURS = [
|
| 34 |
+
(0,200,0),(0,150,255),(200,0,200),(0,200,200),(200,200,0),(220,20,60),
|
| 35 |
+
(255,140,0),(150,50,200),(0,160,80),(30,144,255),(255,20,147),(100,200,100),
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
def draw_boxes(img, bounds):
|
| 39 |
+
left, top, right, bottom = bounds
|
| 40 |
+
fw = right - left
|
| 41 |
+
fh = bottom - top
|
| 42 |
+
|
| 43 |
+
vis = img.copy()
|
| 44 |
+
# form boundary
|
| 45 |
+
cv2.rectangle(vis, (left, top), (right, bottom), (0, 140, 255), 2)
|
| 46 |
+
|
| 47 |
+
for idx, (name, rx1, ry1, rx2, ry2) in enumerate(boxes):
|
| 48 |
+
x1 = int(left + rx1 * fw)
|
| 49 |
+
y1 = int(top + ry1 * fh)
|
| 50 |
+
x2 = int(left + rx2 * fw)
|
| 51 |
+
y2 = int(top + ry2 * fh)
|
| 52 |
+
c = COLOURS[idx % len(COLOURS)]
|
| 53 |
+
cv2.rectangle(vis, (x1, y1), (x2, y2), c, 2)
|
| 54 |
+
cv2.putText(vis, name[:25], (x1 + 2, max(0, y1 - 3)),
|
| 55 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.35, c, 1)
|
| 56 |
+
|
| 57 |
+
# live cursor box
|
| 58 |
+
if drawing and ix >= 0 and ex >= 0:
|
| 59 |
+
cv2.rectangle(vis, (ix, iy), (ex, ey), (255, 255, 255), 1)
|
| 60 |
+
|
| 61 |
+
# instructions
|
| 62 |
+
cv2.putText(vis, "Drag=draw box | S=save | Z=undo | Q=quit",
|
| 63 |
+
(10, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,0,255), 1)
|
| 64 |
+
cv2.putText(vis, f"Boxes: {len(boxes)}",
|
| 65 |
+
(10, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,255,0), 1)
|
| 66 |
+
return vis
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def detect_bounds(image_bgr):
|
| 70 |
+
"""Simple form boundary detection (reuses logic from FormBoundsDetector)."""
|
| 71 |
+
h, w = image_bgr.shape[:2]
|
| 72 |
+
gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
|
| 73 |
+
try:
|
| 74 |
+
thresh = cv2.adaptiveThreshold(
|
| 75 |
+
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
| 76 |
+
cv2.THRESH_BINARY_INV, 11, 2)
|
| 77 |
+
hk = cv2.getStructuringElement(cv2.MORPH_RECT, (max(w // 5, 10), 1))
|
| 78 |
+
h_lines = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, hk)
|
| 79 |
+
h_rows = np.where(np.sum(h_lines, axis=1) > w * 0.15)[0]
|
| 80 |
+
vk = cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(h // 5, 10)))
|
| 81 |
+
v_lines = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, vk)
|
| 82 |
+
v_cols = np.where(np.sum(v_lines, axis=0) > h * 0.08)[0]
|
| 83 |
+
if len(h_rows) == 0 or len(v_cols) == 0:
|
| 84 |
+
return (0, 0, w, h)
|
| 85 |
+
top_b, bottom_b = int(h_rows.min()), int(h_rows.max())
|
| 86 |
+
left_b, right_b = int(v_cols.min()), int(v_cols.max())
|
| 87 |
+
if (right_b - left_b) < w * 0.4 or (bottom_b - top_b) < h * 0.4:
|
| 88 |
+
return (0, 0, w, h)
|
| 89 |
+
return (left_b, top_b, right_b, bottom_b)
|
| 90 |
+
except Exception:
|
| 91 |
+
return (0, 0, w, h)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def save_calibration(output_path, form):
|
| 95 |
+
dict_name = {
|
| 96 |
+
"birth": "BIRTH_FIELDS",
|
| 97 |
+
"death": "DEATH_FIELDS",
|
| 98 |
+
"marriage": "MARRIAGE_FIELDS",
|
| 99 |
+
"marriage_license": "MARRIAGE_LICENSE_FIELDS",
|
| 100 |
+
}.get(form, "CALIBRATED_FIELDS")
|
| 101 |
+
|
| 102 |
+
lines = [f"# Auto-calibrated — copy-paste into field_extractor.py\n",
|
| 103 |
+
f"{dict_name} = {{\n"]
|
| 104 |
+
for name, rx1, ry1, rx2, ry2 in boxes:
|
| 105 |
+
lines.append(f' "{name}":{" " * max(1, 34 - len(name))}'
|
| 106 |
+
f'({rx1:.4f}, {ry1:.4f}, {rx2:.4f}, {ry2:.4f}),\n')
|
| 107 |
+
lines.append("}\n")
|
| 108 |
+
|
| 109 |
+
with open(output_path, "w") as f:
|
| 110 |
+
f.writelines(lines)
|
| 111 |
+
print(f"\n Saved {len(boxes)} fields → {output_path}")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def main():
|
| 115 |
+
global drawing, ix, iy, ex, ey, form_name
|
| 116 |
+
|
| 117 |
+
parser = argparse.ArgumentParser()
|
| 118 |
+
parser.add_argument("--image", required=True)
|
| 119 |
+
parser.add_argument("--form", default="birth",
|
| 120 |
+
choices=["birth","death","marriage","marriage_license"])
|
| 121 |
+
parser.add_argument("--output", default="calibrated_fields.py")
|
| 122 |
+
parser.add_argument("--scale", type=float, default=1.0,
|
| 123 |
+
help="Scale factor to fit image on screen (e.g. 0.5)")
|
| 124 |
+
args = parser.parse_args()
|
| 125 |
+
form_name = args.form
|
| 126 |
+
|
| 127 |
+
img_orig = cv2.imread(args.image)
|
| 128 |
+
if img_orig is None:
|
| 129 |
+
print(f"ERROR: Cannot load {args.image}")
|
| 130 |
+
return
|
| 131 |
+
|
| 132 |
+
scale = args.scale
|
| 133 |
+
if scale != 1.0:
|
| 134 |
+
img_orig = cv2.resize(img_orig, None, fx=scale, fy=scale)
|
| 135 |
+
|
| 136 |
+
bounds = detect_bounds(img_orig)
|
| 137 |
+
left, top, right, bottom = bounds
|
| 138 |
+
fw = right - left
|
| 139 |
+
fh = bottom - top
|
| 140 |
+
print(f" Form boundary detected: {bounds} ({fw}×{fh} px)")
|
| 141 |
+
print(f" Scale: {scale}")
|
| 142 |
+
print("\n Instructions:")
|
| 143 |
+
print(" Drag → draw a field box")
|
| 144 |
+
print(" After releasing → type field name in terminal, press Enter")
|
| 145 |
+
print(" S → save all boxes")
|
| 146 |
+
print(" Z → undo last box")
|
| 147 |
+
print(" Q/ESC → quit\n")
|
| 148 |
+
|
| 149 |
+
win = "Calibrate Fields"
|
| 150 |
+
cv2.namedWindow(win, cv2.WINDOW_NORMAL)
|
| 151 |
+
|
| 152 |
+
def mouse(event, x, y, flags, param):
|
| 153 |
+
global drawing, ix, iy, ex, ey
|
| 154 |
+
if event == cv2.EVENT_LBUTTONDOWN:
|
| 155 |
+
drawing = True
|
| 156 |
+
ix, iy = x, y
|
| 157 |
+
ex, ey = x, y
|
| 158 |
+
elif event == cv2.EVENT_MOUSEMOVE and drawing:
|
| 159 |
+
ex, ey = x, y
|
| 160 |
+
elif event == cv2.EVENT_LBUTTONUP:
|
| 161 |
+
drawing = False
|
| 162 |
+
ex, ey = x, y
|
| 163 |
+
x1r = (min(ix, ex) - left) / fw
|
| 164 |
+
y1r = (min(iy, ey) - top) / fh
|
| 165 |
+
x2r = (max(ix, ex) - left) / fw
|
| 166 |
+
y2r = (max(iy, ey) - top) / fh
|
| 167 |
+
x1r, y1r = max(0.0, x1r), max(0.0, y1r)
|
| 168 |
+
x2r, y2r = min(1.0, x2r), min(1.0, y2r)
|
| 169 |
+
if (x2r - x1r) > 0.005 and (y2r - y1r) > 0.003:
|
| 170 |
+
name = input(f" Field name for ({x1r:.3f},{y1r:.3f},{x2r:.3f},{y2r:.3f}): ").strip()
|
| 171 |
+
if name:
|
| 172 |
+
boxes.append((name, x1r, y1r, x2r, y2r))
|
| 173 |
+
print(f" ✓ '{name}' added (total: {len(boxes)})")
|
| 174 |
+
|
| 175 |
+
cv2.setMouseCallback(win, mouse)
|
| 176 |
+
|
| 177 |
+
while True:
|
| 178 |
+
vis = draw_boxes(img_orig, bounds)
|
| 179 |
+
cv2.imshow(win, vis)
|
| 180 |
+
key = cv2.waitKey(20) & 0xFF
|
| 181 |
+
|
| 182 |
+
if key in (ord('q'), 27):
|
| 183 |
+
print(" Quit — no file saved.")
|
| 184 |
+
break
|
| 185 |
+
elif key == ord('s'):
|
| 186 |
+
save_calibration(args.output, form_name)
|
| 187 |
+
break
|
| 188 |
+
elif key == ord('z') and boxes:
|
| 189 |
+
removed = boxes.pop()
|
| 190 |
+
print(f" Undone: '{removed[0]}'")
|
| 191 |
+
|
| 192 |
+
cv2.destroyAllWindows()
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
if __name__ == "__main__":
|
| 196 |
+
main()
|
calibrated_fields.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Auto-calibrated � copy-paste into field_extractor.py
|
| 2 |
+
BIRTH_FIELDS = {
|
| 3 |
+
"Province": (0.0941, 0.0701, 0.6361, 0.0848),
|
| 4 |
+
"City/Municipality": (0.1621, 0.0880, 0.6429, 0.1086),
|
| 5 |
+
"first_name": (0.0465, 0.1183, 0.3265, 0.1375),
|
| 6 |
+
"middle_name": (0.3469, 0.1189, 0.6916, 0.1375),
|
| 7 |
+
}
|
check_cer.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
check_cer.py
|
| 3 |
+
============
|
| 4 |
+
Measures TRUE CER by actually running the model on images.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
python check_cer.py # live CER on val set
|
| 8 |
+
python check_cer.py --saved # old behavior (fast, unreliable)
|
| 9 |
+
python check_cer.py --images test_images/ # run on any image folder
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
import json
|
| 15 |
+
import random
|
| 16 |
+
import cv2
|
| 17 |
+
import numpy as np
|
| 18 |
+
import editdistance
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
import torch
|
| 23 |
+
except ImportError:
|
| 24 |
+
print("ERROR: torch not installed. Run: pip install torch")
|
| 25 |
+
exit(1)
|
| 26 |
+
|
| 27 |
+
USE_SAVED = '--saved' in sys.argv
|
| 28 |
+
IMAGE_DIR = None
|
| 29 |
+
for i, arg in enumerate(sys.argv[1:], 1):
|
| 30 |
+
if arg == '--images' and i < len(sys.argv) - 1:
|
| 31 |
+
IMAGE_DIR = sys.argv[i + 1]
|
| 32 |
+
elif arg.startswith('--images='):
|
| 33 |
+
IMAGE_DIR = arg.split('=', 1)[1]
|
| 34 |
+
|
| 35 |
+
CHECKPOINTS = [
|
| 36 |
+
'checkpoint_epoch_50.pth',
|
| 37 |
+
'checkpoint_epoch_60.pth',
|
| 38 |
+
'checkpoint_epoch_70.pth',
|
| 39 |
+
'checkpoint_epoch_80.pth',
|
| 40 |
+
'checkpoint_epoch_90.pth',
|
| 41 |
+
'checkpoint_epoch_100.pth',
|
| 42 |
+
]
|
| 43 |
+
CHECKPOINT_DIR = 'checkpoints'
|
| 44 |
+
VAL_DATA_DIR = 'data/val'
|
| 45 |
+
VAL_ANN_FILE = 'data/val_annotations.json'
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class AdaptiveImageNormalizer:
|
| 49 |
+
def __init__(self, target_height=64, target_width=512):
|
| 50 |
+
self.H = target_height
|
| 51 |
+
self.W = target_width
|
| 52 |
+
|
| 53 |
+
def _crop_to_text(self, gray):
|
| 54 |
+
inv = cv2.bitwise_not(gray)
|
| 55 |
+
_, thresh = cv2.threshold(inv, 20, 255, cv2.THRESH_BINARY)
|
| 56 |
+
coords = np.column_stack(np.where(thresh > 0))
|
| 57 |
+
if len(coords) == 0:
|
| 58 |
+
return gray
|
| 59 |
+
y_min, x_min = coords.min(axis=0)
|
| 60 |
+
y_max, x_max = coords.max(axis=0)
|
| 61 |
+
pad = max(4, int((y_max - y_min) * 0.15))
|
| 62 |
+
y_min = max(0, y_min - pad)
|
| 63 |
+
x_min = max(0, x_min - pad)
|
| 64 |
+
y_max = min(gray.shape[0] - 1, y_max + pad)
|
| 65 |
+
x_max = min(gray.shape[1] - 1, x_max + pad)
|
| 66 |
+
return gray[y_min:y_max + 1, x_min:x_max + 1]
|
| 67 |
+
|
| 68 |
+
def _smart_resize_gray(self, gray):
|
| 69 |
+
h, w = gray.shape
|
| 70 |
+
if h == 0 or w == 0:
|
| 71 |
+
return np.ones((self.H, self.W), dtype=np.uint8) * 255
|
| 72 |
+
scale = self.H / h
|
| 73 |
+
new_w = int(w * scale)
|
| 74 |
+
new_h = self.H
|
| 75 |
+
if new_w > self.W:
|
| 76 |
+
scale = self.W / w
|
| 77 |
+
new_h = int(h * scale)
|
| 78 |
+
new_w = self.W
|
| 79 |
+
resized = cv2.resize(gray, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4)
|
| 80 |
+
canvas = np.ones((self.H, self.W), dtype=np.uint8) * 255
|
| 81 |
+
y_off = (self.H - new_h) // 2
|
| 82 |
+
x_off = (self.W - new_w) // 2
|
| 83 |
+
canvas[y_off:y_off + new_h, x_off:x_off + new_w] = resized
|
| 84 |
+
return canvas
|
| 85 |
+
|
| 86 |
+
def _binarize(self, img):
|
| 87 |
+
_, otsu = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
| 88 |
+
white_ratio = np.mean(otsu == 255)
|
| 89 |
+
if white_ratio < 0.30 or white_ratio > 0.97:
|
| 90 |
+
return cv2.adaptiveThreshold(
|
| 91 |
+
img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
| 92 |
+
cv2.THRESH_BINARY, 11, 2)
|
| 93 |
+
return otsu
|
| 94 |
+
|
| 95 |
+
def normalize(self, img):
|
| 96 |
+
if len(img.shape) == 3:
|
| 97 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 98 |
+
else:
|
| 99 |
+
gray = img.copy()
|
| 100 |
+
gray = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)
|
| 101 |
+
gray = self._crop_to_text(gray)
|
| 102 |
+
gray = self._smart_resize_gray(gray)
|
| 103 |
+
return self._binarize(gray)
|
| 104 |
+
|
| 105 |
+
def to_tensor(self, img):
|
| 106 |
+
return torch.FloatTensor(
|
| 107 |
+
img.astype(np.float32) / 255.0
|
| 108 |
+
).unsqueeze(0).unsqueeze(0)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def greedy_decode(outputs, idx_to_char):
|
| 112 |
+
pred_indices = torch.argmax(outputs, dim=2).permute(1, 0)
|
| 113 |
+
results = []
|
| 114 |
+
for seq in pred_indices:
|
| 115 |
+
chars, prev = [], -1
|
| 116 |
+
for idx in seq:
|
| 117 |
+
idx = idx.item()
|
| 118 |
+
if idx != 0 and idx != prev and idx in idx_to_char:
|
| 119 |
+
chars.append(idx_to_char[idx])
|
| 120 |
+
prev = idx
|
| 121 |
+
results.append(''.join(chars))
|
| 122 |
+
return results
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def measure_live_cer(model, idx_to_char, img_h, img_w,
|
| 126 |
+
ann_file, data_dir, device, max_samples=200):
|
| 127 |
+
if not os.path.exists(ann_file):
|
| 128 |
+
return None, 0, f"Annotation file not found: {ann_file}"
|
| 129 |
+
|
| 130 |
+
with open(ann_file, 'r', encoding='utf-8') as f:
|
| 131 |
+
annotations = json.load(f)
|
| 132 |
+
|
| 133 |
+
if len(annotations) > max_samples:
|
| 134 |
+
random.seed(42)
|
| 135 |
+
annotations = random.sample(annotations, max_samples)
|
| 136 |
+
|
| 137 |
+
normalizer = AdaptiveImageNormalizer(img_h, img_w)
|
| 138 |
+
model.eval()
|
| 139 |
+
|
| 140 |
+
total_char_dist = 0
|
| 141 |
+
total_chars = 0
|
| 142 |
+
total_word_dist = 0
|
| 143 |
+
total_words = 0
|
| 144 |
+
n_exact = 0
|
| 145 |
+
n_evaluated = 0
|
| 146 |
+
worst_errors = []
|
| 147 |
+
|
| 148 |
+
with torch.no_grad():
|
| 149 |
+
for ann in annotations:
|
| 150 |
+
img_path = os.path.join(data_dir, ann['image_path'])
|
| 151 |
+
gt = ann['text']
|
| 152 |
+
if not os.path.exists(img_path):
|
| 153 |
+
continue
|
| 154 |
+
try:
|
| 155 |
+
raw = cv2.imread(img_path)
|
| 156 |
+
if raw is None:
|
| 157 |
+
continue
|
| 158 |
+
norm = normalizer.normalize(raw)
|
| 159 |
+
tensor = normalizer.to_tensor(norm).to(device)
|
| 160 |
+
out = model(tensor)
|
| 161 |
+
pred = greedy_decode(out.cpu(), idx_to_char)[0]
|
| 162 |
+
|
| 163 |
+
cd = editdistance.eval(pred, gt)
|
| 164 |
+
wd = editdistance.eval(pred.split(), gt.split())
|
| 165 |
+
|
| 166 |
+
total_char_dist += cd
|
| 167 |
+
total_chars += len(gt)
|
| 168 |
+
total_word_dist += wd
|
| 169 |
+
total_words += len(gt.split())
|
| 170 |
+
if pred == gt:
|
| 171 |
+
n_exact += 1
|
| 172 |
+
if cd > 0:
|
| 173 |
+
worst_errors.append((gt, pred, cd))
|
| 174 |
+
n_evaluated += 1
|
| 175 |
+
except Exception:
|
| 176 |
+
continue
|
| 177 |
+
|
| 178 |
+
if n_evaluated == 0:
|
| 179 |
+
return None, 0, "No images could be evaluated"
|
| 180 |
+
|
| 181 |
+
cer = (total_char_dist / total_chars * 100) if total_chars > 0 else 0
|
| 182 |
+
wer = (total_word_dist / total_words * 100) if total_words > 0 else 0
|
| 183 |
+
acc = (n_exact / n_evaluated * 100)
|
| 184 |
+
|
| 185 |
+
return {
|
| 186 |
+
'cer': cer, 'wer': wer, 'exact_match': acc,
|
| 187 |
+
'n_evaluated': n_evaluated,
|
| 188 |
+
'errors': sorted(worst_errors, key=lambda x: x[2], reverse=True)[:5]
|
| 189 |
+
}, n_evaluated, None
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def run_on_folder(model, idx_to_char, img_h, img_w, folder, device):
|
| 193 |
+
normalizer = AdaptiveImageNormalizer(img_h, img_w)
|
| 194 |
+
model.eval()
|
| 195 |
+
exts = {'.jpg', '.jpeg', '.png', '.bmp'}
|
| 196 |
+
paths = sorted(p for p in Path(folder).rglob('*') if p.suffix.lower() in exts)
|
| 197 |
+
results = []
|
| 198 |
+
with torch.no_grad():
|
| 199 |
+
for p in paths:
|
| 200 |
+
try:
|
| 201 |
+
raw = cv2.imread(str(p))
|
| 202 |
+
norm = normalizer.normalize(raw)
|
| 203 |
+
tensor = normalizer.to_tensor(norm).to(device)
|
| 204 |
+
pred = greedy_decode(model(tensor).cpu(), idx_to_char)[0]
|
| 205 |
+
results.append((p.name, pred))
|
| 206 |
+
except Exception as e:
|
| 207 |
+
results.append((p.name, f'ERROR: {e}'))
|
| 208 |
+
return results
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 212 |
+
# MAIN
|
| 213 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 214 |
+
|
| 215 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 216 |
+
|
| 217 |
+
if USE_SAVED:
|
| 218 |
+
print("=" * 65)
|
| 219 |
+
print(" SAVED CER (training-time value — may not reflect real accuracy)")
|
| 220 |
+
print(" Run without --saved for true live CER.")
|
| 221 |
+
print("=" * 65)
|
| 222 |
+
print("{:<8} {:<12} {:<12} {}".format("Epoch", "CER(%)", "WER(%)", "File"))
|
| 223 |
+
print("-" * 65)
|
| 224 |
+
best_cer, best_cp = float('inf'), None
|
| 225 |
+
for cp in CHECKPOINTS:
|
| 226 |
+
path = os.path.join(CHECKPOINT_DIR, cp)
|
| 227 |
+
if not os.path.exists(path):
|
| 228 |
+
continue
|
| 229 |
+
try:
|
| 230 |
+
c = torch.load(path, weights_only=False)
|
| 231 |
+
cer = c.get('val_cer', c.get('val_loss', 0))
|
| 232 |
+
epoch = c['epoch']
|
| 233 |
+
history = c.get('history', {})
|
| 234 |
+
wer_list = history.get('val_wer', [])
|
| 235 |
+
wer = wer_list[epoch - 1] if wer_list and epoch <= len(wer_list) else None
|
| 236 |
+
wer_s = f"{wer:.4f}%" if wer else 'N/A'
|
| 237 |
+
marker = ' <-- BEST' if cer < best_cer else ''
|
| 238 |
+
print("{:<8} {:<12} {:<12} {}{}".format(
|
| 239 |
+
epoch, f"{cer:.4f}%", wer_s, cp, marker))
|
| 240 |
+
if cer < best_cer:
|
| 241 |
+
best_cer, best_cp = cer, cp
|
| 242 |
+
except Exception as e:
|
| 243 |
+
print(f" Could not load {cp}: {e}")
|
| 244 |
+
print("=" * 65)
|
| 245 |
+
print(f"\nBEST: {best_cp} CER={best_cer:.4f}%")
|
| 246 |
+
|
| 247 |
+
else:
|
| 248 |
+
print("=" * 78)
|
| 249 |
+
print(" LIVE CER — model actually runs on images (true accuracy)")
|
| 250 |
+
print("=" * 78)
|
| 251 |
+
print("{:<8} {:<10} {:<10} {:<12} {:<8} {}".format(
|
| 252 |
+
"Epoch", "CER(%)", "WER(%)", "ExactMatch", "N", "File"))
|
| 253 |
+
print("-" * 78)
|
| 254 |
+
|
| 255 |
+
best_cer, best_cp, best_metrics = float('inf'), None, None
|
| 256 |
+
|
| 257 |
+
for cp in CHECKPOINTS:
|
| 258 |
+
cp_path = os.path.join(CHECKPOINT_DIR, cp)
|
| 259 |
+
if not os.path.exists(cp_path):
|
| 260 |
+
print(f" (skipping {cp} — not found)")
|
| 261 |
+
continue
|
| 262 |
+
try:
|
| 263 |
+
from crnn_model import get_crnn_model
|
| 264 |
+
c = torch.load(cp_path, map_location=device, weights_only=False)
|
| 265 |
+
epoch = c['epoch']
|
| 266 |
+
idx_to_char = c['idx_to_char']
|
| 267 |
+
config = c.get('config', {})
|
| 268 |
+
img_h = config.get('img_height', 64)
|
| 269 |
+
img_w = config.get('img_width', 512)
|
| 270 |
+
saved_cer = c.get('val_cer', c.get('val_loss', None))
|
| 271 |
+
|
| 272 |
+
model = get_crnn_model(
|
| 273 |
+
model_type=config.get('model_type', 'standard'),
|
| 274 |
+
img_height=img_h,
|
| 275 |
+
num_chars=c['model_state_dict']['fc.weight'].shape[0],
|
| 276 |
+
hidden_size=config.get('hidden_size', 128), # FIXED: was 256
|
| 277 |
+
num_lstm_layers=config.get('num_lstm_layers', 1) # FIXED: was 2
|
| 278 |
+
).to(device)
|
| 279 |
+
model.load_state_dict(c['model_state_dict'])
|
| 280 |
+
|
| 281 |
+
if IMAGE_DIR:
|
| 282 |
+
print(f"\nPredictions from {cp}:")
|
| 283 |
+
for fname, pred in run_on_folder(
|
| 284 |
+
model, idx_to_char, img_h, img_w, IMAGE_DIR, device):
|
| 285 |
+
print(f" {fname:<35} -> {pred}")
|
| 286 |
+
continue
|
| 287 |
+
|
| 288 |
+
metrics, n, err = measure_live_cer(
|
| 289 |
+
model, idx_to_char, img_h, img_w,
|
| 290 |
+
VAL_ANN_FILE, VAL_DATA_DIR, device)
|
| 291 |
+
|
| 292 |
+
if metrics is None:
|
| 293 |
+
print(f" Epoch {epoch} SKIP: {err}")
|
| 294 |
+
continue
|
| 295 |
+
|
| 296 |
+
cer = metrics['cer']
|
| 297 |
+
marker = ' <-- BEST' if cer < best_cer else ''
|
| 298 |
+
print("{:<8} {:<10} {:<10} {:<12} {:<8} {}{}".format(
|
| 299 |
+
epoch,
|
| 300 |
+
f"{cer:.2f}%",
|
| 301 |
+
f"{metrics['wer']:.2f}%",
|
| 302 |
+
f"{metrics['exact_match']:.1f}%",
|
| 303 |
+
n, cp, marker))
|
| 304 |
+
|
| 305 |
+
if saved_cer and abs(cer - saved_cer) > 2.0:
|
| 306 |
+
print(f" ^ MISMATCH: saved={saved_cer:.2f}% live={cer:.2f}%"
|
| 307 |
+
f" diff={abs(cer - saved_cer):.2f}%")
|
| 308 |
+
print(f" Cause: model trained on clean synthetic only.")
|
| 309 |
+
print(f" Fix: regenerate data with fix_data.py + retrain.")
|
| 310 |
+
|
| 311 |
+
if cer < best_cer:
|
| 312 |
+
best_cer, best_cp, best_metrics = cer, cp, metrics
|
| 313 |
+
|
| 314 |
+
except Exception as e:
|
| 315 |
+
print(f" Could not evaluate {cp}: {e}")
|
| 316 |
+
|
| 317 |
+
if not IMAGE_DIR:
|
| 318 |
+
print("=" * 78)
|
| 319 |
+
print(f"\nBEST CHECKPOINT : {best_cp}")
|
| 320 |
+
print(f"BEST LIVE CER : {best_cer:.4f}%")
|
| 321 |
+
|
| 322 |
+
if best_metrics and best_metrics['errors']:
|
| 323 |
+
print(f"\nWorst predictions (GT -> Predicted):")
|
| 324 |
+
for gt, pred, dist in best_metrics['errors']:
|
| 325 |
+
print(f" [{dist:2d}] '{gt}'")
|
| 326 |
+
print(f" '{pred}'")
|
| 327 |
+
|
| 328 |
+
print(f"\nTo use best model:")
|
| 329 |
+
print(f" import shutil")
|
| 330 |
+
print(f" shutil.copy('checkpoints/{best_cp}', 'checkpoints/best_model.pth')")
|
| 331 |
+
print("=" * 78)
|
compare_checkpoints.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import sys
|
| 3 |
+
sys.path.append('.')
|
| 4 |
+
from crnn_model import get_crnn_model
|
| 5 |
+
|
| 6 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 7 |
+
|
| 8 |
+
def test_model(path, label):
|
| 9 |
+
c = torch.load(path, map_location=device, weights_only=False)
|
| 10 |
+
config = c.get('config', {})
|
| 11 |
+
model = get_crnn_model(
|
| 12 |
+
model_type = config.get('model_type', 'standard'),
|
| 13 |
+
img_height = config.get('img_height', 64),
|
| 14 |
+
num_chars = c['model_state_dict']['fc.weight'].shape[0],
|
| 15 |
+
hidden_size = config.get('hidden_size', 128),
|
| 16 |
+
num_lstm_layers = config.get('num_lstm_layers', 1),
|
| 17 |
+
).to(device)
|
| 18 |
+
model.load_state_dict(c['model_state_dict'], strict=False)
|
| 19 |
+
epoch = c.get('epoch', 'N/A')
|
| 20 |
+
val_loss = c.get('val_loss', None) # fine-tuned checkpoints (EMNIST, IAM)
|
| 21 |
+
val_cer = c.get('val_cer', None) # synthetic baseline checkpoint
|
| 22 |
+
if val_loss is not None:
|
| 23 |
+
metric_str = f"val_loss={val_loss:.4f}"
|
| 24 |
+
elif val_cer is not None:
|
| 25 |
+
metric_str = f"val_cer={val_cer:.4f}%"
|
| 26 |
+
else:
|
| 27 |
+
metric_str = "no metric saved"
|
| 28 |
+
print(f"{label}: epoch={epoch} {metric_str}")
|
| 29 |
+
|
| 30 |
+
print("=" * 55)
|
| 31 |
+
test_model('checkpoints/best_model.pth', 'Synthetic ')
|
| 32 |
+
test_model('checkpoints/best_model_emnist.pth', 'EMNIST ')
|
| 33 |
+
test_model('checkpoints/best_model_iam.pth', 'IAM ')
|
| 34 |
+
print("=" * 55)
|
compare_live_cer.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
compare_live_cer.py
|
| 3 |
+
===================
|
| 4 |
+
Runs live CER on all three checkpoints to find the best one.
|
| 5 |
+
Usage: python compare_live_cer.py
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import sys
|
| 10 |
+
import json
|
| 11 |
+
import random
|
| 12 |
+
import cv2
|
| 13 |
+
import numpy as np
|
| 14 |
+
import editdistance
|
| 15 |
+
import torch
|
| 16 |
+
import torch.nn.functional as F
|
| 17 |
+
sys.path.append('.')
|
| 18 |
+
from crnn_model import get_crnn_model
|
| 19 |
+
|
| 20 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 21 |
+
|
| 22 |
+
VAL_ANN = 'data/val_annotations.json'
|
| 23 |
+
VAL_DIR = 'data/val'
|
| 24 |
+
MAX_SAMPLES = 200
|
| 25 |
+
|
| 26 |
+
CHECKPOINTS = {
|
| 27 |
+
'Synthetic' : 'checkpoints/best_model.pth',
|
| 28 |
+
'EMNIST' : 'checkpoints/best_model_emnist.pth',
|
| 29 |
+
'IAM' : 'checkpoints/best_model_iam.pth',
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def normalize(img, H=64, W=512):
|
| 34 |
+
if len(img.shape) == 3:
|
| 35 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 36 |
+
else:
|
| 37 |
+
gray = img.copy()
|
| 38 |
+
gray = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)
|
| 39 |
+
inv = cv2.bitwise_not(gray)
|
| 40 |
+
_, thresh = cv2.threshold(inv, 20, 255, cv2.THRESH_BINARY)
|
| 41 |
+
coords = np.column_stack(np.where(thresh > 0))
|
| 42 |
+
if len(coords) > 0:
|
| 43 |
+
y_min, x_min = coords.min(axis=0)
|
| 44 |
+
y_max, x_max = coords.max(axis=0)
|
| 45 |
+
pad = max(4, int((y_max - y_min) * 0.15))
|
| 46 |
+
y_min = max(0, y_min - pad)
|
| 47 |
+
x_min = max(0, x_min - pad)
|
| 48 |
+
y_max = min(gray.shape[0]-1, y_max + pad)
|
| 49 |
+
x_max = min(gray.shape[1]-1, x_max + pad)
|
| 50 |
+
gray = gray[y_min:y_max+1, x_min:x_max+1]
|
| 51 |
+
h, w = gray.shape
|
| 52 |
+
if h == 0 or w == 0:
|
| 53 |
+
return np.ones((H, W), dtype=np.uint8) * 255
|
| 54 |
+
scale = H / h
|
| 55 |
+
new_w = int(w * scale)
|
| 56 |
+
if new_w > W:
|
| 57 |
+
scale = W / w
|
| 58 |
+
new_w = W
|
| 59 |
+
new_h = int(h * scale)
|
| 60 |
+
else:
|
| 61 |
+
new_h = H
|
| 62 |
+
resized = cv2.resize(gray, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4)
|
| 63 |
+
canvas = np.ones((H, W), dtype=np.uint8) * 255
|
| 64 |
+
canvas[(H-new_h)//2:(H-new_h)//2+new_h,
|
| 65 |
+
(W-new_w)//2:(W-new_w)//2+new_w] = resized
|
| 66 |
+
_, otsu = cv2.threshold(canvas, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
| 67 |
+
return otsu
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def greedy_decode(outputs, idx_to_char):
|
| 71 |
+
pred_indices = torch.argmax(outputs, dim=2).permute(1, 0)
|
| 72 |
+
results = []
|
| 73 |
+
for seq in pred_indices:
|
| 74 |
+
chars, prev = [], -1
|
| 75 |
+
for idx in seq:
|
| 76 |
+
idx = idx.item()
|
| 77 |
+
if idx != 0 and idx != prev and idx in idx_to_char:
|
| 78 |
+
chars.append(idx_to_char[idx])
|
| 79 |
+
prev = idx
|
| 80 |
+
results.append(''.join(chars))
|
| 81 |
+
return results
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def evaluate(checkpoint_path, label):
|
| 85 |
+
if not os.path.exists(checkpoint_path):
|
| 86 |
+
print(f" {label:<12}: FILE NOT FOUND — skipping")
|
| 87 |
+
return
|
| 88 |
+
|
| 89 |
+
c = torch.load(checkpoint_path, map_location=device, weights_only=False)
|
| 90 |
+
config = c.get('config', {})
|
| 91 |
+
|
| 92 |
+
# Load idx_to_char from checkpoint if available
|
| 93 |
+
idx_to_char = c.get('idx_to_char', None)
|
| 94 |
+
if idx_to_char is None:
|
| 95 |
+
from dataset import build_char_maps
|
| 96 |
+
_, idx_to_char, _ = build_char_maps()
|
| 97 |
+
|
| 98 |
+
model = get_crnn_model(
|
| 99 |
+
model_type = config.get('model_type', 'standard'),
|
| 100 |
+
img_height = config.get('img_height', 64),
|
| 101 |
+
num_chars = c['model_state_dict']['fc.weight'].shape[0],
|
| 102 |
+
hidden_size = config.get('hidden_size', 128),
|
| 103 |
+
num_lstm_layers = config.get('num_lstm_layers', 1),
|
| 104 |
+
).to(device)
|
| 105 |
+
model.load_state_dict(c['model_state_dict'], strict=False)
|
| 106 |
+
model.eval()
|
| 107 |
+
|
| 108 |
+
with open(VAL_ANN, 'r', encoding='utf-8') as f:
|
| 109 |
+
anns = json.load(f)
|
| 110 |
+
random.seed(42)
|
| 111 |
+
if len(anns) > MAX_SAMPLES:
|
| 112 |
+
anns = random.sample(anns, MAX_SAMPLES)
|
| 113 |
+
|
| 114 |
+
total_cd, total_c = 0, 0
|
| 115 |
+
exact, n = 0, 0
|
| 116 |
+
worst = []
|
| 117 |
+
|
| 118 |
+
with torch.no_grad():
|
| 119 |
+
for ann in anns:
|
| 120 |
+
img_path = os.path.join(VAL_DIR, ann['image_path'])
|
| 121 |
+
gt = ann['text']
|
| 122 |
+
if not os.path.exists(img_path):
|
| 123 |
+
continue
|
| 124 |
+
raw = cv2.imread(img_path)
|
| 125 |
+
if raw is None:
|
| 126 |
+
continue
|
| 127 |
+
norm = normalize(raw)
|
| 128 |
+
tensor = torch.FloatTensor(
|
| 129 |
+
norm.astype(np.float32) / 255.0
|
| 130 |
+
).unsqueeze(0).unsqueeze(0).to(device)
|
| 131 |
+
out = model(tensor)
|
| 132 |
+
pred = greedy_decode(out.cpu(), idx_to_char)[0]
|
| 133 |
+
cd = editdistance.eval(pred, gt)
|
| 134 |
+
total_cd += cd
|
| 135 |
+
total_c += len(gt)
|
| 136 |
+
if pred == gt:
|
| 137 |
+
exact += 1
|
| 138 |
+
if cd > 0:
|
| 139 |
+
worst.append((gt, pred, cd))
|
| 140 |
+
n += 1
|
| 141 |
+
|
| 142 |
+
cer = (total_cd / total_c * 100) if total_c > 0 else 0
|
| 143 |
+
acc = (exact / n * 100) if n > 0 else 0
|
| 144 |
+
print(f" {label:<12}: CER={cer:.2f}% ExactMatch={acc:.1f}% (n={n})")
|
| 145 |
+
|
| 146 |
+
if worst:
|
| 147 |
+
worst = sorted(worst, key=lambda x: x[2], reverse=True)[:2]
|
| 148 |
+
for gt, pred, d in worst:
|
| 149 |
+
print(f" [{d}] '{gt}' -> '{pred}'")
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
print("=" * 60)
|
| 153 |
+
print(" LIVE CER COMPARISON — all checkpoints")
|
| 154 |
+
print("=" * 60)
|
| 155 |
+
for label, path in CHECKPOINTS.items():
|
| 156 |
+
evaluate(path, label)
|
| 157 |
+
print("=" * 60)
|
| 158 |
+
print("Use the checkpoint with the lowest CER for IAM/physical fine-tuning.")
|
create_test_images.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 3 |
+
|
| 4 |
+
os.makedirs('test_images', exist_ok=True)
|
| 5 |
+
|
| 6 |
+
def load_font(size=22): # FIXED: was 20 — must match fix_data.py FONT_SIZE=22
|
| 7 |
+
"""Same font loader as fix_data.py — tries multiple paths."""
|
| 8 |
+
for fp in [
|
| 9 |
+
'arial.ttf', 'Arial.ttf',
|
| 10 |
+
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
|
| 11 |
+
'/System/Library/Fonts/Helvetica.ttc',
|
| 12 |
+
'C:/Windows/Fonts/arial.ttf',
|
| 13 |
+
]:
|
| 14 |
+
try:
|
| 15 |
+
return ImageFont.truetype(fp, size)
|
| 16 |
+
except Exception:
|
| 17 |
+
continue
|
| 18 |
+
print("WARNING: Could not load Arial/DejaVu font. Using default — predictions may be inaccurate.")
|
| 19 |
+
return ImageFont.load_default()
|
| 20 |
+
|
| 21 |
+
def create_image(text, filename):
|
| 22 |
+
"""Render text exactly the same way as fix_data.py training images."""
|
| 23 |
+
img = Image.new('RGB', (512, 64), color=(255, 255, 255))
|
| 24 |
+
draw = ImageDraw.Draw(img)
|
| 25 |
+
font = load_font(22)
|
| 26 |
+
|
| 27 |
+
bbox = draw.textbbox((0, 0), text, font=font)
|
| 28 |
+
x = max((512 - (bbox[2] - bbox[0])) // 2, 2)
|
| 29 |
+
y = max((64 - (bbox[3] - bbox[1])) // 2, 2)
|
| 30 |
+
draw.text((x, y), text, fill=(0, 0, 0), font=font)
|
| 31 |
+
img.save(filename)
|
| 32 |
+
print(f'Created: {filename}')
|
| 33 |
+
|
| 34 |
+
# ── Test samples ──────────────────────────────────────────────
|
| 35 |
+
create_image('Juan Dela Cruz', 'test_images/demo.jpg')
|
| 36 |
+
create_image('Juan Dela Cruz', 'test_images/name1.jpg')
|
| 37 |
+
create_image('01/15/1990', 'test_images/date1.jpg')
|
| 38 |
+
create_image('Tarlac City', 'test_images/place1.jpg')
|
| 39 |
+
create_image('Maria Santos', 'test_images/form1a_sample.jpg')
|
| 40 |
+
|
| 41 |
+
# ── Extra test cases (names, dates, addresses) ────────────────
|
| 42 |
+
create_image('Jose Dela Cruz Jr.', 'test_images/name2.jpg')
|
| 43 |
+
create_image('Ana Marie Reyes', 'test_images/name3.jpg')
|
| 44 |
+
create_image('03/22/1985', 'test_images/date2.jpg')
|
| 45 |
+
create_image('07/04/2000', 'test_images/date3.jpg')
|
| 46 |
+
create_image('Brgy. San Jose, Capas, Tarlac', 'test_images/place2.jpg')
|
| 47 |
+
create_image('78 MacArthur Hwy., Tarlac City', 'test_images/place3.jpg')
|
| 48 |
+
|
| 49 |
+
print('\nAll test images created!')
|
| 50 |
+
print('Font used matches training data — predictions should be accurate.')
|
crnn_model.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CRNN+CTC Model — simplified for small datasets (~5000-10000 samples)
|
| 3 |
+
~700K parameters, converges reliably without CTC blank collapse.
|
| 4 |
+
"""
|
| 5 |
+
import torch
|
| 6 |
+
import torch.nn as nn
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class CRNN_CivilRegistry(nn.Module):
|
| 10 |
+
|
| 11 |
+
def __init__(self, img_height=64, num_chars=96, hidden_size=128, num_lstm_layers=1,
|
| 12 |
+
dropout=0.3):
|
| 13 |
+
super().__init__()
|
| 14 |
+
|
| 15 |
+
# CNN — width reductions for 512px input:
|
| 16 |
+
# MaxPool(2,2): 512→256, MaxPool(2,2): 256→128
|
| 17 |
+
# MaxPool(2,1): 128 (height only), MaxPool(2,1): 128 (height only)
|
| 18 |
+
# Conv(k=2,p=0): 127 → seq_len=127, fits labels up to 64 chars
|
| 19 |
+
self.cnn = nn.Sequential(
|
| 20 |
+
nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True),
|
| 21 |
+
nn.MaxPool2d(2, 2),
|
| 22 |
+
|
| 23 |
+
nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True),
|
| 24 |
+
nn.MaxPool2d(2, 2),
|
| 25 |
+
|
| 26 |
+
nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True),
|
| 27 |
+
nn.MaxPool2d((2, 1)),
|
| 28 |
+
|
| 29 |
+
nn.Conv2d(128, 256, 3, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True),
|
| 30 |
+
nn.MaxPool2d((2, 1)),
|
| 31 |
+
|
| 32 |
+
nn.Conv2d(256, 256, kernel_size=2, padding=0),
|
| 33 |
+
nn.BatchNorm2d(256), nn.ReLU(inplace=True),
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# FIXED Bug 4: derive cnn_out_h from a real forward pass instead of
|
| 37 |
+
# a hardcoded formula — safer if architecture or img_height ever changes.
|
| 38 |
+
with torch.no_grad():
|
| 39 |
+
_dummy = torch.zeros(1, 1, img_height, 32)
|
| 40 |
+
_out = self.cnn(_dummy)
|
| 41 |
+
cnn_out_h = _out.shape[2] # actual height after all CNN layers
|
| 42 |
+
rnn_input = 256 * cnn_out_h
|
| 43 |
+
|
| 44 |
+
self.rnn = nn.LSTM(
|
| 45 |
+
input_size=rnn_input,
|
| 46 |
+
hidden_size=hidden_size,
|
| 47 |
+
num_layers=num_lstm_layers,
|
| 48 |
+
bidirectional=True,
|
| 49 |
+
batch_first=False,
|
| 50 |
+
)
|
| 51 |
+
# Dropout before FC — prevents overfitting on small datasets.
|
| 52 |
+
# Applied after BiLSTM output, before character projection.
|
| 53 |
+
# p=0.3 is standard for CRNN OCR models (disabled at inference via model.eval()).
|
| 54 |
+
self.dropout = nn.Dropout(p=dropout)
|
| 55 |
+
self.fc = nn.Linear(hidden_size * 2, num_chars)
|
| 56 |
+
|
| 57 |
+
def forward(self, x):
|
| 58 |
+
f = self.cnn(x)
|
| 59 |
+
B, C, h, w = f.size()
|
| 60 |
+
f = f.permute(3, 0, 1, 2).reshape(w, B, C * h)
|
| 61 |
+
f, _ = self.rnn(f)
|
| 62 |
+
return self.fc(self.dropout(f))
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class CRNN_Ensemble(nn.Module):
|
| 66 |
+
def __init__(self, num_models=3, **kwargs):
|
| 67 |
+
super().__init__()
|
| 68 |
+
self.models = nn.ModuleList([CRNN_CivilRegistry(**kwargs) for _ in range(num_models)])
|
| 69 |
+
|
| 70 |
+
def forward(self, x):
|
| 71 |
+
# FIXED Rec 3: average softmax probabilities across models (correct ensemble),
|
| 72 |
+
# then return log of the average so CTCLoss receives log-probabilities —
|
| 73 |
+
# the same contract as CRNN_CivilRegistry (raw logits + log_softmax in trainer).
|
| 74 |
+
# Returning raw averaged probabilities caused CTCLoss to receive un-logged values.
|
| 75 |
+
probs = [torch.nn.functional.softmax(m(x), dim=2) for m in self.models]
|
| 76 |
+
avg_probs = torch.mean(torch.stack(probs), dim=0)
|
| 77 |
+
return torch.log(avg_probs.clamp(min=1e-9)) # log-probs, safe clamp avoids log(0)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def get_crnn_model(model_type='standard', **kwargs):
|
| 81 |
+
if model_type == 'ensemble':
|
| 82 |
+
return CRNN_Ensemble(**kwargs)
|
| 83 |
+
return CRNN_CivilRegistry(**kwargs)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def initialize_weights(model):
|
| 87 |
+
for m in model.modules():
|
| 88 |
+
if isinstance(m, nn.Conv2d):
|
| 89 |
+
nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
|
| 90 |
+
if m.bias is not None:
|
| 91 |
+
nn.init.constant_(m.bias, 0)
|
| 92 |
+
elif isinstance(m, nn.BatchNorm2d):
|
| 93 |
+
nn.init.constant_(m.weight, 1)
|
| 94 |
+
nn.init.constant_(m.bias, 0)
|
| 95 |
+
elif isinstance(m, nn.Linear):
|
| 96 |
+
nn.init.normal_(m.weight, 0, 0.01)
|
| 97 |
+
nn.init.constant_(m.bias, 0)
|
| 98 |
+
elif isinstance(m, nn.LSTM):
|
| 99 |
+
for name, param in m.named_parameters():
|
| 100 |
+
if 'weight' in name:
|
| 101 |
+
nn.init.orthogonal_(param)
|
| 102 |
+
elif 'bias' in name:
|
| 103 |
+
nn.init.constant_(param, 0)
|
| 104 |
+
# Rec 1: set forget gate bias to 1.0 — helps the model
|
| 105 |
+
# remember across long sequences at the start of training.
|
| 106 |
+
# LSTM gate order: [input | forget | cell | output]
|
| 107 |
+
n = param.size(0)
|
| 108 |
+
param.data[n // 4 : n // 2].fill_(1.0)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
if __name__ == "__main__":
|
| 112 |
+
model = get_crnn_model('standard', img_height=64, num_chars=96, hidden_size=128, num_lstm_layers=1)
|
| 113 |
+
initialize_weights(model)
|
| 114 |
+
x = torch.randn(2, 1, 64, 512)
|
| 115 |
+
out = model(x)
|
| 116 |
+
params = sum(p.numel() for p in model.parameters())
|
| 117 |
+
print(f"Output: {out.shape} seq_len={out.shape[0]}")
|
| 118 |
+
print(f"Params: {params:,} (unchanged — dropout adds no parameters)")
|
| 119 |
+
print(f"Dropout p=0.3 active during training, disabled during model.eval()")
|
dataset.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
dataset.py
|
| 3 |
+
==========
|
| 4 |
+
PyTorch Dataset and DataLoader utilities for the Civil Registry OCR system.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import json
|
| 9 |
+
import random
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import List, Tuple, Dict, Optional
|
| 12 |
+
|
| 13 |
+
import cv2
|
| 14 |
+
import numpy as np
|
| 15 |
+
import torch
|
| 16 |
+
from torch.utils.data import Dataset
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 20 |
+
# CHARACTER SET
|
| 21 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 22 |
+
|
| 23 |
+
PRINTABLE_CHARS = [chr(i) for i in range(32, 127)] # space (32) to ~ (126)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def build_char_maps(extra_chars: Optional[List[str]] = None):
|
| 27 |
+
chars = PRINTABLE_CHARS.copy()
|
| 28 |
+
if extra_chars:
|
| 29 |
+
for c in extra_chars:
|
| 30 |
+
if c not in chars:
|
| 31 |
+
chars.append(c)
|
| 32 |
+
char_to_idx = {c: i + 1 for i, c in enumerate(chars)}
|
| 33 |
+
idx_to_char = {i + 1: c for i, c in enumerate(chars)}
|
| 34 |
+
num_chars = len(chars) + 1 # +1 for blank=0
|
| 35 |
+
return char_to_idx, idx_to_char, num_chars
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 39 |
+
# IMAGE NORMALIZER
|
| 40 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 41 |
+
|
| 42 |
+
class ImageNormalizer:
|
| 43 |
+
|
| 44 |
+
def __init__(self, target_height: int = 64, target_width: int = 512):
|
| 45 |
+
self.H = target_height
|
| 46 |
+
self.W = target_width
|
| 47 |
+
|
| 48 |
+
def _to_gray(self, img):
|
| 49 |
+
if len(img.shape) == 3:
|
| 50 |
+
return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 51 |
+
return img.copy()
|
| 52 |
+
|
| 53 |
+
def _crop_to_text(self, gray):
|
| 54 |
+
inv = cv2.bitwise_not(gray)
|
| 55 |
+
_, thresh = cv2.threshold(inv, 20, 255, cv2.THRESH_BINARY)
|
| 56 |
+
coords = np.column_stack(np.where(thresh > 0))
|
| 57 |
+
if len(coords) == 0:
|
| 58 |
+
return gray
|
| 59 |
+
y_min, x_min = coords.min(axis=0)
|
| 60 |
+
y_max, x_max = coords.max(axis=0)
|
| 61 |
+
pad = max(4, int((y_max - y_min) * 0.15))
|
| 62 |
+
y_min = max(0, y_min - pad)
|
| 63 |
+
x_min = max(0, x_min - pad)
|
| 64 |
+
y_max = min(gray.shape[0] - 1, y_max + pad)
|
| 65 |
+
x_max = min(gray.shape[1] - 1, x_max + pad)
|
| 66 |
+
return gray[y_min:y_max + 1, x_min:x_max + 1]
|
| 67 |
+
|
| 68 |
+
def _aspect_resize(self, gray):
|
| 69 |
+
h, w = gray.shape
|
| 70 |
+
if h == 0 or w == 0:
|
| 71 |
+
return np.ones((self.H, self.W), dtype=np.uint8) * 255
|
| 72 |
+
scale = self.H / h
|
| 73 |
+
new_w = int(w * scale)
|
| 74 |
+
new_h = self.H
|
| 75 |
+
if new_w > self.W:
|
| 76 |
+
scale = self.W / w
|
| 77 |
+
new_h = int(h * scale)
|
| 78 |
+
new_w = self.W
|
| 79 |
+
resized = cv2.resize(gray, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4)
|
| 80 |
+
canvas = np.ones((self.H, self.W), dtype=np.uint8) * 255
|
| 81 |
+
y_off = (self.H - new_h) // 2
|
| 82 |
+
x_off = (self.W - new_w) // 2
|
| 83 |
+
canvas[y_off:y_off + new_h, x_off:x_off + new_w] = resized
|
| 84 |
+
return canvas
|
| 85 |
+
|
| 86 |
+
def _binarize(self, img):
|
| 87 |
+
_, otsu = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
| 88 |
+
white_ratio = np.mean(otsu == 255)
|
| 89 |
+
if white_ratio < 0.30 or white_ratio > 0.97:
|
| 90 |
+
return cv2.adaptiveThreshold(
|
| 91 |
+
img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
| 92 |
+
cv2.THRESH_BINARY, 11, 2)
|
| 93 |
+
return otsu
|
| 94 |
+
|
| 95 |
+
def normalize(self, img: np.ndarray, augmenter=None) -> np.ndarray:
|
| 96 |
+
gray = self._to_gray(img)
|
| 97 |
+
# NOTE: fastNlMeansDenoising intentionally removed from training pipeline.
|
| 98 |
+
# It is slow (~200ms/image) and pointless on clean synthetic images.
|
| 99 |
+
# Denoising is only applied in check_cer.py / inference.py (AdaptiveNormalizer)
|
| 100 |
+
# which runs on real scanned documents where denoising actually helps.
|
| 101 |
+
gray = self._crop_to_text(gray)
|
| 102 |
+
gray = self._aspect_resize(gray)
|
| 103 |
+
# FIXED Bug 3: augment on grayscale BEFORE binarize.
|
| 104 |
+
# Brightness/contrast augmentation has zero effect on binary (0/255) pixels.
|
| 105 |
+
if augmenter is not None:
|
| 106 |
+
gray = augmenter(gray)
|
| 107 |
+
return self._binarize(gray)
|
| 108 |
+
|
| 109 |
+
def to_tensor(self, img: np.ndarray) -> torch.Tensor:
|
| 110 |
+
return torch.FloatTensor(
|
| 111 |
+
img.astype(np.float32) / 255.0
|
| 112 |
+
).unsqueeze(0) # [1, H, W]
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 116 |
+
# AUGMENTATION
|
| 117 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 118 |
+
|
| 119 |
+
class Augmenter:
|
| 120 |
+
|
| 121 |
+
def __call__(self, img: np.ndarray) -> np.ndarray:
|
| 122 |
+
img = img.copy()
|
| 123 |
+
|
| 124 |
+
# Random slight rotation (±3°)
|
| 125 |
+
if random.random() < 0.3:
|
| 126 |
+
angle = random.uniform(-3, 3)
|
| 127 |
+
h, w = img.shape
|
| 128 |
+
M = cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0)
|
| 129 |
+
img = cv2.warpAffine(img, M, (w, h),
|
| 130 |
+
borderMode=cv2.BORDER_CONSTANT,
|
| 131 |
+
borderValue=255)
|
| 132 |
+
|
| 133 |
+
# Random brightness/contrast
|
| 134 |
+
if random.random() < 0.4:
|
| 135 |
+
alpha = random.uniform(0.8, 1.2)
|
| 136 |
+
beta = random.randint(-20, 20)
|
| 137 |
+
img = np.clip(alpha * img.astype(np.float32) + beta,
|
| 138 |
+
0, 255).astype(np.uint8)
|
| 139 |
+
|
| 140 |
+
# Gaussian blur
|
| 141 |
+
if random.random() < 0.3:
|
| 142 |
+
ksize = random.choice([3, 5])
|
| 143 |
+
img = cv2.GaussianBlur(img, (ksize, ksize), 0)
|
| 144 |
+
|
| 145 |
+
# Salt-and-pepper noise
|
| 146 |
+
if random.random() < 0.2:
|
| 147 |
+
noise = np.random.randint(0, 100, img.shape)
|
| 148 |
+
img[noise < 2] = 0
|
| 149 |
+
img[noise > 97] = 255
|
| 150 |
+
|
| 151 |
+
# Random small horizontal shift
|
| 152 |
+
if random.random() < 0.2:
|
| 153 |
+
h, w = img.shape
|
| 154 |
+
shift = random.randint(-int(w * 0.05), int(w * 0.05))
|
| 155 |
+
M = np.float32([[1, 0, shift], [0, 1, 0]])
|
| 156 |
+
img = cv2.warpAffine(img, M, (w, h),
|
| 157 |
+
borderMode=cv2.BORDER_CONSTANT,
|
| 158 |
+
borderValue=255)
|
| 159 |
+
|
| 160 |
+
# ── NEW: Horizontal line noise ────────────────────────────────────────
|
| 161 |
+
# Simulates ruled form lines bleeding through behind the text.
|
| 162 |
+
# Civil registry forms have printed horizontal grid lines — scanners
|
| 163 |
+
# often pick these up as faint grey stripes across text fields.
|
| 164 |
+
if random.random() < 0.3:
|
| 165 |
+
h, w = img.shape
|
| 166 |
+
n_lines = random.randint(1, 3)
|
| 167 |
+
for _ in range(n_lines):
|
| 168 |
+
y = random.randint(0, h - 1)
|
| 169 |
+
thickness = random.choice([1, 1, 1, 2]) # mostly 1px
|
| 170 |
+
intensity = random.randint(160, 220) # light grey, not black
|
| 171 |
+
cv2.line(img, (0, y), (w, y),
|
| 172 |
+
color=intensity, thickness=thickness)
|
| 173 |
+
|
| 174 |
+
# ── NEW: Perspective warp ─────────────────────────────────────────────
|
| 175 |
+
# Simulates documents scanned or photographed at a slight angle.
|
| 176 |
+
# Keystone distortion is common when forms are placed unevenly on
|
| 177 |
+
# a flatbed scanner or photographed with a phone camera.
|
| 178 |
+
if random.random() < 0.25:
|
| 179 |
+
h, w = img.shape
|
| 180 |
+
d = 0.03
|
| 181 |
+
dx = int(w * d)
|
| 182 |
+
dy = int(h * d)
|
| 183 |
+
src = np.float32([[0, 0], [w, 0], [w, h], [0, h]])
|
| 184 |
+
dst = np.float32([
|
| 185 |
+
[random.randint(0, dx), random.randint(0, dy)],
|
| 186 |
+
[w - random.randint(0, dx), random.randint(0, dy)],
|
| 187 |
+
[w - random.randint(0, dx), h - random.randint(0, dy)],
|
| 188 |
+
[random.randint(0, dx), h - random.randint(0, dy)],
|
| 189 |
+
])
|
| 190 |
+
M = cv2.getPerspectiveTransform(src, dst)
|
| 191 |
+
img = cv2.warpPerspective(img, M, (w, h),
|
| 192 |
+
borderMode=cv2.BORDER_CONSTANT,
|
| 193 |
+
borderValue=255)
|
| 194 |
+
|
| 195 |
+
return img
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 199 |
+
# DATASET
|
| 200 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 201 |
+
|
| 202 |
+
class CivilRegistryDataset(Dataset):
|
| 203 |
+
"""
|
| 204 |
+
Args:
|
| 205 |
+
data_dir : root folder containing image subfolders (e.g. 'data/train')
|
| 206 |
+
annotations_file : path to JSON file with image_path + text pairs
|
| 207 |
+
img_height : target image height (default 64)
|
| 208 |
+
img_width : target image width (default 512)
|
| 209 |
+
augment : True = apply augmentation (training only)
|
| 210 |
+
form_type : 'all' or filter by form e.g. 'form1a'
|
| 211 |
+
|
| 212 |
+
Properties used by train.py:
|
| 213 |
+
.num_chars → passed to CRNN model
|
| 214 |
+
.char_to_idx → saved in checkpoint
|
| 215 |
+
.idx_to_char → used for decoding predictions
|
| 216 |
+
|
| 217 |
+
__getitem__ returns:
|
| 218 |
+
image_tensor FloatTensor [1, H, W]
|
| 219 |
+
target LongTensor [label_length]
|
| 220 |
+
target_length int
|
| 221 |
+
text str (original ground truth)
|
| 222 |
+
"""
|
| 223 |
+
|
| 224 |
+
def __init__(
|
| 225 |
+
self,
|
| 226 |
+
data_dir: str,
|
| 227 |
+
annotations_file: str,
|
| 228 |
+
img_height: int = 64,
|
| 229 |
+
img_width: int = 512,
|
| 230 |
+
augment: bool = False,
|
| 231 |
+
form_type: str = 'all',
|
| 232 |
+
seed: Optional[int] = None, # Rec 2: reproducible augmentation
|
| 233 |
+
):
|
| 234 |
+
self.data_dir = Path(data_dir)
|
| 235 |
+
self.augment = augment
|
| 236 |
+
self.normalizer = ImageNormalizer(img_height, img_width)
|
| 237 |
+
self.augmenter = Augmenter()
|
| 238 |
+
if seed is not None: # Rec 2: seed random for reproducibility
|
| 239 |
+
random.seed(seed)
|
| 240 |
+
np.random.seed(seed)
|
| 241 |
+
|
| 242 |
+
self.char_to_idx, self.idx_to_char, self.num_chars = build_char_maps()
|
| 243 |
+
|
| 244 |
+
with open(annotations_file, 'r', encoding='utf-8') as f:
|
| 245 |
+
all_annotations = json.load(f)
|
| 246 |
+
|
| 247 |
+
if form_type != 'all':
|
| 248 |
+
all_annotations = [
|
| 249 |
+
a for a in all_annotations
|
| 250 |
+
if form_type in a.get('image_path', '')
|
| 251 |
+
]
|
| 252 |
+
|
| 253 |
+
self.samples: List[Dict] = []
|
| 254 |
+
missing = 0
|
| 255 |
+
for ann in all_annotations:
|
| 256 |
+
img_path = self.data_dir / ann['image_path']
|
| 257 |
+
if img_path.exists():
|
| 258 |
+
text = ann['text'].strip()
|
| 259 |
+
if text:
|
| 260 |
+
self.samples.append({
|
| 261 |
+
'image_path': str(img_path),
|
| 262 |
+
'text': text,
|
| 263 |
+
})
|
| 264 |
+
else:
|
| 265 |
+
missing += 1
|
| 266 |
+
|
| 267 |
+
if missing > 0:
|
| 268 |
+
print(f" [Dataset] WARNING: {missing} image(s) not found and skipped.")
|
| 269 |
+
|
| 270 |
+
print(f" [Dataset] Loaded {len(self.samples)} samples "
|
| 271 |
+
f"from {annotations_file} (augment={augment})")
|
| 272 |
+
|
| 273 |
+
def __len__(self) -> int:
|
| 274 |
+
return len(self.samples)
|
| 275 |
+
|
| 276 |
+
def __getitem__(self, idx: int):
|
| 277 |
+
sample = self.samples[idx]
|
| 278 |
+
text = sample['text']
|
| 279 |
+
|
| 280 |
+
img = cv2.imread(sample['image_path'])
|
| 281 |
+
if img is None:
|
| 282 |
+
img = np.ones((64, 512, 3), dtype=np.uint8) * 255
|
| 283 |
+
|
| 284 |
+
# FIXED Bug 3: pass augmenter into normalize() so it runs on grayscale
|
| 285 |
+
# (before binarization), not on the binary output where it has no effect.
|
| 286 |
+
aug = self.augmenter if self.augment else None
|
| 287 |
+
normalized = self.normalizer.normalize(img, augmenter=aug)
|
| 288 |
+
|
| 289 |
+
image_tensor = self.normalizer.to_tensor(normalized) # [1, H, W]
|
| 290 |
+
|
| 291 |
+
encoded = [
|
| 292 |
+
self.char_to_idx[c]
|
| 293 |
+
for c in text
|
| 294 |
+
if c in self.char_to_idx
|
| 295 |
+
]
|
| 296 |
+
if len(encoded) == 0:
|
| 297 |
+
encoded = [self.char_to_idx.get(' ', 1)]
|
| 298 |
+
|
| 299 |
+
target = torch.LongTensor(encoded)
|
| 300 |
+
target_length = len(encoded)
|
| 301 |
+
|
| 302 |
+
return image_tensor, target, target_length, text
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 306 |
+
# COLLATE FUNCTION
|
| 307 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 308 |
+
|
| 309 |
+
def collate_fn(batch):
|
| 310 |
+
"""
|
| 311 |
+
CTC loss needs all labels packed into one flat 1D tensor.
|
| 312 |
+
PyTorch's default collator can't handle variable-length labels,
|
| 313 |
+
so this custom function packs them correctly.
|
| 314 |
+
|
| 315 |
+
Returns:
|
| 316 |
+
images FloatTensor [B, 1, H, W]
|
| 317 |
+
targets LongTensor [sum of all label lengths]
|
| 318 |
+
target_lengths LongTensor [B]
|
| 319 |
+
texts List[str]
|
| 320 |
+
"""
|
| 321 |
+
images, targets, target_lengths, texts = zip(*batch)
|
| 322 |
+
|
| 323 |
+
images = torch.stack(images, dim=0)
|
| 324 |
+
targets = torch.cat([t for t in targets])
|
| 325 |
+
target_lengths = torch.LongTensor(target_lengths)
|
| 326 |
+
|
| 327 |
+
return images, targets, target_lengths, list(texts)
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 331 |
+
# HELPER: CREATE ANNOTATION FILE (run once to build your JSON)
|
| 332 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 333 |
+
|
| 334 |
+
def create_annotation_file(data_dir: str, output_file: str,
|
| 335 |
+
extensions=('.jpg', '.jpeg', '.png')):
|
| 336 |
+
"""
|
| 337 |
+
Auto-generate annotations JSON by scanning data_dir.
|
| 338 |
+
For each image, looks for a sidecar .txt file with the same name.
|
| 339 |
+
If not found, uses the filename stem (underscores → spaces) as label.
|
| 340 |
+
|
| 341 |
+
Usage:
|
| 342 |
+
from dataset import create_annotation_file
|
| 343 |
+
create_annotation_file('data/train', 'data/train_annotations.json')
|
| 344 |
+
create_annotation_file('data/val', 'data/val_annotations.json')
|
| 345 |
+
"""
|
| 346 |
+
data_path = Path(data_dir)
|
| 347 |
+
annotations = []
|
| 348 |
+
|
| 349 |
+
for img_path in sorted(data_path.rglob('*')):
|
| 350 |
+
if img_path.suffix.lower() not in extensions:
|
| 351 |
+
continue
|
| 352 |
+
txt_path = img_path.with_suffix('.txt')
|
| 353 |
+
if txt_path.exists():
|
| 354 |
+
label = txt_path.read_text(encoding='utf-8').strip()
|
| 355 |
+
else:
|
| 356 |
+
label = img_path.stem.replace('_', ' ')
|
| 357 |
+
if not label:
|
| 358 |
+
continue
|
| 359 |
+
rel_path = img_path.relative_to(data_path)
|
| 360 |
+
annotations.append({
|
| 361 |
+
'image_path': str(rel_path).replace('\\', '/'),
|
| 362 |
+
'text': label,
|
| 363 |
+
})
|
| 364 |
+
|
| 365 |
+
os.makedirs(Path(output_file).parent, exist_ok=True)
|
| 366 |
+
with open(output_file, 'w', encoding='utf-8') as f:
|
| 367 |
+
json.dump(annotations, f, indent=2, ensure_ascii=False)
|
| 368 |
+
|
| 369 |
+
print(f"✓ Saved {len(annotations)} entries → {output_file}")
|
| 370 |
+
return annotations
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 374 |
+
# SELF-TEST (python dataset.py)
|
| 375 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 376 |
+
|
| 377 |
+
if __name__ == '__main__':
|
| 378 |
+
print("=" * 55)
|
| 379 |
+
print(" dataset.py self-test")
|
| 380 |
+
print("=" * 55)
|
| 381 |
+
|
| 382 |
+
c2i, i2c, n = build_char_maps()
|
| 383 |
+
print(f"\n Vocab size : {n} (including blank=0)")
|
| 384 |
+
print(f" 'A'={c2i['A']} '0'={c2i['0']} ' '={c2i[' ']} '.'={c2i['.']}")
|
| 385 |
+
|
| 386 |
+
dummy = np.ones((80, 300, 3), dtype=np.uint8) * 200
|
| 387 |
+
norm = ImageNormalizer(64, 512)
|
| 388 |
+
out = norm.normalize(dummy)
|
| 389 |
+
t = norm.to_tensor(out)
|
| 390 |
+
print(f"\n Normalizer : {dummy.shape} → {out.shape} → tensor {t.shape}")
|
| 391 |
+
|
| 392 |
+
fake = [
|
| 393 |
+
(torch.zeros(1, 64, 512), torch.LongTensor([1, 2, 3]), 3, "ABC"),
|
| 394 |
+
(torch.zeros(1, 64, 512), torch.LongTensor([4, 5]), 2, "DE"),
|
| 395 |
+
(torch.zeros(1, 64, 512), torch.LongTensor([6, 7, 8, 9]), 4, "FGHI"),
|
| 396 |
+
]
|
| 397 |
+
imgs, tgts, tlens, txts = collate_fn(fake)
|
| 398 |
+
print(f"\n collate_fn : images={imgs.shape} "
|
| 399 |
+
f"targets={tgts.shape} lengths={tlens.tolist()}")
|
| 400 |
+
|
| 401 |
+
print("\n ✓ All checks passed.\n")
|
debug_and_retrain.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
import matplotlib.pyplot as plt
|
| 3 |
+
|
| 4 |
+
# Load and show the image
|
| 5 |
+
img = cv2.imread('your_image.png')
|
| 6 |
+
plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
|
| 7 |
+
plt.title('Original Image')
|
| 8 |
+
plt.show()
|
| 9 |
+
|
| 10 |
+
# Preprocess and show
|
| 11 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 12 |
+
thresh = cv2.threshold(gray, 128, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1]
|
| 13 |
+
plt.imshow(thresh, cmap='gray')
|
| 14 |
+
plt.title('Thresholded Image')
|
| 15 |
+
plt.show()
|
| 16 |
+
|
| 17 |
+
# Run OCR and print output
|
| 18 |
+
import pytesseract
|
| 19 |
+
text = pytesseract.image_to_string(thresh)
|
| 20 |
+
print("OCR Output:", text)
|
extract_actual_data.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
extract_actual_data.py
|
| 3 |
+
======================
|
| 4 |
+
Extract field crops from actual scanned civil registry forms and
|
| 5 |
+
auto-label them with EasyOCR as a starting point for CRNN fine-tuning.
|
| 6 |
+
|
| 7 |
+
Reads images from:
|
| 8 |
+
actual_images/{form_type}/*.{png,jpg,jpeg}
|
| 9 |
+
|
| 10 |
+
For each image:
|
| 11 |
+
1. Aligns to reference using ORB + ECC + corner fallback
|
| 12 |
+
2. Crops every field defined in TEMPLATES
|
| 13 |
+
3. Applies CLAHE per-crop before auto-labeling
|
| 14 |
+
4. Saves crop to data/actual_crops/
|
| 15 |
+
5. Auto-labels with EasyOCR + field-type post-processing
|
| 16 |
+
|
| 17 |
+
Output:
|
| 18 |
+
data/actual_crops/ -- field crop images
|
| 19 |
+
data/actual_annotations.json -- labels for fine-tuning
|
| 20 |
+
|
| 21 |
+
After running:
|
| 22 |
+
- Open actual_annotations.json
|
| 23 |
+
- Fix any wrong 'text' values
|
| 24 |
+
- Run finetune.py to train
|
| 25 |
+
|
| 26 |
+
Usage:
|
| 27 |
+
cd python/CRNN+CTC
|
| 28 |
+
python extract_actual_data.py
|
| 29 |
+
|
| 30 |
+
# or point to a different images folder:
|
| 31 |
+
python extract_actual_data.py --images /path/to/actual_images
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
import os
|
| 35 |
+
import sys
|
| 36 |
+
import json
|
| 37 |
+
import argparse
|
| 38 |
+
import numpy as np
|
| 39 |
+
from PIL import Image
|
| 40 |
+
|
| 41 |
+
# ── Paths ─────────────────────────────────────────────────────
|
| 42 |
+
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 43 |
+
ROOT_DIR = os.path.dirname(os.path.dirname(THIS_DIR)) # project root
|
| 44 |
+
PYTHON_DIR = os.path.dirname(THIS_DIR) # python/
|
| 45 |
+
|
| 46 |
+
sys.path.insert(0, PYTHON_DIR)
|
| 47 |
+
|
| 48 |
+
from template_matcher import (
|
| 49 |
+
TEMPLATES, REFERENCE_IMAGES,
|
| 50 |
+
align_to_reference, _preprocess, _crop_field,
|
| 51 |
+
_get_easyocr, _postprocess,
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
try:
|
| 55 |
+
import cv2 as _cv2
|
| 56 |
+
_CV2_OK = True
|
| 57 |
+
except ImportError:
|
| 58 |
+
_CV2_OK = False
|
| 59 |
+
|
| 60 |
+
CROPS_DIR = os.path.join(THIS_DIR, 'data', 'actual_crops')
|
| 61 |
+
ANN_PATH = os.path.join(THIS_DIR, 'data', 'actual_annotations.json')
|
| 62 |
+
MIN_CROP_W = 10
|
| 63 |
+
MIN_CROP_H = 6
|
| 64 |
+
|
| 65 |
+
# Substrings that indicate a file is a debug/test output, not a real scan
|
| 66 |
+
_SKIP_SUBSTRINGS = ('debug', 'aligned', 'crops_aligned')
|
| 67 |
+
_SKIP_PREFIXES = ('test_', 'father_', 'father2_')
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _is_scan(fname: str) -> bool:
|
| 71 |
+
base = fname.lower()
|
| 72 |
+
ext = os.path.splitext(base)[1]
|
| 73 |
+
if ext not in ('.png', '.jpg', '.jpeg', '.tiff', '.bmp'):
|
| 74 |
+
return False
|
| 75 |
+
if any(s in base for s in _SKIP_SUBSTRINGS):
|
| 76 |
+
return False
|
| 77 |
+
if any(base.startswith(p) for p in _SKIP_PREFIXES):
|
| 78 |
+
return False
|
| 79 |
+
return True
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _ocr_crop(arr: np.ndarray, reader) -> str:
|
| 83 |
+
"""Run EasyOCR on a uint8 RGB numpy array."""
|
| 84 |
+
try:
|
| 85 |
+
results = reader.readtext(arr, detail=0, paragraph=True)
|
| 86 |
+
return ' '.join(results).strip()
|
| 87 |
+
except Exception as e:
|
| 88 |
+
return ''
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def process_image(img_path: str, form_type: str, reader, crops_dir: str) -> list:
|
| 92 |
+
"""
|
| 93 |
+
Align one scan, crop every template field, save crops, auto-label.
|
| 94 |
+
Returns list of annotation dicts for this image.
|
| 95 |
+
"""
|
| 96 |
+
template = TEMPLATES[form_type]
|
| 97 |
+
fname = os.path.basename(img_path)
|
| 98 |
+
stem = os.path.splitext(fname)[0]
|
| 99 |
+
|
| 100 |
+
try:
|
| 101 |
+
img = Image.open(img_path).convert('RGB')
|
| 102 |
+
except Exception as e:
|
| 103 |
+
print(f' [skip] Cannot open: {e}')
|
| 104 |
+
return []
|
| 105 |
+
|
| 106 |
+
w, h = img.size
|
| 107 |
+
print(f' Processing {fname} ({w}x{h})...')
|
| 108 |
+
|
| 109 |
+
# Align (ORB → ECC → corner → resize)
|
| 110 |
+
img, orb_inliers = align_to_reference(img, form_type)
|
| 111 |
+
print(f' ORB inliers: {orb_inliers}')
|
| 112 |
+
|
| 113 |
+
# Grayscale + deskew
|
| 114 |
+
processed = _preprocess(img)
|
| 115 |
+
|
| 116 |
+
annotations = []
|
| 117 |
+
|
| 118 |
+
for field_name, coords in template.items():
|
| 119 |
+
x1r, y1r, x2r, y2r, _ = coords
|
| 120 |
+
crop = _crop_field(processed, x1r, y1r, x2r, y2r)
|
| 121 |
+
|
| 122 |
+
if crop is None or crop.size[0] < MIN_CROP_W or crop.size[1] < MIN_CROP_H:
|
| 123 |
+
continue
|
| 124 |
+
|
| 125 |
+
# CLAHE per-crop before OCR (same as extract_fields in template_matcher)
|
| 126 |
+
gray = np.array(crop.convert('L'))
|
| 127 |
+
if _CV2_OK:
|
| 128 |
+
clahe = _cv2.createCLAHE(clipLimit=1.5, tileGridSize=(2, 2))
|
| 129 |
+
gray = clahe.apply(gray)
|
| 130 |
+
arr = np.stack([gray, gray, gray], axis=-1)
|
| 131 |
+
|
| 132 |
+
raw = _ocr_crop(arr, reader)
|
| 133 |
+
label = _postprocess(raw, field_name)
|
| 134 |
+
|
| 135 |
+
crop_fname = f'{form_type}_{stem}_{field_name}.png'
|
| 136 |
+
crop.save(os.path.join(crops_dir, crop_fname))
|
| 137 |
+
|
| 138 |
+
annotations.append({
|
| 139 |
+
'image_path': os.path.join('data', 'actual_crops', crop_fname),
|
| 140 |
+
'text': label,
|
| 141 |
+
'form_type': form_type,
|
| 142 |
+
'field': field_name,
|
| 143 |
+
'source_img': fname,
|
| 144 |
+
})
|
| 145 |
+
|
| 146 |
+
print(f' Saved {len(annotations)} crops')
|
| 147 |
+
return annotations
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def main(images_root: str):
|
| 151 |
+
os.makedirs(CROPS_DIR, exist_ok=True)
|
| 152 |
+
|
| 153 |
+
print('[extract] Loading EasyOCR...')
|
| 154 |
+
reader = _get_easyocr()
|
| 155 |
+
if reader is None:
|
| 156 |
+
print('[extract] ERROR: EasyOCR failed to load.')
|
| 157 |
+
sys.exit(1)
|
| 158 |
+
print('[extract] EasyOCR ready.')
|
| 159 |
+
|
| 160 |
+
all_annotations = []
|
| 161 |
+
|
| 162 |
+
for form_type in sorted(TEMPLATES.keys()):
|
| 163 |
+
folder = os.path.join(images_root, form_type)
|
| 164 |
+
if not os.path.isdir(folder):
|
| 165 |
+
print(f'\n[extract] No images in {folder}, skipping.')
|
| 166 |
+
continue
|
| 167 |
+
|
| 168 |
+
scans = sorted(f for f in os.listdir(folder) if _is_scan(f))
|
| 169 |
+
if not scans:
|
| 170 |
+
print(f'\n[extract] No scan images in {folder}, skipping.')
|
| 171 |
+
continue
|
| 172 |
+
|
| 173 |
+
ref = REFERENCE_IMAGES.get(form_type, '')
|
| 174 |
+
if not os.path.exists(ref):
|
| 175 |
+
print(f'\n[extract] WARNING: No reference image for form {form_type} — alignment will be resize-only')
|
| 176 |
+
|
| 177 |
+
print(f'\n[extract] Form {form_type} — {len(scans)} image(s)')
|
| 178 |
+
|
| 179 |
+
for fname in scans:
|
| 180 |
+
anns = process_image(os.path.join(folder, fname), form_type, reader, CROPS_DIR)
|
| 181 |
+
all_annotations.extend(anns)
|
| 182 |
+
|
| 183 |
+
with open(ANN_PATH, 'w', encoding='utf-8') as f:
|
| 184 |
+
json.dump(all_annotations, f, indent=2, ensure_ascii=False)
|
| 185 |
+
|
| 186 |
+
total = len(all_annotations)
|
| 187 |
+
print(f'\n[extract] Done.')
|
| 188 |
+
print(f' Crops saved : {total}')
|
| 189 |
+
print(f' Annotations : {ANN_PATH}')
|
| 190 |
+
print()
|
| 191 |
+
print('Review actual_annotations.json and correct any wrong labels,')
|
| 192 |
+
print('then run finetune.py to train on this data.')
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
if __name__ == '__main__':
|
| 196 |
+
parser = argparse.ArgumentParser()
|
| 197 |
+
parser.add_argument(
|
| 198 |
+
'--images',
|
| 199 |
+
default=os.path.join(ROOT_DIR, 'actual_images'),
|
| 200 |
+
help='Path to actual_images/ folder (default: <project_root>/actual_images)',
|
| 201 |
+
)
|
| 202 |
+
args = parser.parse_args()
|
| 203 |
+
main(args.images)
|
field_extractor.py
ADDED
|
@@ -0,0 +1,735 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Philippine Civil Registry — Field Extractor (Dynamic)
|
| 3 |
+
======================================================
|
| 4 |
+
Automatically detects form borders on ANY scan/photo and aligns field
|
| 5 |
+
extraction to the detected boundary — no hardcoded pixel positions.
|
| 6 |
+
|
| 7 |
+
Field coordinates calibrated directly from official PDF renders at 200 DPI:
|
| 8 |
+
Form 102 (Birth): 1700 x 2800 px
|
| 9 |
+
Form 103 (Death): 1700 x 2878 px
|
| 10 |
+
Form 97 (Marriage): 1700 x 2600 px
|
| 11 |
+
Form 90 (License): 1700 x 2600 px
|
| 12 |
+
|
| 13 |
+
Usage:
|
| 14 |
+
python field_extractor.py --pdf FORM_102.pdf --form birth
|
| 15 |
+
python field_extractor.py --pdf FORM_97.pdf --form marriage --visualize
|
| 16 |
+
python field_extractor.py --pdf FORM_103.pdf --form death --output results.json
|
| 17 |
+
python field_extractor.py --image form102.png --form birth --visualize
|
| 18 |
+
python field_extractor.py --pdf FORM_102.pdf --form birth --checkpoint checkpoints/best_model_emnist.pth
|
| 19 |
+
|
| 20 |
+
.env file (project root) — each team member sets their own:
|
| 21 |
+
POPPLER_PATH=C:\\your\\path\\to\\poppler\\Library\\bin
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import argparse
|
| 25 |
+
import os
|
| 26 |
+
import sys
|
| 27 |
+
import json
|
| 28 |
+
import cv2
|
| 29 |
+
import numpy as np
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
|
| 32 |
+
import torch
|
| 33 |
+
from dotenv import load_dotenv
|
| 34 |
+
|
| 35 |
+
# Load .env from same folder as this script (works regardless of cwd)
|
| 36 |
+
_script_dir = Path(__file__).parent.resolve()
|
| 37 |
+
load_dotenv(dotenv_path=_script_dir / ".env")
|
| 38 |
+
|
| 39 |
+
# Poppler path — from .env or None (Linux/Mac auto-detects)
|
| 40 |
+
POPPLER_PATH = os.environ.get("POPPLER_PATH", None)
|
| 41 |
+
DEFAULT_CHECKPOINT = "checkpoints/best_model.pth"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 45 |
+
# FIELD RATIO MAPS
|
| 46 |
+
# Format: field_name: (x1, y1, x2, y2) — ratios 0.0–1.0
|
| 47 |
+
# Coordinates are relative to the DETECTED FORM BOUNDARY (not full image).
|
| 48 |
+
# x = left→right, y = top→bottom
|
| 49 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 50 |
+
|
| 51 |
+
# Form 102 → Certificate of Live Birth (Form 1A)
|
| 52 |
+
BIRTH_FIELDS = {
|
| 53 |
+
# Header
|
| 54 |
+
"province": (0.02, 0.068, 0.30, 0.088),
|
| 55 |
+
"registry_number": (0.66, 0.068, 0.99, 0.108),
|
| 56 |
+
"city_municipality": (0.02, 0.090, 0.65, 0.108),
|
| 57 |
+
|
| 58 |
+
# Item 1 — Child Name
|
| 59 |
+
"child_first_name": (0.03, 0.109, 0.40, 0.141),
|
| 60 |
+
"child_middle_name": (0.40, 0.109, 0.64, 0.141),
|
| 61 |
+
"child_last_name": (0.64, 0.109, 0.99, 0.141),
|
| 62 |
+
|
| 63 |
+
# Items 2-3 — Sex / Date of Birth
|
| 64 |
+
"sex": (0.03, 0.142, 0.30, 0.167),
|
| 65 |
+
"dob_day": (0.40, 0.142, 0.80, 0.167),
|
| 66 |
+
"dob_month": (0.80, 0.142, 0.60, 0.167),
|
| 67 |
+
"dob_year": (0.80, 0.142, 0.99, 0.167),
|
| 68 |
+
|
| 69 |
+
# Item 4 — Place of Birth
|
| 70 |
+
"place_birth_hospital": (0.03, 0.169, 0.46, 0.197),
|
| 71 |
+
"place_birth_city": (0.47, 0.169, 0.70, 0.199),
|
| 72 |
+
"place_birth_province": (0.71, 0.169, 0.99, 0.199),
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# Mother section
|
| 77 |
+
"mother_first_name": (0.03, 0.248, 0.40, 0.276),
|
| 78 |
+
"mother_middle_name": (0.40, 0.248, 0.64, 0.276),
|
| 79 |
+
"mother_last_name": (0.64, 0.248, 0.99, 0.276),
|
| 80 |
+
"mother_citizenship": (0.03, 0.277, 0.50, 0.305),
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# Father section
|
| 84 |
+
"father_first_name": (0.03, 0.380, 0.40, 0.410),
|
| 85 |
+
"father_middle_name": (0.40, 0.380, 0.64, 0.410),
|
| 86 |
+
"father_last_name": (0.64, 0.380, 0.99, 0.410),
|
| 87 |
+
"father_citizenship": (0.03, 0.411, 0.28, 0.445),
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# Item 20 — Marriage of Parents
|
| 91 |
+
"parents_marriage_month": (0.03, 0.496, 0.19, 0.526),
|
| 92 |
+
"parents_marriage_day": (0.19, 0.496, 0.27, 0.526),
|
| 93 |
+
"parents_marriage_year": (0.27, 0.496, 0.38, 0.526),
|
| 94 |
+
|
| 95 |
+
"parents_marriage_city": (0.41, 0.496, 0.68, 0.526),
|
| 96 |
+
"parents_marriage_province": (0.68, 0.496, 0.84, 0.526),
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
# Form 103 → Certificate of Death (Form 2A)
|
| 102 |
+
DEATH_FIELDS = {
|
| 103 |
+
# Header
|
| 104 |
+
"province": (0.04, 0.128, 0.40, 0.144),
|
| 105 |
+
"registry_number": (0.52, 0.128, 0.75, 0.144),
|
| 106 |
+
"city_municipality": (0.04, 0.145, 0.45, 0.160),
|
| 107 |
+
|
| 108 |
+
# Item 1 — Name
|
| 109 |
+
"deceased_first_name": (0.10, 0.162, 0.34, 0.178),
|
| 110 |
+
"deceased_middle_name": (0.34, 0.162, 0.56, 0.178),
|
| 111 |
+
"deceased_last_name": (0.56, 0.162, 0.75, 0.178),
|
| 112 |
+
|
| 113 |
+
# Items 2-4 — Sex / Religion / Age
|
| 114 |
+
"sex": (0.04, 0.182, 0.13, 0.220),
|
| 115 |
+
"age_years": (0.28, 0.182, 0.38, 0.202),
|
| 116 |
+
|
| 117 |
+
# Item 5 — Place of Death
|
| 118 |
+
"place_death_hospital": (0.13, 0.224, 0.42, 0.242),
|
| 119 |
+
"place_death_city": (0.42, 0.224, 0.58, 0.242),
|
| 120 |
+
"place_death_province": (0.58, 0.224, 0.75, 0.242),
|
| 121 |
+
|
| 122 |
+
# Items 6-7 — Date of Death / Citizenship
|
| 123 |
+
"dod_day": (0.10, 0.252, 0.22, 0.268),
|
| 124 |
+
"dod_month": (0.22, 0.252, 0.38, 0.268),
|
| 125 |
+
"dod_year": (0.38, 0.252, 0.52, 0.268),
|
| 126 |
+
"citizenship": (0.52, 0.252, 0.75, 0.268),
|
| 127 |
+
|
| 128 |
+
# Item 8 — Residence
|
| 129 |
+
"residence_house": (0.13, 0.278, 0.40, 0.294),
|
| 130 |
+
"residence_city": (0.40, 0.278, 0.56, 0.294),
|
| 131 |
+
"residence_province": (0.56, 0.278, 0.75, 0.294),
|
| 132 |
+
|
| 133 |
+
# Items 9-10 — Civil Status / Occupation
|
| 134 |
+
"civil_status": (0.04, 0.302, 0.38, 0.360),
|
| 135 |
+
"occupation": (0.44, 0.302, 0.75, 0.360),
|
| 136 |
+
|
| 137 |
+
# Item 17 — Causes of Death
|
| 138 |
+
"cause_immediate": (0.18, 0.402, 0.58, 0.418),
|
| 139 |
+
"cause_antecedent": (0.18, 0.424, 0.58, 0.440),
|
| 140 |
+
"cause_underlying": (0.18, 0.446, 0.58, 0.462),
|
| 141 |
+
"cause_other": (0.18, 0.468, 0.58, 0.484),
|
| 142 |
+
|
| 143 |
+
# Item 25 — Informant
|
| 144 |
+
"informant_name": (0.04, 0.808, 0.35, 0.822),
|
| 145 |
+
"informant_address": (0.04, 0.822, 0.35, 0.836),
|
| 146 |
+
"informant_date": (0.35, 0.836, 0.58, 0.850),
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
# Form 97 → Certificate of Marriage (Form 3A)
|
| 150 |
+
# Only the fields that flow through bridge.py → spaCy NER → SpouseOutput/Form3A.
|
| 151 |
+
# Removed: province, city_municipality, dob_day/month/year (×2),
|
| 152 |
+
# place_birth_city/prov/country (×2), sex (×2), residence (×2),
|
| 153 |
+
# religion (×2), civil_status (×2).
|
| 154 |
+
MARRIAGE_FIELDS = {
|
| 155 |
+
# ── Header ───────────────────────────────────────────────────────────────
|
| 156 |
+
"registry_number": (0.62, 0.088, 0.97, 0.104), # → Form3A.registry_number
|
| 157 |
+
|
| 158 |
+
#"registry_number": (0.62, 0.088, 0.97, 0.104), # → Form3A.registry_number
|
| 159 |
+
|
| 160 |
+
# ── Item 1 — Name (HUSBAND left / WIFE right) ────────────────────────────
|
| 161 |
+
"husband_first_name": (0.23, 0.121, 0.56, 0.139),
|
| 162 |
+
"husband_middle_name": (0.23, 0.141, 0.56, 0.159),
|
| 163 |
+
"husband_last_name": (0.23, 0.160, 0.56, 0.178),
|
| 164 |
+
"wife_first_name": (0.65, 0.121, 0.98, 0.139),
|
| 165 |
+
"wife_middle_name": (0.65, 0.141, 0.98, 0.159),
|
| 166 |
+
"wife_last_name": (0.65, 0.160, 0.98, 0.178),
|
| 167 |
+
|
| 168 |
+
# "husband_first_name": (0.14, 0.138, 0.47, 0.156),
|
| 169 |
+
# "husband_middle_name": (0.14, 0.156, 0.47, 0.174),
|
| 170 |
+
# "husband_last_name": (0.14, 0.174, 0.47, 0.192),
|
| 171 |
+
# "wife_first_name": (0.53, 0.138, 0.86, 0.156),
|
| 172 |
+
# "wife_middle_name": (0.53, 0.156, 0.86, 0.174),
|
| 173 |
+
# "wife_last_name": (0.53, 0.174, 0.86, 0.192),
|
| 174 |
+
|
| 175 |
+
# ── Item 2b — Age ────────────────────────────────────────────────────────
|
| 176 |
+
"husband_age": (0.40, 0.198, 0.47, 0.216), # → husband.age
|
| 177 |
+
"wife_age": (0.78, 0.198, 0.86, 0.216), # → wife.age
|
| 178 |
+
|
| 179 |
+
# ── Item 4b — Citizenship ────────────────────────────────────────────────
|
| 180 |
+
"husband_citizenship": (0.22, 0.252, 0.47, 0.270), # → husband.nationality
|
| 181 |
+
"wife_citizenship": (0.62, 0.252, 0.86, 0.270), # → wife.nationality
|
| 182 |
+
|
| 183 |
+
# ── Item 8 — Name of Father ──────────────────────────────────────────────
|
| 184 |
+
"husband_father_first": (0.14, 0.396, 0.24, 0.414),
|
| 185 |
+
"husband_father_middle": (0.24, 0.396, 0.34, 0.414),
|
| 186 |
+
"husband_father_last": (0.34, 0.396, 0.47, 0.414),
|
| 187 |
+
"wife_father_first": (0.53, 0.396, 0.63, 0.414),
|
| 188 |
+
"wife_father_middle": (0.63, 0.396, 0.73, 0.414),
|
| 189 |
+
"wife_father_last": (0.73, 0.396, 0.86, 0.414),
|
| 190 |
+
|
| 191 |
+
# ── Item 9 — Citizenship of Father ──────────────────────────────────────
|
| 192 |
+
"husband_father_citizenship": (0.14, 0.420, 0.47, 0.436), # → husband.nationality_of_father
|
| 193 |
+
"wife_father_citizenship": (0.53, 0.420, 0.86, 0.436), # → wife.nationality_of_father
|
| 194 |
+
|
| 195 |
+
# ── Item 10 — Name of Mother ─────────────────────────────────────────────
|
| 196 |
+
"husband_mother_first": (0.14, 0.444, 0.24, 0.462),
|
| 197 |
+
"husband_mother_middle": (0.24, 0.444, 0.34, 0.462),
|
| 198 |
+
"husband_mother_last": (0.34, 0.444, 0.47, 0.462),
|
| 199 |
+
"wife_mother_first": (0.53, 0.444, 0.63, 0.462),
|
| 200 |
+
"wife_mother_middle": (0.63, 0.444, 0.73, 0.462),
|
| 201 |
+
"wife_mother_last": (0.73, 0.444, 0.86, 0.462),
|
| 202 |
+
|
| 203 |
+
# ── Item 11 — Citizenship of Mother ─────────────────────────────────────
|
| 204 |
+
"husband_mother_citizenship": (0.14, 0.468, 0.47, 0.484), # → husband.nationality_of_mother
|
| 205 |
+
"wife_mother_citizenship": (0.53, 0.468, 0.86, 0.484), # → wife.nationality_of_mother
|
| 206 |
+
|
| 207 |
+
# ── Items 15–16 — Place / Date of Marriage ───────────────────────────────
|
| 208 |
+
"place_marriage_office": (0.14, 0.596, 0.44, 0.614),
|
| 209 |
+
"place_marriage_city": (0.44, 0.596, 0.68, 0.614),
|
| 210 |
+
"place_marriage_province": (0.68, 0.596, 0.88, 0.614),
|
| 211 |
+
"date_marriage_day": (0.14, 0.630, 0.24, 0.648),
|
| 212 |
+
"date_marriage_month": (0.24, 0.630, 0.38, 0.648),
|
| 213 |
+
"date_marriage_year": (0.38, 0.630, 0.48, 0.648),
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
# Form 90 → Application for Marriage License
|
| 217 |
+
MARRIAGE_LICENSE_FIELDS = {
|
| 218 |
+
# Header
|
| 219 |
+
"province": (0.12, 0.092, 0.48, 0.108),
|
| 220 |
+
"registry_number": (0.56, 0.092, 0.97, 0.108),
|
| 221 |
+
"city_municipality": (0.12, 0.108, 0.48, 0.124),
|
| 222 |
+
"received_by": (0.12, 0.124, 0.48, 0.140),
|
| 223 |
+
"date_of_receipt": (0.12, 0.140, 0.48, 0.156),
|
| 224 |
+
"marriage_license_number": (0.56, 0.124, 0.97, 0.140),
|
| 225 |
+
"date_of_issuance": (0.56, 0.140, 0.97, 0.156),
|
| 226 |
+
|
| 227 |
+
# Item 1 — Name of Applicant (GROOM left / BRIDE right)
|
| 228 |
+
"groom_first_name": (0.02, 0.278, 0.46, 0.294),
|
| 229 |
+
"bride_first_name": (0.54, 0.278, 0.97, 0.294),
|
| 230 |
+
"groom_middle_name": (0.02, 0.296, 0.46, 0.312),
|
| 231 |
+
"bride_middle_name": (0.54, 0.296, 0.97, 0.312),
|
| 232 |
+
"groom_last_name": (0.02, 0.314, 0.46, 0.330),
|
| 233 |
+
"bride_last_name": (0.54, 0.314, 0.97, 0.330),
|
| 234 |
+
|
| 235 |
+
# Item 2 — Date of Birth / Age
|
| 236 |
+
"groom_dob_day": (0.02, 0.334, 0.12, 0.350),
|
| 237 |
+
"groom_dob_month": (0.12, 0.334, 0.24, 0.350),
|
| 238 |
+
"groom_dob_year": (0.24, 0.334, 0.34, 0.350),
|
| 239 |
+
"groom_age": (0.34, 0.334, 0.46, 0.350),
|
| 240 |
+
"bride_dob_day": (0.54, 0.334, 0.62, 0.350),
|
| 241 |
+
"bride_dob_month": (0.62, 0.334, 0.74, 0.350),
|
| 242 |
+
"bride_dob_year": (0.74, 0.334, 0.84, 0.350),
|
| 243 |
+
"bride_age": (0.84, 0.334, 0.97, 0.350),
|
| 244 |
+
|
| 245 |
+
# Item 3 — Place of Birth
|
| 246 |
+
"groom_place_birth_city": (0.02, 0.354, 0.18, 0.370),
|
| 247 |
+
"groom_place_birth_province": (0.18, 0.354, 0.32, 0.370),
|
| 248 |
+
"groom_place_birth_country": (0.32, 0.354, 0.46, 0.370),
|
| 249 |
+
"bride_place_birth_city": (0.54, 0.354, 0.70, 0.370),
|
| 250 |
+
"bride_place_birth_province": (0.70, 0.354, 0.84, 0.370),
|
| 251 |
+
"bride_place_birth_country": (0.84, 0.354, 0.97, 0.370),
|
| 252 |
+
|
| 253 |
+
# Item 4 — Sex / Citizenship
|
| 254 |
+
"groom_sex": (0.02, 0.374, 0.16, 0.390),
|
| 255 |
+
"groom_citizenship": (0.16, 0.374, 0.46, 0.390),
|
| 256 |
+
"bride_sex": (0.54, 0.374, 0.68, 0.390),
|
| 257 |
+
"bride_citizenship": (0.68, 0.374, 0.97, 0.390),
|
| 258 |
+
|
| 259 |
+
# Item 5 — Residence
|
| 260 |
+
"groom_residence": (0.02, 0.394, 0.46, 0.412),
|
| 261 |
+
"bride_residence": (0.54, 0.394, 0.97, 0.412),
|
| 262 |
+
|
| 263 |
+
# Item 6 — Religion
|
| 264 |
+
"groom_religion": (0.02, 0.424, 0.46, 0.440),
|
| 265 |
+
"bride_religion": (0.54, 0.424, 0.97, 0.440),
|
| 266 |
+
|
| 267 |
+
# Item 7 — Civil Status
|
| 268 |
+
"groom_civil_status": (0.02, 0.452, 0.46, 0.468),
|
| 269 |
+
"bride_civil_status": (0.54, 0.452, 0.97, 0.468),
|
| 270 |
+
|
| 271 |
+
# Item 9 — Place where dissolved
|
| 272 |
+
"groom_dissolution_city": (0.02, 0.496, 0.16, 0.512),
|
| 273 |
+
"groom_dissolution_province": (0.16, 0.496, 0.30, 0.512),
|
| 274 |
+
"groom_dissolution_country": (0.30, 0.496, 0.46, 0.512),
|
| 275 |
+
"bride_dissolution_city": (0.54, 0.496, 0.68, 0.512),
|
| 276 |
+
"bride_dissolution_province": (0.68, 0.496, 0.82, 0.512),
|
| 277 |
+
"bride_dissolution_country": (0.82, 0.496, 0.97, 0.512),
|
| 278 |
+
|
| 279 |
+
# Item 10 — Date when dissolved
|
| 280 |
+
"groom_dissolution_day": (0.02, 0.520, 0.12, 0.536),
|
| 281 |
+
"groom_dissolution_month": (0.12, 0.520, 0.24, 0.536),
|
| 282 |
+
"groom_dissolution_year": (0.24, 0.520, 0.34, 0.536),
|
| 283 |
+
"bride_dissolution_day": (0.54, 0.520, 0.62, 0.536),
|
| 284 |
+
"bride_dissolution_month": (0.62, 0.520, 0.74, 0.536),
|
| 285 |
+
"bride_dissolution_year": (0.74, 0.520, 0.84, 0.536),
|
| 286 |
+
|
| 287 |
+
# Item 12 — Father Name
|
| 288 |
+
"groom_father_first": (0.02, 0.594, 0.16, 0.610),
|
| 289 |
+
"groom_father_middle": (0.16, 0.594, 0.28, 0.610),
|
| 290 |
+
"groom_father_last": (0.28, 0.594, 0.46, 0.610),
|
| 291 |
+
"bride_father_first": (0.54, 0.594, 0.66, 0.610),
|
| 292 |
+
"bride_father_middle": (0.66, 0.594, 0.78, 0.610),
|
| 293 |
+
"bride_father_last": (0.78, 0.594, 0.97, 0.610),
|
| 294 |
+
|
| 295 |
+
# Item 13 — Father Citizenship
|
| 296 |
+
"groom_father_citizenship": (0.02, 0.620, 0.46, 0.636),
|
| 297 |
+
"bride_father_citizenship": (0.54, 0.620, 0.97, 0.636),
|
| 298 |
+
|
| 299 |
+
# Item 14 — Father Residence
|
| 300 |
+
"groom_father_residence": (0.02, 0.644, 0.46, 0.660),
|
| 301 |
+
"bride_father_residence": (0.54, 0.644, 0.97, 0.660),
|
| 302 |
+
|
| 303 |
+
# Item 15 — Mother Name
|
| 304 |
+
"groom_mother_first": (0.02, 0.674, 0.16, 0.690),
|
| 305 |
+
"groom_mother_middle": (0.16, 0.674, 0.28, 0.690),
|
| 306 |
+
"groom_mother_last": (0.28, 0.674, 0.46, 0.690),
|
| 307 |
+
"bride_mother_first": (0.54, 0.674, 0.66, 0.690),
|
| 308 |
+
"bride_mother_middle": (0.66, 0.674, 0.78, 0.690),
|
| 309 |
+
"bride_mother_last": (0.78, 0.674, 0.97, 0.690),
|
| 310 |
+
|
| 311 |
+
# Item 16 — Mother Citizenship
|
| 312 |
+
"groom_mother_citizenship": (0.02, 0.696, 0.46, 0.712),
|
| 313 |
+
"bride_mother_citizenship": (0.54, 0.696, 0.97, 0.712),
|
| 314 |
+
|
| 315 |
+
# Item 17 — Mother Residence
|
| 316 |
+
"groom_mother_residence": (0.02, 0.720, 0.46, 0.736),
|
| 317 |
+
"bride_mother_residence": (0.54, 0.720, 0.97, 0.736),
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
FORM_FIELDS = {
|
| 321 |
+
"birth": BIRTH_FIELDS,
|
| 322 |
+
"death": DEATH_FIELDS,
|
| 323 |
+
"marriage": MARRIAGE_FIELDS,
|
| 324 |
+
"marriage_license": MARRIAGE_LICENSE_FIELDS,
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
COLOURS = [
|
| 328 |
+
(0,200,0),(0,150,255),(200,0,200),(0,200,200),(200,200,0),(220,20,60),
|
| 329 |
+
(255,140,0),(150,50,200),(0,160,80),(30,144,255),(255,20,147),(100,200,100),
|
| 330 |
+
]
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 334 |
+
# FORM BOUNDS DETECTOR
|
| 335 |
+
# Finds the outer border of a civil registry form using line detection.
|
| 336 |
+
# Falls back to full image if detection fails.
|
| 337 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 338 |
+
|
| 339 |
+
class FormBoundsDetector:
|
| 340 |
+
def __init__(self, verbose=False):
|
| 341 |
+
self.verbose = verbose
|
| 342 |
+
|
| 343 |
+
def detect(self, image_bgr):
|
| 344 |
+
h, w = image_bgr.shape[:2]
|
| 345 |
+
gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
|
| 346 |
+
bounds = self._detect_by_lines(gray, w, h)
|
| 347 |
+
if bounds is None:
|
| 348 |
+
if self.verbose:
|
| 349 |
+
print(" [Bounds] Line detection failed — using full image")
|
| 350 |
+
return (0, 0, w, h)
|
| 351 |
+
if self.verbose:
|
| 352 |
+
print(f" [Bounds] Detected: {bounds}")
|
| 353 |
+
return bounds
|
| 354 |
+
|
| 355 |
+
def _detect_by_lines(self, gray, w, h):
|
| 356 |
+
try:
|
| 357 |
+
thresh = cv2.adaptiveThreshold(
|
| 358 |
+
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
| 359 |
+
cv2.THRESH_BINARY_INV, 11, 2)
|
| 360 |
+
hk = cv2.getStructuringElement(cv2.MORPH_RECT, (max(w // 5, 10), 1))
|
| 361 |
+
h_lines = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, hk)
|
| 362 |
+
h_rows = np.where(np.sum(h_lines, axis=1) > w * 0.15)[0]
|
| 363 |
+
vk = cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(h // 5, 10)))
|
| 364 |
+
v_lines = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, vk)
|
| 365 |
+
v_cols = np.where(np.sum(v_lines, axis=0) > h * 0.08)[0]
|
| 366 |
+
if len(h_rows) == 0 or len(v_cols) == 0:
|
| 367 |
+
return None
|
| 368 |
+
top, bottom = int(h_rows.min()), int(h_rows.max())
|
| 369 |
+
left, right = int(v_cols.min()), int(v_cols.max())
|
| 370 |
+
if (right - left) < w * 0.4 or (bottom - top) < h * 0.4:
|
| 371 |
+
return None
|
| 372 |
+
return (left, top, right, bottom)
|
| 373 |
+
except Exception as e:
|
| 374 |
+
if self.verbose:
|
| 375 |
+
print(f" [Bounds error] {e}")
|
| 376 |
+
return None
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 380 |
+
# DYNAMIC FIELD EXTRACTOR
|
| 381 |
+
# Crops each field region relative to the detected form boundary.
|
| 382 |
+
# Works on any image size, DPI, scan margin, or slight rotation.
|
| 383 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 384 |
+
|
| 385 |
+
class DynamicFieldExtractor:
|
| 386 |
+
def __init__(self, form_type="birth", verbose=False):
|
| 387 |
+
self.form_type = form_type.lower()
|
| 388 |
+
self.field_map = FORM_FIELDS.get(self.form_type, BIRTH_FIELDS)
|
| 389 |
+
self.detector = FormBoundsDetector(verbose=verbose)
|
| 390 |
+
self.verbose = verbose
|
| 391 |
+
self._last_bounds = None
|
| 392 |
+
|
| 393 |
+
def _to_bgr(self, image):
|
| 394 |
+
try:
|
| 395 |
+
from PIL import Image as PILImage
|
| 396 |
+
if isinstance(image, PILImage.Image):
|
| 397 |
+
arr = np.array(image.convert("RGB"))
|
| 398 |
+
return cv2.cvtColor(arr, cv2.COLOR_RGB2BGR)
|
| 399 |
+
except ImportError:
|
| 400 |
+
pass
|
| 401 |
+
if isinstance(image, np.ndarray):
|
| 402 |
+
if len(image.shape) == 2:
|
| 403 |
+
return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
|
| 404 |
+
if image.shape[2] == 4:
|
| 405 |
+
return cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
|
| 406 |
+
return image
|
| 407 |
+
raise TypeError(f"Unsupported image type: {type(image)}")
|
| 408 |
+
|
| 409 |
+
def extract(self, image):
|
| 410 |
+
"""Returns {field_name: BGR numpy array}."""
|
| 411 |
+
image = self._to_bgr(image)
|
| 412 |
+
h, w = image.shape[:2]
|
| 413 |
+
left, top, right, bottom = self.detector.detect(image)
|
| 414 |
+
self._last_bounds = (left, top, right, bottom)
|
| 415 |
+
form_w = right - left
|
| 416 |
+
form_h = bottom - top
|
| 417 |
+
if self.verbose:
|
| 418 |
+
print(f" [Extract] Image={w}x{h} "
|
| 419 |
+
f" Form={form_w}x{form_h} @ ({left},{top})-({right},{bottom})")
|
| 420 |
+
crops = {}
|
| 421 |
+
for name, (rx1, ry1, rx2, ry2) in self.field_map.items():
|
| 422 |
+
x1 = max(0, min(int(left + rx1 * form_w), w - 1))
|
| 423 |
+
y1 = max(0, min(int(top + ry1 * form_h), h - 1))
|
| 424 |
+
x2 = max(0, min(int(left + rx2 * form_w), w - 1))
|
| 425 |
+
y2 = max(0, min(int(top + ry2 * form_h), h - 1))
|
| 426 |
+
if x2 > x1 and y2 > y1:
|
| 427 |
+
crops[name] = image[y1:y2, x1:x2]
|
| 428 |
+
return crops
|
| 429 |
+
|
| 430 |
+
def visualize(self, image, output_path=None):
|
| 431 |
+
"""Draw detected boundary + field boxes. Returns annotated BGR image."""
|
| 432 |
+
image = self._to_bgr(image)
|
| 433 |
+
vis = image.copy()
|
| 434 |
+
h, w = vis.shape[:2]
|
| 435 |
+
self.extract(image)
|
| 436 |
+
left, top, right, bottom = self._last_bounds
|
| 437 |
+
form_w = right - left
|
| 438 |
+
form_h = bottom - top
|
| 439 |
+
cv2.rectangle(vis, (left, top), (right, bottom), (0, 140, 255), 3)
|
| 440 |
+
cv2.putText(vis, "DETECTED FORM BOUNDARY",
|
| 441 |
+
(left, max(0, top - 8)),
|
| 442 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 140, 255), 1)
|
| 443 |
+
for idx, (name, (rx1, ry1, rx2, ry2)) in enumerate(self.field_map.items()):
|
| 444 |
+
x1 = max(0, min(int(left + rx1 * form_w), w - 1))
|
| 445 |
+
y1 = max(0, min(int(top + ry1 * form_h), h - 1))
|
| 446 |
+
x2 = max(0, min(int(left + rx2 * form_w), w - 1))
|
| 447 |
+
y2 = max(0, min(int(top + ry2 * form_h), h - 1))
|
| 448 |
+
c = COLOURS[idx % len(COLOURS)]
|
| 449 |
+
cv2.rectangle(vis, (x1, y1), (x2, y2), c, 2)
|
| 450 |
+
cv2.putText(vis, name[:22], (x1 + 2, max(0, y1 - 2)),
|
| 451 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.28, c, 1)
|
| 452 |
+
if output_path:
|
| 453 |
+
cv2.imwrite(str(output_path), vis)
|
| 454 |
+
print(f" Field map saved -> {output_path}")
|
| 455 |
+
return vis
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 459 |
+
# FIELD NORMALIZER — prepares a BGR crop for CRNN inference
|
| 460 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 461 |
+
|
| 462 |
+
class FieldNormalizer:
|
| 463 |
+
def __init__(self, target_height=64, target_width=512):
|
| 464 |
+
self.H = target_height
|
| 465 |
+
self.W = target_width
|
| 466 |
+
|
| 467 |
+
def _crop_to_text(self, gray):
|
| 468 |
+
inv = cv2.bitwise_not(gray)
|
| 469 |
+
_, thresh = cv2.threshold(inv, 20, 255, cv2.THRESH_BINARY)
|
| 470 |
+
coords = np.column_stack(np.where(thresh > 0))
|
| 471 |
+
if len(coords) == 0:
|
| 472 |
+
return gray
|
| 473 |
+
y_min, x_min = coords.min(axis=0)
|
| 474 |
+
y_max, x_max = coords.max(axis=0)
|
| 475 |
+
pad = max(4, int((y_max - y_min) * 0.15))
|
| 476 |
+
y_min = max(0, y_min - pad)
|
| 477 |
+
x_min = max(0, x_min - pad)
|
| 478 |
+
y_max = min(gray.shape[0] - 1, y_max + pad)
|
| 479 |
+
x_max = min(gray.shape[1] - 1, x_max + pad)
|
| 480 |
+
return gray[y_min:y_max + 1, x_min:x_max + 1]
|
| 481 |
+
|
| 482 |
+
def _smart_resize(self, gray):
|
| 483 |
+
h, w = gray.shape
|
| 484 |
+
if h == 0 or w == 0:
|
| 485 |
+
return np.ones((self.H, self.W), dtype=np.uint8) * 255
|
| 486 |
+
scale = self.H / h
|
| 487 |
+
new_w = int(w * scale)
|
| 488 |
+
new_h = self.H
|
| 489 |
+
if new_w > self.W:
|
| 490 |
+
scale = self.W / w
|
| 491 |
+
new_h = int(h * scale)
|
| 492 |
+
new_w = self.W
|
| 493 |
+
resized = cv2.resize(gray, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4)
|
| 494 |
+
canvas = np.ones((self.H, self.W), dtype=np.uint8) * 255
|
| 495 |
+
y_off = (self.H - new_h) // 2
|
| 496 |
+
x_off = (self.W - new_w) // 2
|
| 497 |
+
canvas[y_off:y_off + new_h, x_off:x_off + new_w] = resized
|
| 498 |
+
return canvas
|
| 499 |
+
|
| 500 |
+
def _binarize(self, img):
|
| 501 |
+
_, otsu = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
| 502 |
+
white_ratio = np.mean(otsu == 255)
|
| 503 |
+
if white_ratio < 0.30 or white_ratio > 0.97:
|
| 504 |
+
return cv2.adaptiveThreshold(
|
| 505 |
+
img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
| 506 |
+
cv2.THRESH_BINARY, 11, 2)
|
| 507 |
+
return otsu
|
| 508 |
+
|
| 509 |
+
def normalize(self, crop) -> np.ndarray:
|
| 510 |
+
"""Accept BGR numpy array or PIL image, return normalized binary array."""
|
| 511 |
+
try:
|
| 512 |
+
from PIL import Image as PILImage
|
| 513 |
+
if isinstance(crop, PILImage.Image):
|
| 514 |
+
crop = cv2.cvtColor(np.array(crop.convert("RGB")), cv2.COLOR_RGB2BGR)
|
| 515 |
+
except ImportError:
|
| 516 |
+
pass
|
| 517 |
+
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) if len(crop.shape) == 3 else crop.copy()
|
| 518 |
+
gray = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)
|
| 519 |
+
gray = self._crop_to_text(gray)
|
| 520 |
+
gray = self._smart_resize(gray)
|
| 521 |
+
return self._binarize(gray)
|
| 522 |
+
|
| 523 |
+
def to_tensor(self, img: np.ndarray) -> torch.Tensor:
|
| 524 |
+
return torch.FloatTensor(
|
| 525 |
+
img.astype(np.float32) / 255.0
|
| 526 |
+
).unsqueeze(0).unsqueeze(0)
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 530 |
+
# CRNN MODEL LOADER
|
| 531 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 532 |
+
|
| 533 |
+
def load_crnn_model(checkpoint_path: str, device: torch.device):
|
| 534 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 535 |
+
from crnn_model import get_crnn_model
|
| 536 |
+
|
| 537 |
+
print(f" Loading CRNN model from: {checkpoint_path}")
|
| 538 |
+
c = torch.load(checkpoint_path, map_location=device, weights_only=False)
|
| 539 |
+
config = c.get("config", {})
|
| 540 |
+
idx_to_char = c["idx_to_char"]
|
| 541 |
+
num_chars = c["model_state_dict"]["fc.weight"].shape[0]
|
| 542 |
+
|
| 543 |
+
model = get_crnn_model(
|
| 544 |
+
model_type=config.get("model_type", "standard"),
|
| 545 |
+
img_height=config.get("img_height", 64),
|
| 546 |
+
num_chars=num_chars,
|
| 547 |
+
hidden_size=config.get("hidden_size", 128),
|
| 548 |
+
num_lstm_layers=config.get("num_lstm_layers", 1),
|
| 549 |
+
).to(device)
|
| 550 |
+
model.load_state_dict(c["model_state_dict"])
|
| 551 |
+
model.eval()
|
| 552 |
+
|
| 553 |
+
val_cer = c.get("val_cer", None)
|
| 554 |
+
val_loss = c.get("val_loss", None)
|
| 555 |
+
metric = f"val_cer={val_cer:.2f}%" if val_cer else \
|
| 556 |
+
f"val_loss={val_loss:.4f}" if val_loss else "no metric"
|
| 557 |
+
print(f" Model loaded | {metric} | chars={num_chars}")
|
| 558 |
+
return model, idx_to_char, config.get("img_height", 64), config.get("img_width", 512)
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 562 |
+
# GREEDY CTC DECODE
|
| 563 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 564 |
+
|
| 565 |
+
def greedy_decode(outputs: torch.Tensor, idx_to_char: dict) -> str:
|
| 566 |
+
pred_indices = torch.argmax(outputs, dim=2).permute(1, 0)
|
| 567 |
+
chars, prev = [], -1
|
| 568 |
+
for idx in pred_indices[0]:
|
| 569 |
+
idx = idx.item()
|
| 570 |
+
if idx != 0 and idx != prev and idx in idx_to_char:
|
| 571 |
+
chars.append(idx_to_char[idx])
|
| 572 |
+
prev = idx
|
| 573 |
+
return "".join(chars)
|
| 574 |
+
|
| 575 |
+
|
| 576 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 577 |
+
# PDF → PIL IMAGE
|
| 578 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 579 |
+
|
| 580 |
+
def pdf_to_image(pdf_path: str, dpi: int = 200):
|
| 581 |
+
from pdf2image import convert_from_path
|
| 582 |
+
# Resolve to absolute path — fixes "Unable to get page count" on Windows
|
| 583 |
+
pdf_path = str(Path(pdf_path).resolve())
|
| 584 |
+
kwargs = {"dpi": dpi, "first_page": 1, "last_page": 1}
|
| 585 |
+
if POPPLER_PATH:
|
| 586 |
+
kwargs["poppler_path"] = str(Path(POPPLER_PATH).resolve())
|
| 587 |
+
return convert_from_path(pdf_path, **kwargs)[0]
|
| 588 |
+
|
| 589 |
+
|
| 590 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 591 |
+
# CRNN OCR — runs on extracted field crops
|
| 592 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 593 |
+
|
| 594 |
+
def run_crnn_ocr(crops: dict, model, idx_to_char: dict,
|
| 595 |
+
img_h: int, img_w: int, device: torch.device) -> dict:
|
| 596 |
+
normalizer = FieldNormalizer(target_height=img_h, target_width=img_w)
|
| 597 |
+
results = {}
|
| 598 |
+
with torch.no_grad():
|
| 599 |
+
for name, crop in crops.items():
|
| 600 |
+
try:
|
| 601 |
+
norm = normalizer.normalize(crop)
|
| 602 |
+
tensor = normalizer.to_tensor(norm).to(device)
|
| 603 |
+
text = greedy_decode(model(tensor).cpu(), idx_to_char)
|
| 604 |
+
results[name] = text
|
| 605 |
+
except Exception as e:
|
| 606 |
+
results[name] = f"[ERROR: {e}]"
|
| 607 |
+
return results
|
| 608 |
+
|
| 609 |
+
|
| 610 |
+
# ════════════════════════════════════���═════════════════════════════════════════
|
| 611 |
+
# CONVENIENCE WRAPPER — for other scripts that import this module
|
| 612 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 613 |
+
|
| 614 |
+
def extract_field_images(image, form_type="birth", verbose=False):
|
| 615 |
+
"""Extract field crops using dynamic boundary detection.
|
| 616 |
+
|
| 617 |
+
Parameters
|
| 618 |
+
----------
|
| 619 |
+
image : PIL Image or BGR numpy array
|
| 620 |
+
form_type : str 'birth' | 'death' | 'marriage' | 'marriage_license'
|
| 621 |
+
verbose : bool
|
| 622 |
+
|
| 623 |
+
Returns
|
| 624 |
+
-------
|
| 625 |
+
dict {field_name: BGR numpy array}
|
| 626 |
+
"""
|
| 627 |
+
return DynamicFieldExtractor(form_type=form_type, verbose=verbose).extract(image)
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
# Keep old name as alias so any existing code doesn't break
|
| 631 |
+
extract_field_images_dynamic = extract_field_images
|
| 632 |
+
|
| 633 |
+
|
| 634 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 635 |
+
# MAIN
|
| 636 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 637 |
+
|
| 638 |
+
def main():
|
| 639 |
+
parser = argparse.ArgumentParser(
|
| 640 |
+
description="PH Civil Registry Field Extractor — Dynamic CRNN OCR")
|
| 641 |
+
group = parser.add_mutually_exclusive_group(required=True)
|
| 642 |
+
group.add_argument("--pdf", help="Path to scanned PDF")
|
| 643 |
+
group.add_argument("--image", help="Path to scanned image (JPG/PNG)")
|
| 644 |
+
parser.add_argument("--form", required=True,
|
| 645 |
+
choices=["birth", "death", "marriage", "marriage_license"])
|
| 646 |
+
parser.add_argument("--checkpoint", default=DEFAULT_CHECKPOINT)
|
| 647 |
+
parser.add_argument("--visualize", action="store_true",
|
| 648 |
+
help="Save annotated field-map image")
|
| 649 |
+
parser.add_argument("--output", default=None,
|
| 650 |
+
help="Save extracted fields to JSON")
|
| 651 |
+
parser.add_argument("--poppler", default=None,
|
| 652 |
+
help="Override Poppler bin path (overrides .env)")
|
| 653 |
+
parser.add_argument("--dpi", type=int, default=200)
|
| 654 |
+
parser.add_argument("--verbose", action="store_true")
|
| 655 |
+
args = parser.parse_args()
|
| 656 |
+
|
| 657 |
+
global POPPLER_PATH
|
| 658 |
+
if args.poppler:
|
| 659 |
+
POPPLER_PATH = args.poppler
|
| 660 |
+
|
| 661 |
+
form_labels = {
|
| 662 |
+
"birth": "Form 102 — Certificate of Live Birth",
|
| 663 |
+
"death": "Form 103 — Certificate of Death",
|
| 664 |
+
"marriage": "Form 97 — Certificate of Marriage",
|
| 665 |
+
"marriage_license": "Form 90 — Application for Marriage License",
|
| 666 |
+
}
|
| 667 |
+
input_file = args.pdf or args.image
|
| 668 |
+
|
| 669 |
+
print("\nPhilippine Civil Registry OCR — Dynamic Field Extractor")
|
| 670 |
+
print("=" * 65)
|
| 671 |
+
print(f" Form : {form_labels[args.form]}")
|
| 672 |
+
print(f" File : {input_file}")
|
| 673 |
+
print(f" Checkpoint : {args.checkpoint}")
|
| 674 |
+
|
| 675 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 676 |
+
print(f" Device : {device}\n")
|
| 677 |
+
|
| 678 |
+
if not os.path.exists(args.checkpoint):
|
| 679 |
+
print(f"ERROR: Checkpoint not found: {args.checkpoint}")
|
| 680 |
+
sys.exit(1)
|
| 681 |
+
|
| 682 |
+
model, idx_to_char, img_h, img_w = load_crnn_model(args.checkpoint, device)
|
| 683 |
+
|
| 684 |
+
# Load image
|
| 685 |
+
if args.pdf:
|
| 686 |
+
print(f" Converting PDF to image at {args.dpi} DPI...")
|
| 687 |
+
try:
|
| 688 |
+
pil_img = pdf_to_image(args.pdf, dpi=args.dpi)
|
| 689 |
+
page_image = cv2.cvtColor(np.array(pil_img.convert("RGB")), cv2.COLOR_RGB2BGR)
|
| 690 |
+
except Exception as e:
|
| 691 |
+
print(f"\nERROR converting PDF: {e}")
|
| 692 |
+
print("Fix: add POPPLER_PATH=C:\\...\\poppler\\Library\\bin to your .env file")
|
| 693 |
+
sys.exit(1)
|
| 694 |
+
else:
|
| 695 |
+
page_image = cv2.imread(args.image)
|
| 696 |
+
if page_image is None:
|
| 697 |
+
print(f"ERROR: Could not load image: {args.image}")
|
| 698 |
+
sys.exit(1)
|
| 699 |
+
|
| 700 |
+
h, w = page_image.shape[:2]
|
| 701 |
+
print(f" Page size : {w} x {h} px")
|
| 702 |
+
|
| 703 |
+
extractor = DynamicFieldExtractor(form_type=args.form, verbose=args.verbose)
|
| 704 |
+
|
| 705 |
+
if args.visualize:
|
| 706 |
+
stem = Path(input_file).stem
|
| 707 |
+
out_path = stem + "_field_map.jpg"
|
| 708 |
+
extractor.visualize(page_image, output_path=out_path)
|
| 709 |
+
print(f" Field map saved -> {out_path}")
|
| 710 |
+
|
| 711 |
+
print(f"\n Detecting form boundary and extracting fields...")
|
| 712 |
+
crops = extractor.extract(page_image)
|
| 713 |
+
print(f" {len(crops)} field crops extracted")
|
| 714 |
+
|
| 715 |
+
print(f"\n Running CRNN OCR on {len(crops)} fields...")
|
| 716 |
+
results = run_crnn_ocr(crops, model, idx_to_char, img_h, img_w, device)
|
| 717 |
+
|
| 718 |
+
print(f"\n{'─'*65}")
|
| 719 |
+
print(f" {'FIELD':<42} TEXT")
|
| 720 |
+
print(f"{'─'*65}")
|
| 721 |
+
for name, text in results.items():
|
| 722 |
+
print(f" {name:<42} {text if text.strip() else '(empty)'}")
|
| 723 |
+
print(f"{'─'*65}")
|
| 724 |
+
print(f"\n Fields recognized : {sum(1 for t in results.values() if t.strip())} / {len(results)}")
|
| 725 |
+
|
| 726 |
+
if args.output:
|
| 727 |
+
with open(args.output, "w", encoding="utf-8") as f:
|
| 728 |
+
json.dump({"form": form_labels[args.form], "file": input_file,
|
| 729 |
+
"fields": results}, f, ensure_ascii=False, indent=2)
|
| 730 |
+
print(f"\n Results saved -> {args.output}")
|
| 731 |
+
print()
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
if __name__ == "__main__":
|
| 735 |
+
main()
|
finetune.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
finetune.py
|
| 3 |
+
===========
|
| 4 |
+
Fine-tune CRNN+CTC on generated civil registry form crops.
|
| 5 |
+
|
| 6 |
+
Continues from best_model_v2.pth, trains on actual_annotations.json
|
| 7 |
+
+ train_annotations.json, saves to best_model_v4.pth.
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
python finetune.py
|
| 11 |
+
|
| 12 |
+
Output:
|
| 13 |
+
checkpoints/best_model_v4.pth
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
import sys
|
| 18 |
+
import json
|
| 19 |
+
import shutil
|
| 20 |
+
import torch
|
| 21 |
+
import torch.nn.functional as F
|
| 22 |
+
import torch.optim as optim
|
| 23 |
+
from torch.utils.data import DataLoader, ConcatDataset
|
| 24 |
+
|
| 25 |
+
sys.path.append('.')
|
| 26 |
+
from crnn_model import get_crnn_model
|
| 27 |
+
from dataset import CivilRegistryDataset, collate_fn
|
| 28 |
+
|
| 29 |
+
# ── Config ────────────────────────────────────────────────────
|
| 30 |
+
CHECKPOINT_IN = "checkpoints/best_model_v3.pth"
|
| 31 |
+
CHECKPOINT_OUT = "checkpoints/best_model_v4.pth"
|
| 32 |
+
|
| 33 |
+
ACTUAL_ANN = "data/actual_annotations.json" # real scanned forms
|
| 34 |
+
SYNTH_ANN = "data/train_annotations.json" # synthetic / train split
|
| 35 |
+
VAL_ANN = "data/val_annotations.json" # validation set
|
| 36 |
+
|
| 37 |
+
DRIVE_BACKUP = "/content/drive/MyDrive/crnn_finetune/CRNN+CTC/checkpoints/best_model_v4.pth"
|
| 38 |
+
|
| 39 |
+
IMG_HEIGHT = 64
|
| 40 |
+
IMG_WIDTH = 512
|
| 41 |
+
BATCH_SIZE = 32
|
| 42 |
+
|
| 43 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 44 |
+
|
| 45 |
+
# ── Phase settings ────────────────────────────────────────────
|
| 46 |
+
PHASES = [
|
| 47 |
+
# (name, epochs, lr, freeze_cnn, patience)
|
| 48 |
+
("Phase 1 — CNN frozen, warm up on actual crops", 20, 1e-4, True, 5),
|
| 49 |
+
("Phase 2 — Full model, main training", 30, 1e-5, False, 6),
|
| 50 |
+
("Phase 3 — Full model, slow burn", 30, 5e-6, False, 6),
|
| 51 |
+
("Phase 4 — Full model, final polish", 20, 1e-6, False, 5),
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
# ── Fix Windows backslash paths ───────────────────────────────
|
| 55 |
+
def fix_paths(json_path):
|
| 56 |
+
with open(json_path) as f:
|
| 57 |
+
ann = json.load(f)
|
| 58 |
+
changed = False
|
| 59 |
+
for a in ann:
|
| 60 |
+
if 'image_path' in a and '\\' in a['image_path']:
|
| 61 |
+
a['image_path'] = a['image_path'].replace('\\', '/')
|
| 62 |
+
changed = True
|
| 63 |
+
if changed:
|
| 64 |
+
with open(json_path, 'w') as f:
|
| 65 |
+
json.dump(ann, f)
|
| 66 |
+
print(f" Fixed backslash paths in {json_path}")
|
| 67 |
+
|
| 68 |
+
# ── Main ──────────────────────────────────────────────────────
|
| 69 |
+
def main():
|
| 70 |
+
print("=" * 60)
|
| 71 |
+
print(" Fine-tuning CRNN+CTC on civil registry form crops")
|
| 72 |
+
print("=" * 60)
|
| 73 |
+
print(f" Device : {DEVICE}")
|
| 74 |
+
print(f" Checkpoint : {CHECKPOINT_IN}")
|
| 75 |
+
|
| 76 |
+
# ── Check required files ──────────────────────────────────
|
| 77 |
+
for f in [CHECKPOINT_IN, VAL_ANN]:
|
| 78 |
+
if not os.path.exists(f):
|
| 79 |
+
print(f"ERROR: {f} not found.")
|
| 80 |
+
sys.exit(1)
|
| 81 |
+
|
| 82 |
+
# ── Fix backslash paths ───────────────────────────────────
|
| 83 |
+
for ann_file in [ACTUAL_ANN, SYNTH_ANN, VAL_ANN]:
|
| 84 |
+
if os.path.exists(ann_file):
|
| 85 |
+
fix_paths(ann_file)
|
| 86 |
+
|
| 87 |
+
# ── Datasets ──────────────────────────────────────────────
|
| 88 |
+
datasets_to_merge = []
|
| 89 |
+
|
| 90 |
+
# 1. Actual scanned forms (highest priority — real data)
|
| 91 |
+
if os.path.exists(ACTUAL_ANN):
|
| 92 |
+
actual_dataset = CivilRegistryDataset(
|
| 93 |
+
data_dir=".", annotations_file=ACTUAL_ANN,
|
| 94 |
+
img_height=IMG_HEIGHT, img_width=IMG_WIDTH, augment=True
|
| 95 |
+
)
|
| 96 |
+
datasets_to_merge.append(actual_dataset)
|
| 97 |
+
print(f" Actual crops: {len(actual_dataset)} (real scanned forms)")
|
| 98 |
+
else:
|
| 99 |
+
print(f" [!] {ACTUAL_ANN} not found")
|
| 100 |
+
|
| 101 |
+
# 2. Fully synthetic — keep so model doesn't forget basic characters
|
| 102 |
+
if os.path.exists(SYNTH_ANN):
|
| 103 |
+
synth_dataset = CivilRegistryDataset(
|
| 104 |
+
data_dir=".", annotations_file=SYNTH_ANN,
|
| 105 |
+
img_height=IMG_HEIGHT, img_width=IMG_WIDTH, augment=True
|
| 106 |
+
)
|
| 107 |
+
datasets_to_merge.append(synth_dataset)
|
| 108 |
+
print(f" Synth crops : {len(synth_dataset)} (fully synthetic)")
|
| 109 |
+
|
| 110 |
+
if not datasets_to_merge:
|
| 111 |
+
print("ERROR: No training data found.")
|
| 112 |
+
sys.exit(1)
|
| 113 |
+
|
| 114 |
+
val_dataset = CivilRegistryDataset(
|
| 115 |
+
data_dir=".", annotations_file=VAL_ANN,
|
| 116 |
+
img_height=IMG_HEIGHT, img_width=IMG_WIDTH, augment=False
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
train_dataset = ConcatDataset(datasets_to_merge) if len(datasets_to_merge) > 1 else datasets_to_merge[0]
|
| 120 |
+
print(f" Total train : {len(train_dataset)}")
|
| 121 |
+
print(f" Val : {len(val_dataset)}")
|
| 122 |
+
|
| 123 |
+
train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE,
|
| 124 |
+
shuffle=True, num_workers=0, collate_fn=collate_fn)
|
| 125 |
+
val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE,
|
| 126 |
+
shuffle=False, num_workers=0, collate_fn=collate_fn)
|
| 127 |
+
|
| 128 |
+
# ── Load checkpoint ───────────────────────────────────────
|
| 129 |
+
print(f"\n Loading {CHECKPOINT_IN}...")
|
| 130 |
+
ckpt = torch.load(CHECKPOINT_IN, map_location=DEVICE, weights_only=False)
|
| 131 |
+
config = ckpt.get('config', {})
|
| 132 |
+
|
| 133 |
+
ref_dataset = datasets_to_merge[0]
|
| 134 |
+
model = get_crnn_model(
|
| 135 |
+
model_type = config.get('model_type', 'standard'),
|
| 136 |
+
img_height = config.get('img_height', 64),
|
| 137 |
+
num_chars = ref_dataset.num_chars,
|
| 138 |
+
hidden_size = config.get('hidden_size', 128),
|
| 139 |
+
num_lstm_layers = config.get('num_lstm_layers', 1),
|
| 140 |
+
).to(DEVICE)
|
| 141 |
+
|
| 142 |
+
missing, _ = model.load_state_dict(ckpt['model_state_dict'], strict=False)
|
| 143 |
+
if missing:
|
| 144 |
+
print(f" Note: {len(missing)} layers re-initialized (expected if vocab size changed)")
|
| 145 |
+
print(f" Loaded epoch {ckpt.get('epoch','?')} "
|
| 146 |
+
f"val_loss={ckpt.get('val_loss', ckpt.get('val_cer', 0)):.4f}")
|
| 147 |
+
|
| 148 |
+
criterion = torch.nn.CTCLoss(blank=0, reduction='mean', zero_infinity=True)
|
| 149 |
+
os.makedirs("checkpoints", exist_ok=True)
|
| 150 |
+
|
| 151 |
+
# ── Train/val loop ────────────────────────────────────────
|
| 152 |
+
def run_epoch(loader, training, optimizer=None):
|
| 153 |
+
model.train() if training else model.eval()
|
| 154 |
+
total, n = 0, 0
|
| 155 |
+
ctx = torch.enable_grad() if training else torch.no_grad()
|
| 156 |
+
with ctx:
|
| 157 |
+
for images, targets, target_lengths, _ in loader:
|
| 158 |
+
images = images.to(DEVICE)
|
| 159 |
+
batch_size = images.size(0)
|
| 160 |
+
if training:
|
| 161 |
+
optimizer.zero_grad()
|
| 162 |
+
outputs = F.log_softmax(model(images), dim=2)
|
| 163 |
+
seq_len = outputs.size(0)
|
| 164 |
+
input_lengths = torch.full((batch_size,), seq_len, dtype=torch.long)
|
| 165 |
+
loss = criterion(outputs, targets, input_lengths, target_lengths)
|
| 166 |
+
if not torch.isnan(loss) and not torch.isinf(loss):
|
| 167 |
+
if training:
|
| 168 |
+
loss.backward()
|
| 169 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 5)
|
| 170 |
+
optimizer.step()
|
| 171 |
+
total += loss.item()
|
| 172 |
+
n += 1
|
| 173 |
+
return total / max(n, 1)
|
| 174 |
+
|
| 175 |
+
best_overall = float('inf')
|
| 176 |
+
|
| 177 |
+
for phase_name, epochs, lr, freeze_cnn, patience in PHASES:
|
| 178 |
+
print(f"\n{'='*60}")
|
| 179 |
+
print(f" {phase_name} LR={lr}")
|
| 180 |
+
print(f"{'='*60}")
|
| 181 |
+
|
| 182 |
+
for name, param in model.named_parameters():
|
| 183 |
+
param.requires_grad = not (freeze_cnn and 'cnn' in name)
|
| 184 |
+
|
| 185 |
+
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 186 |
+
print(f" Trainable params : {trainable:,}")
|
| 187 |
+
|
| 188 |
+
opt = optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=lr)
|
| 189 |
+
sched = optim.lr_scheduler.ReduceLROnPlateau(opt, patience=2, factor=0.5)
|
| 190 |
+
best = float('inf')
|
| 191 |
+
wait = 0
|
| 192 |
+
|
| 193 |
+
for epoch in range(1, epochs + 1):
|
| 194 |
+
tr = run_epoch(train_loader, True, opt)
|
| 195 |
+
vl = run_epoch(val_loader, False, None)
|
| 196 |
+
sched.step(vl)
|
| 197 |
+
|
| 198 |
+
if vl < best:
|
| 199 |
+
best = vl
|
| 200 |
+
wait = 0
|
| 201 |
+
if vl < best_overall:
|
| 202 |
+
best_overall = vl
|
| 203 |
+
torch.save({
|
| 204 |
+
**ckpt,
|
| 205 |
+
'model_state_dict': model.state_dict(),
|
| 206 |
+
'config': config,
|
| 207 |
+
'char_to_idx': ref_dataset.char_to_idx,
|
| 208 |
+
'idx_to_char': ref_dataset.idx_to_char,
|
| 209 |
+
'epoch': epoch,
|
| 210 |
+
'val_loss': vl,
|
| 211 |
+
}, CHECKPOINT_OUT)
|
| 212 |
+
print(f" Epoch {epoch:02d}/{epochs} Train={tr:.4f} Val={vl:.4f} <- saved")
|
| 213 |
+
else:
|
| 214 |
+
wait += 1
|
| 215 |
+
print(f" Epoch {epoch:02d}/{epochs} Train={tr:.4f} Val={vl:.4f} (patience {wait}/{patience})")
|
| 216 |
+
if wait >= patience:
|
| 217 |
+
print(f" Early stopping.")
|
| 218 |
+
break
|
| 219 |
+
|
| 220 |
+
# ── Drive backup ──────────────────────────────────────────
|
| 221 |
+
if os.path.exists(CHECKPOINT_OUT) and os.path.exists(os.path.dirname(DRIVE_BACKUP)):
|
| 222 |
+
shutil.copy(CHECKPOINT_OUT, DRIVE_BACKUP)
|
| 223 |
+
print(f"\n Backed up to Drive: {DRIVE_BACKUP}")
|
| 224 |
+
|
| 225 |
+
print(f"\n{'='*60}")
|
| 226 |
+
print(f" Fine-tuning complete!")
|
| 227 |
+
print(f" Best val loss : {best_overall:.4f}")
|
| 228 |
+
print(f" Saved : {CHECKPOINT_OUT}")
|
| 229 |
+
print(f"{'='*60}")
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
if __name__ == '__main__':
|
| 233 |
+
main()
|
fix_annotations.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json, os
|
| 2 |
+
|
| 3 |
+
# Maps any image path to its correct form subfolder.
|
| 4 |
+
# FIXED: was only handling form1a/form2a — missed form3a and form90.
|
| 5 |
+
def detect_folder(image_path):
|
| 6 |
+
for form in ['form1a', 'form2a', 'form3a', 'form90']:
|
| 7 |
+
if form in image_path:
|
| 8 |
+
return form
|
| 9 |
+
return 'form1a' # safe fallback
|
| 10 |
+
|
| 11 |
+
for split in ['train', 'val']:
|
| 12 |
+
ann_file = f'data/{split}_annotations.json'
|
| 13 |
+
if not os.path.exists(ann_file):
|
| 14 |
+
print(f'SKIP: {ann_file} not found')
|
| 15 |
+
continue
|
| 16 |
+
|
| 17 |
+
with open(ann_file) as f:
|
| 18 |
+
data = json.load(f)
|
| 19 |
+
|
| 20 |
+
fixed = []
|
| 21 |
+
skipped = 0
|
| 22 |
+
for d in data:
|
| 23 |
+
# Support both old key names ('image'/'label') and new ('image_path'/'text')
|
| 24 |
+
image_val = d.get('image') or d.get('image_path', '')
|
| 25 |
+
text_val = d.get('label') or d.get('text', '')
|
| 26 |
+
|
| 27 |
+
if not image_val or not text_val:
|
| 28 |
+
skipped += 1
|
| 29 |
+
continue
|
| 30 |
+
|
| 31 |
+
filename = os.path.basename(image_val)
|
| 32 |
+
folder = detect_folder(image_val)
|
| 33 |
+
fixed.append({'image_path': f'{folder}/{filename}', 'text': text_val})
|
| 34 |
+
|
| 35 |
+
with open(ann_file, 'w') as f:
|
| 36 |
+
json.dump(fixed, f, indent=2)
|
| 37 |
+
|
| 38 |
+
print(f'{split}: {len(fixed)} fixed, {skipped} skipped')
|
| 39 |
+
|
| 40 |
+
print('Done!')
|
fix_data.py
ADDED
|
@@ -0,0 +1,770 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
fix_data.py
|
| 3 |
+
===========
|
| 4 |
+
Generates synthetic training images for the Civil Registry OCR system.
|
| 5 |
+
|
| 6 |
+
Run this ONCE before training to create your dataset.
|
| 7 |
+
|
| 8 |
+
STEP ORDER:
|
| 9 |
+
1. python generate_ph_names.py <- generates data/ph_names.json
|
| 10 |
+
2. python fix_data.py <- generates all training images (THIS FILE)
|
| 11 |
+
3. python train.py <- trains the CRNN model
|
| 12 |
+
|
| 13 |
+
WHAT IT GENERATES:
|
| 14 |
+
- Printed text images of names, dates, places, and other form fields
|
| 15 |
+
- Covers all 4 form types: birth, death, marriage, marriage license
|
| 16 |
+
- Splits into train (90%) and val (10%)
|
| 17 |
+
- Writes data/train_annotations.json and data/val_annotations.json
|
| 18 |
+
|
| 19 |
+
OUTPUT STRUCTURE:
|
| 20 |
+
data/
|
| 21 |
+
train/
|
| 22 |
+
form1a/ <- birth certificate fields
|
| 23 |
+
form2a/ <- death certificate fields
|
| 24 |
+
form3a/ <- marriage certificate fields
|
| 25 |
+
form90/ <- marriage license fields
|
| 26 |
+
val/
|
| 27 |
+
form1a/
|
| 28 |
+
form2a/
|
| 29 |
+
form3a/
|
| 30 |
+
form90/
|
| 31 |
+
train_annotations.json
|
| 32 |
+
val_annotations.json
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
import os
|
| 36 |
+
import json
|
| 37 |
+
import random
|
| 38 |
+
import numpy as np
|
| 39 |
+
from pathlib import Path
|
| 40 |
+
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 44 |
+
# CONFIG
|
| 45 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 46 |
+
|
| 47 |
+
IMG_WIDTH = 512
|
| 48 |
+
IMG_HEIGHT = 64
|
| 49 |
+
FONT_SIZE = 22
|
| 50 |
+
VAL_SPLIT = 0.10
|
| 51 |
+
RANDOM_SEED = 42
|
| 52 |
+
|
| 53 |
+
SAMPLES_PER_FORM = {
|
| 54 |
+
'form1a': 6000,
|
| 55 |
+
'form2a': 4000,
|
| 56 |
+
'form3a': 4000,
|
| 57 |
+
'form90': 2000,
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
PH_NAMES_FILE = 'data/ph_names.json'
|
| 61 |
+
|
| 62 |
+
random.seed(RANDOM_SEED)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 66 |
+
# FONT LOADER
|
| 67 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 68 |
+
|
| 69 |
+
def load_font(size=FONT_SIZE):
|
| 70 |
+
"""Load a single font — used as fallback. Prefer load_font_pool()."""
|
| 71 |
+
for fp in [
|
| 72 |
+
'arial.ttf', 'Arial.ttf',
|
| 73 |
+
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
|
| 74 |
+
'/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf',
|
| 75 |
+
'/System/Library/Fonts/Helvetica.ttc',
|
| 76 |
+
'C:/Windows/Fonts/arial.ttf',
|
| 77 |
+
'C:/Windows/Fonts/calibri.ttf',
|
| 78 |
+
]:
|
| 79 |
+
try:
|
| 80 |
+
return ImageFont.truetype(fp, size)
|
| 81 |
+
except Exception:
|
| 82 |
+
continue
|
| 83 |
+
print("WARNING: Could not load a TrueType font. Using default bitmap font.")
|
| 84 |
+
print(" Prediction accuracy may be lower.")
|
| 85 |
+
return ImageFont.load_default()
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def load_font_pool(size=FONT_SIZE):
|
| 89 |
+
"""
|
| 90 |
+
Load a pool of diverse fonts so the model trains on varied typefaces.
|
| 91 |
+
Using only one font causes the model to overfit to that font's style and
|
| 92 |
+
fail on real civil registry documents which use mixed fonts.
|
| 93 |
+
Returns a list of at least 1 font; caller picks randomly per image.
|
| 94 |
+
"""
|
| 95 |
+
candidates = [
|
| 96 |
+
# Sans-serif (most common in PH civil registry printed forms)
|
| 97 |
+
'arial.ttf', 'Arial.ttf',
|
| 98 |
+
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
|
| 99 |
+
'/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf',
|
| 100 |
+
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
|
| 101 |
+
'C:/Windows/Fonts/arial.ttf',
|
| 102 |
+
'C:/Windows/Fonts/arialbd.ttf',
|
| 103 |
+
'C:/Windows/Fonts/calibri.ttf',
|
| 104 |
+
'C:/Windows/Fonts/calibrib.ttf',
|
| 105 |
+
# Serif (used in older typewriter-style registry entries)
|
| 106 |
+
'/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf',
|
| 107 |
+
'/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf',
|
| 108 |
+
'C:/Windows/Fonts/times.ttf',
|
| 109 |
+
'C:/Windows/Fonts/Georgia.ttf',
|
| 110 |
+
'/System/Library/Fonts/Times.ttc',
|
| 111 |
+
# Mono (typewriter — common in pre-2000 civil registry forms)
|
| 112 |
+
'/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf',
|
| 113 |
+
'/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf',
|
| 114 |
+
'C:/Windows/Fonts/cour.ttf',
|
| 115 |
+
# Condensed / narrow (space-saving fonts used in registry tables)
|
| 116 |
+
'C:/Windows/Fonts/arialn.ttf',
|
| 117 |
+
'/usr/share/fonts/truetype/ubuntu/UbuntuCondensed-Regular.ttf',
|
| 118 |
+
]
|
| 119 |
+
pool = []
|
| 120 |
+
for fp in candidates:
|
| 121 |
+
try:
|
| 122 |
+
pool.append(ImageFont.truetype(fp, size))
|
| 123 |
+
except Exception:
|
| 124 |
+
continue
|
| 125 |
+
if not pool:
|
| 126 |
+
print("WARNING: No TrueType fonts found. Using default bitmap font.")
|
| 127 |
+
pool.append(ImageFont.load_default())
|
| 128 |
+
else:
|
| 129 |
+
print(f" ✓ Font pool loaded: {len(pool)} font(s) available")
|
| 130 |
+
return pool
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 134 |
+
# IMAGE RENDERER
|
| 135 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 136 |
+
|
| 137 |
+
def render_text_image(text: str, font, width=IMG_WIDTH, height=IMG_HEIGHT,
|
| 138 |
+
handwriting=False) -> Image.Image:
|
| 139 |
+
"""
|
| 140 |
+
Render text on a white background, centered.
|
| 141 |
+
handwriting=True applies handwriting-style augmentations.
|
| 142 |
+
"""
|
| 143 |
+
img = Image.new('RGB', (width, height), color=(255, 255, 255))
|
| 144 |
+
draw = ImageDraw.Draw(img)
|
| 145 |
+
|
| 146 |
+
bbox = draw.textbbox((0, 0), text, font=font)
|
| 147 |
+
tw = bbox[2] - bbox[0]
|
| 148 |
+
th = bbox[3] - bbox[1]
|
| 149 |
+
x = max(4, (width - tw) // 2)
|
| 150 |
+
y = max(4, (height - th) // 2)
|
| 151 |
+
|
| 152 |
+
if not handwriting:
|
| 153 |
+
# ── PRINTED mode ──────────────────────────────────────
|
| 154 |
+
shade = random.randint(0, 40)
|
| 155 |
+
draw.text((x, y), text, fill=(shade, shade, shade), font=font)
|
| 156 |
+
|
| 157 |
+
else:
|
| 158 |
+
# ── HANDWRITING simulation mode ───────────────────────
|
| 159 |
+
# 1. Pen color — blue-black ballpen
|
| 160 |
+
r = random.randint(0, 60)
|
| 161 |
+
g = random.randint(0, 60)
|
| 162 |
+
b = random.randint(0, 120)
|
| 163 |
+
ink_color = (r, g, b)
|
| 164 |
+
|
| 165 |
+
# 2. Per-character y-wobble (unsteady hand)
|
| 166 |
+
if random.choice([True, False]) and len(text) > 1:
|
| 167 |
+
char_x = x
|
| 168 |
+
for ch in text:
|
| 169 |
+
y_offset = random.randint(-2, 2)
|
| 170 |
+
draw.text((char_x, y + y_offset), ch, fill=ink_color, font=font)
|
| 171 |
+
ch_bbox = draw.textbbox((0, 0), ch, font=font)
|
| 172 |
+
char_x += (ch_bbox[2] - ch_bbox[0]) + random.randint(-1, 1)
|
| 173 |
+
else:
|
| 174 |
+
draw.text((x, y), text, fill=ink_color, font=font)
|
| 175 |
+
|
| 176 |
+
# 3. Pixel-level augmentation
|
| 177 |
+
arr = np.array(img).astype(np.float32)
|
| 178 |
+
|
| 179 |
+
# 4. Ink bleed
|
| 180 |
+
if random.random() < 0.5:
|
| 181 |
+
img_pil = Image.fromarray(arr.astype(np.uint8))
|
| 182 |
+
img_pil = img_pil.filter(
|
| 183 |
+
ImageFilter.GaussianBlur(radius=random.uniform(0.3, 0.7)))
|
| 184 |
+
arr = np.array(img_pil).astype(np.float32)
|
| 185 |
+
|
| 186 |
+
# 5. Paper texture noise
|
| 187 |
+
noise_map = np.random.normal(0, random.uniform(3, 10), arr.shape)
|
| 188 |
+
arr = np.clip(arr + noise_map, 0, 255)
|
| 189 |
+
|
| 190 |
+
# 6. Scan shadow patch
|
| 191 |
+
if random.random() < 0.3:
|
| 192 |
+
patch_x = random.randint(0, width - 20)
|
| 193 |
+
patch_w = random.randint(10, 60)
|
| 194 |
+
arr[:, patch_x:patch_x + patch_w] *= random.uniform(0.88, 0.97)
|
| 195 |
+
arr = np.clip(arr, 0, 255)
|
| 196 |
+
|
| 197 |
+
img = Image.fromarray(arr.astype(np.uint8))
|
| 198 |
+
|
| 199 |
+
# 7. Pen tilt rotation (+-3 degrees)
|
| 200 |
+
if random.random() < 0.6:
|
| 201 |
+
angle = random.uniform(-3, 3)
|
| 202 |
+
img = img.rotate(angle, fillcolor=(255, 255, 255), expand=False)
|
| 203 |
+
|
| 204 |
+
return img
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 208 |
+
# NAME / DATA POOLS
|
| 209 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 210 |
+
|
| 211 |
+
# Populated at runtime from ph_names.json via load_ph_names()
|
| 212 |
+
MIDDLE_NAMES = []
|
| 213 |
+
|
| 214 |
+
SUFFIXES = ['Jr.', 'Sr.', 'II', 'III', '']
|
| 215 |
+
|
| 216 |
+
MONTHS = [
|
| 217 |
+
'January', 'February', 'March', 'April', 'May', 'June',
|
| 218 |
+
'July', 'August', 'September', 'October', 'November', 'December',
|
| 219 |
+
]
|
| 220 |
+
|
| 221 |
+
CITIES = [
|
| 222 |
+
# NCR
|
| 223 |
+
'Manila', 'Quezon City', 'Caloocan', 'Pasig', 'Makati',
|
| 224 |
+
'Taguig', 'Paranaque', 'Pasay', 'Las Pinas', 'Muntinlupa',
|
| 225 |
+
'Marikina', 'Valenzuela', 'Malabon', 'Navotas', 'Mandaluyong',
|
| 226 |
+
'San Juan', 'Pateros',
|
| 227 |
+
# Luzon
|
| 228 |
+
'Tarlac City', 'Angeles City', 'San Fernando', 'Olongapo',
|
| 229 |
+
'Cabanatuan', 'San Jose del Monte', 'Bacoor', 'Imus', 'Dasmarinas',
|
| 230 |
+
'Antipolo', 'Binangonan', 'Taytay', 'Santa Rosa', 'Calamba',
|
| 231 |
+
'San Pablo', 'Lucena', 'Batangas City', 'Lipa', 'Naga City',
|
| 232 |
+
'Legazpi', 'Sorsogon City', 'Tuguegarao', 'Ilagan', 'Santiago City',
|
| 233 |
+
'Cauayan', 'San Fernando (La Union)', 'Vigan', 'Laoag',
|
| 234 |
+
'Dagupan', 'San Carlos', 'Urdaneta', 'Baguio City',
|
| 235 |
+
# Visayas
|
| 236 |
+
'Cebu City', 'Mandaue', 'Lapu-Lapu', 'Talisay', 'Danao',
|
| 237 |
+
'Toledo', 'Carcar', 'Bacolod', 'Bago', 'Sagay', 'Victorias',
|
| 238 |
+
'Iloilo City', 'Passi', 'Roxas City', 'Kalibo',
|
| 239 |
+
'Tacloban', 'Ormoc', 'Palo', 'Catbalogan', 'Calbayog',
|
| 240 |
+
'Tagbilaran', 'Dumaguete', 'Tanjay', 'Bayawan', 'Kabankalan',
|
| 241 |
+
# Mindanao
|
| 242 |
+
'Davao City', 'Tagum', 'Panabo', 'Digos', 'Mati',
|
| 243 |
+
'General Santos', 'Koronadal', 'Kidapawan', 'Cotabato City',
|
| 244 |
+
'Cagayan de Oro', 'Iligan', 'Ozamiz', 'Oroquieta', 'Tangub',
|
| 245 |
+
'Butuan', 'Cabadbaran', 'Surigao City', 'Bislig', 'Bayugan',
|
| 246 |
+
'Zamboanga City', 'Pagadian', 'Dipolog', 'Dapitan',
|
| 247 |
+
'Marawi', 'Malaybalay', 'Valencia',
|
| 248 |
+
]
|
| 249 |
+
|
| 250 |
+
PROVINCES = [
|
| 251 |
+
# Luzon
|
| 252 |
+
'Tarlac', 'Pampanga', 'Bulacan', 'Nueva Ecija', 'Bataan',
|
| 253 |
+
'Zambales', 'Aurora', 'Rizal', 'Cavite', 'Laguna',
|
| 254 |
+
'Batangas', 'Quezon', 'Marinduque', 'Occidental Mindoro',
|
| 255 |
+
'Oriental Mindoro', 'Palawan', 'Romblon',
|
| 256 |
+
'Camarines Norte', 'Camarines Sur', 'Albay', 'Sorsogon',
|
| 257 |
+
'Catanduanes', 'Masbate',
|
| 258 |
+
'Pangasinan', 'La Union', 'Benguet', 'Ifugao', 'Mountain Province',
|
| 259 |
+
'Kalinga', 'Apayao', 'Abra', 'Ilocos Norte', 'Ilocos Sur',
|
| 260 |
+
'Cagayan', 'Isabela', 'Nueva Vizcaya', 'Quirino',
|
| 261 |
+
'Metro Manila',
|
| 262 |
+
# Visayas
|
| 263 |
+
'Cebu', 'Bohol', 'Negros Oriental', 'Siquijor',
|
| 264 |
+
'Negros Occidental', 'Iloilo', 'Capiz', 'Aklan', 'Antique',
|
| 265 |
+
'Guimaras', 'Leyte', 'Southern Leyte', 'Samar', 'Eastern Samar',
|
| 266 |
+
'Northern Samar', 'Biliran',
|
| 267 |
+
# Mindanao
|
| 268 |
+
'Davao del Sur', 'Davao del Norte', 'Davao Oriental',
|
| 269 |
+
'Davao Occidental', 'Davao de Oro',
|
| 270 |
+
'South Cotabato', 'Sarangani', 'Sultan Kudarat', 'North Cotabato',
|
| 271 |
+
'Misamis Oriental', 'Misamis Occidental', 'Camiguin',
|
| 272 |
+
'Bukidnon', 'Lanao del Norte', 'Lanao del Sur',
|
| 273 |
+
'Maguindanao', 'Basilan', 'Sulu', 'Tawi-Tawi',
|
| 274 |
+
'Zamboanga del Sur', 'Zamboanga del Norte', 'Zamboanga Sibugay',
|
| 275 |
+
'Agusan del Norte', 'Agusan del Sur', 'Surigao del Norte',
|
| 276 |
+
'Surigao del Sur', 'Dinagat Islands',
|
| 277 |
+
]
|
| 278 |
+
|
| 279 |
+
BARANGAYS = [
|
| 280 |
+
'Brgy. San Jose', 'Brgy. Sta. Maria', 'Brgy. San Antonio',
|
| 281 |
+
'Brgy. Santo Nino', 'Brgy. Poblacion', 'Brgy. San Isidro',
|
| 282 |
+
'Brgy. San Pedro', 'Brgy. San Miguel', 'Brgy. Mabini',
|
| 283 |
+
'Brgy. Rizal', 'Brgy. Magsaysay', 'Brgy. Quezon',
|
| 284 |
+
'Brgy. Bagong Silang', 'Brgy. Bagumbayan', 'Brgy. Batasan Hills',
|
| 285 |
+
'Brgy. Commonwealth', 'Brgy. Culiat', 'Brgy. Fairview',
|
| 286 |
+
'Brgy. Holy Spirit', 'Brgy. Kamuning', 'Brgy. Laging Handa',
|
| 287 |
+
'Brgy. Malaya', 'Brgy. Masagana', 'Brgy. Pinyahan',
|
| 288 |
+
'Brgy. Roxas', 'Brgy. Sacred Heart', 'Brgy. San Roque',
|
| 289 |
+
'Brgy. Santa Cruz', 'Brgy. Santa Teresita', 'Brgy. Santo Domingo',
|
| 290 |
+
'Brgy. Silangan', 'Brgy. South Triangle', 'Brgy. Tagumpay',
|
| 291 |
+
'Brgy. Tandang Sora', 'Brgy. Vasra', 'Brgy. White Plains',
|
| 292 |
+
]
|
| 293 |
+
|
| 294 |
+
STREETS = [
|
| 295 |
+
'Mabini St.', 'Rizal Ave.', 'MacArthur Hwy.', 'Quezon Blvd.',
|
| 296 |
+
'Gen. Luna St.', 'Bonifacio St.', 'Aguinaldo St.', 'Burgos St.',
|
| 297 |
+
'Del Pilar St.', 'Gomez St.', 'Jacinto St.', 'Lapu-Lapu St.',
|
| 298 |
+
'Lopez Jaena St.', 'Luna St.', 'Osmena Blvd.', 'Padre Faura St.',
|
| 299 |
+
'Palma St.', 'Plaridel St.', 'Recto Ave.', 'Roxas Blvd.',
|
| 300 |
+
'San Andres St.', 'Shaw Blvd.', 'Taft Ave.', 'Tandang Sora Ave.',
|
| 301 |
+
'Timog Ave.', 'Tuazon Blvd.', 'Visayas Ave.', 'Aurora Blvd.',
|
| 302 |
+
'EDSA', 'Espana Blvd.', 'Katipunan Ave.', 'Marcos Hwy.',
|
| 303 |
+
'Ortigas Ave.', 'Quirino Ave.',
|
| 304 |
+
]
|
| 305 |
+
|
| 306 |
+
RELIGIONS = [
|
| 307 |
+
'Roman Catholic', 'Catholic', 'Islam', 'Muslim',
|
| 308 |
+
'Iglesia ni Cristo', 'INC', 'Baptist', 'Methodist',
|
| 309 |
+
'Seventh Day Adventist', 'Born Again Christian', 'Aglipayan',
|
| 310 |
+
]
|
| 311 |
+
|
| 312 |
+
OCCUPATIONS = [
|
| 313 |
+
'Farmer', 'Teacher', 'Engineer', 'Nurse', 'Doctor',
|
| 314 |
+
'Laborer', 'Housewife', 'Driver', 'Carpenter', 'Vendor',
|
| 315 |
+
'Student', 'OFW', 'Fisherman', 'Mechanic', 'Electrician',
|
| 316 |
+
'Police Officer', 'Military', 'Government Employee',
|
| 317 |
+
'Business Owner', 'Retired',
|
| 318 |
+
]
|
| 319 |
+
|
| 320 |
+
CIVIL_STATUSES = ['Single', 'Married', 'Widowed', 'Legally Separated']
|
| 321 |
+
|
| 322 |
+
CITIZENSHIPS = ['Filipino', 'Filipino', 'Filipino', 'American',
|
| 323 |
+
'Chinese', 'Japanese', 'Korean']
|
| 324 |
+
|
| 325 |
+
DEATH_CAUSES = [
|
| 326 |
+
'Cardio-Respiratory Arrest', 'Hypertensive Cardiovascular Disease',
|
| 327 |
+
'Acute Myocardial Infarction', 'Cerebrovascular Accident',
|
| 328 |
+
'Pneumonia', 'Septicemia', 'Renal Failure', 'Diabetes Mellitus',
|
| 329 |
+
'Pulmonary Tuberculosis', 'Cancer of the Lung',
|
| 330 |
+
'Chronic Obstructive Pulmonary Disease', 'Liver Cirrhosis',
|
| 331 |
+
'Dengue Hemorrhagic Fever', 'Acute Gastroenteritis',
|
| 332 |
+
'Congestive Heart Failure',
|
| 333 |
+
]
|
| 334 |
+
|
| 335 |
+
ATTENDANT_TYPES = [
|
| 336 |
+
'Private Physician', 'Public Health Officer',
|
| 337 |
+
'Hospital Authority', 'Hilot', 'None',
|
| 338 |
+
]
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 342 |
+
# NAME LOADER
|
| 343 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 344 |
+
|
| 345 |
+
def load_ph_names():
|
| 346 |
+
"""
|
| 347 |
+
Load Filipino names from ph_names.json.
|
| 348 |
+
Returns (first_names, last_names, middle_names).
|
| 349 |
+
Falls back to built-in lists if JSON not found.
|
| 350 |
+
"""
|
| 351 |
+
if os.path.exists(PH_NAMES_FILE):
|
| 352 |
+
with open(PH_NAMES_FILE, 'r', encoding='utf-8') as f:
|
| 353 |
+
data = json.load(f)
|
| 354 |
+
first_names = data['first_names']['all']
|
| 355 |
+
last_names = data['last_names']
|
| 356 |
+
# Load middle_names from JSON (added by updated generate_ph_names.py)
|
| 357 |
+
# Falls back to last_names if key missing (older ph_names.json)
|
| 358 |
+
middle_names = data.get('middle_names', last_names)
|
| 359 |
+
print(f" Loaded ph_names.json: "
|
| 360 |
+
f"{len(first_names)} first, "
|
| 361 |
+
f"{len(last_names)} last, "
|
| 362 |
+
f"{len(middle_names)} middle names")
|
| 363 |
+
else:
|
| 364 |
+
print(f" WARNING: {PH_NAMES_FILE} not found.")
|
| 365 |
+
print(f" Using built-in fallback names.")
|
| 366 |
+
print(f" For better results run: python generate_ph_names.py first.")
|
| 367 |
+
first_names = [
|
| 368 |
+
'Juan', 'Maria', 'Jose', 'Ana', 'Pedro', 'Rosa', 'Carlos',
|
| 369 |
+
'Elena', 'Ramon', 'Lucia', 'Eduardo', 'Carmen', 'Antonio',
|
| 370 |
+
'Isabel', 'Francisco', 'Gloria', 'Roberto', 'Corazon',
|
| 371 |
+
'Ricardo', 'Remedios', 'Manuel', 'Teresita', 'Andres',
|
| 372 |
+
'Lourdes', 'Fernando', 'Maricel', 'Rolando', 'Rowena',
|
| 373 |
+
'Danilo', 'Cristina', 'Ernesto', 'Marilou', 'Renato',
|
| 374 |
+
'Felicidad', 'Alfredo', 'Natividad', 'Domingo', 'Milagros',
|
| 375 |
+
]
|
| 376 |
+
last_names = [
|
| 377 |
+
'Santos', 'Reyes', 'Cruz', 'Bautista', 'Ocampo', 'Garcia',
|
| 378 |
+
'Mendoza', 'Torres', 'Flores', 'Aquino', 'Dela Cruz',
|
| 379 |
+
'Del Rosario', 'San Jose', 'De Guzman', 'Villanueva',
|
| 380 |
+
'Gonzales', 'Ramos', 'Diaz', 'Castro', 'Morales',
|
| 381 |
+
'Lim', 'Tan', 'Go', 'Chua', 'Sy', 'Ong',
|
| 382 |
+
'Macaraeg', 'Pascual', 'Buenaventura', 'Concepcion',
|
| 383 |
+
'Manalo', 'Soriano', 'Evangelista', 'Salazar', 'Tolentino',
|
| 384 |
+
]
|
| 385 |
+
middle_names = last_names
|
| 386 |
+
return first_names, last_names, middle_names
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 390 |
+
# TEXT GENERATORS
|
| 391 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 392 |
+
|
| 393 |
+
def gen_full_name(first_names, last_names, with_suffix=True):
|
| 394 |
+
first = random.choice(first_names)
|
| 395 |
+
middle = random.choice(MIDDLE_NAMES) if MIDDLE_NAMES else random.choice(last_names)
|
| 396 |
+
last = random.choice(last_names)
|
| 397 |
+
suffix = random.choice(SUFFIXES) if with_suffix else ''
|
| 398 |
+
name = f"{first} {middle} {last}"
|
| 399 |
+
if suffix:
|
| 400 |
+
name += f" {suffix}"
|
| 401 |
+
return name
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def gen_first_name(first_names):
|
| 405 |
+
return random.choice(first_names)
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def gen_last_name(last_names):
|
| 409 |
+
return random.choice(last_names)
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def gen_middle_name(last_names):
|
| 413 |
+
# Always draw from MIDDLE_NAMES (700+ entries from ph_names.json)
|
| 414 |
+
pool = MIDDLE_NAMES if MIDDLE_NAMES else last_names
|
| 415 |
+
return random.choice(pool)
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def gen_date_slash():
|
| 419 |
+
month = random.randint(1, 12)
|
| 420 |
+
day = random.randint(1, 28)
|
| 421 |
+
year = random.randint(1930, 2024)
|
| 422 |
+
return f"{month:02d}/{day:02d}/{year}"
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
def gen_date_long():
|
| 426 |
+
month = random.choice(MONTHS)
|
| 427 |
+
day = random.randint(1, 28)
|
| 428 |
+
year = random.randint(1930, 2024)
|
| 429 |
+
return f"{month} {day}, {year}"
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def gen_date_day():
|
| 433 |
+
return str(random.randint(1, 28))
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def gen_date_month():
|
| 437 |
+
return random.choice(MONTHS)
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def gen_date_year():
|
| 441 |
+
return str(random.randint(1930, 2024))
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def gen_age():
|
| 445 |
+
return str(random.randint(1, 95))
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
def gen_place_full():
|
| 449 |
+
return (f"{random.choice(BARANGAYS)}, "
|
| 450 |
+
f"{random.choice(CITIES)}, "
|
| 451 |
+
f"{random.choice(PROVINCES)}")
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
def gen_place_city():
|
| 455 |
+
return random.choice(CITIES)
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
def gen_place_province():
|
| 459 |
+
return random.choice(PROVINCES)
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
def gen_address():
|
| 463 |
+
num = random.randint(1, 999)
|
| 464 |
+
st = random.choice(STREETS)
|
| 465 |
+
return f"{num} {st}, {random.choice(CITIES)}"
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
def gen_registry_no():
|
| 469 |
+
year = random.randint(2000, 2024)
|
| 470 |
+
seq = random.randint(1, 9999)
|
| 471 |
+
return f"{year}-{seq:04d}"
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
def gen_sex():
|
| 475 |
+
return random.choice(['Male', 'Female'])
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def gen_religion():
|
| 479 |
+
return random.choice(RELIGIONS)
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
def gen_occupation():
|
| 483 |
+
return random.choice(OCCUPATIONS)
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
def gen_civil_status():
|
| 487 |
+
return random.choice(CIVIL_STATUSES)
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
def gen_citizenship():
|
| 491 |
+
return random.choice(CITIZENSHIPS)
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
def gen_weight():
|
| 495 |
+
return f"{random.randint(1500, 4500)} grams"
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
def gen_death_cause():
|
| 499 |
+
return random.choice(DEATH_CAUSES)
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
def gen_attendant():
|
| 503 |
+
return random.choice(ATTENDANT_TYPES)
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 507 |
+
# FORM FIELD DEFINITIONS
|
| 508 |
+
# ─────────────���───────────────────────────────────────────────────────────────
|
| 509 |
+
|
| 510 |
+
def get_form_fields(form_type, first_names, last_names):
|
| 511 |
+
fn = first_names
|
| 512 |
+
ln = last_names
|
| 513 |
+
|
| 514 |
+
if form_type == 'form1a': # Birth Certificate
|
| 515 |
+
return [
|
| 516 |
+
('province', lambda: gen_place_province()),
|
| 517 |
+
('registry_no', lambda: gen_registry_no()),
|
| 518 |
+
('city_municipality', lambda: gen_place_city()),
|
| 519 |
+
('child_first_name', lambda: gen_first_name(fn)),
|
| 520 |
+
('child_middle_name', lambda: gen_middle_name(ln)),
|
| 521 |
+
('child_last_name', lambda: gen_last_name(ln)),
|
| 522 |
+
('sex', lambda: gen_sex()),
|
| 523 |
+
('dob_day', lambda: gen_date_day()),
|
| 524 |
+
('dob_month', lambda: gen_date_month()),
|
| 525 |
+
('dob_year', lambda: gen_date_year()),
|
| 526 |
+
('place_birth_hospital', lambda: f"Ospital ng {gen_place_city()}"),
|
| 527 |
+
('place_birth_city', lambda: gen_place_city()),
|
| 528 |
+
('place_birth_province', lambda: gen_place_province()),
|
| 529 |
+
('weight_at_birth', lambda: gen_weight()),
|
| 530 |
+
('type_of_birth', lambda: random.choice(['Single', 'Twin', 'Triplet'])),
|
| 531 |
+
('mother_first_name', lambda: gen_first_name(fn)),
|
| 532 |
+
('mother_middle_name', lambda: gen_middle_name(ln)),
|
| 533 |
+
('mother_last_name', lambda: gen_last_name(ln)),
|
| 534 |
+
('mother_citizenship', lambda: gen_citizenship()),
|
| 535 |
+
('mother_religion', lambda: gen_religion()),
|
| 536 |
+
('mother_occupation', lambda: gen_occupation()),
|
| 537 |
+
('mother_age_at_birth', lambda: str(random.randint(16, 45))),
|
| 538 |
+
('mother_residence_house', lambda: gen_address()),
|
| 539 |
+
('mother_residence_city', lambda: gen_place_city()),
|
| 540 |
+
('mother_residence_province', lambda: gen_place_province()),
|
| 541 |
+
('father_first_name', lambda: gen_first_name(fn)),
|
| 542 |
+
('father_middle_name', lambda: gen_middle_name(ln)),
|
| 543 |
+
('father_last_name', lambda: gen_last_name(ln)),
|
| 544 |
+
('father_citizenship', lambda: gen_citizenship()),
|
| 545 |
+
('father_religion', lambda: gen_religion()),
|
| 546 |
+
('father_occupation', lambda: gen_occupation()),
|
| 547 |
+
('father_age_at_birth', lambda: str(random.randint(18, 55))),
|
| 548 |
+
('parents_marriage_month', lambda: gen_date_month()),
|
| 549 |
+
('parents_marriage_day', lambda: gen_date_day()),
|
| 550 |
+
('parents_marriage_year', lambda: gen_date_year()),
|
| 551 |
+
('parents_marriage_city', lambda: gen_place_city()),
|
| 552 |
+
('informant_name', lambda: gen_full_name(fn, ln, False)),
|
| 553 |
+
('informant_address', lambda: gen_address()),
|
| 554 |
+
('informant_date', lambda: gen_date_slash()),
|
| 555 |
+
]
|
| 556 |
+
|
| 557 |
+
elif form_type == 'form2a': # Death Certificate
|
| 558 |
+
return [
|
| 559 |
+
('province', lambda: gen_place_province()),
|
| 560 |
+
('registry_no', lambda: gen_registry_no()),
|
| 561 |
+
('city_municipality', lambda: gen_place_city()),
|
| 562 |
+
('deceased_first_name', lambda: gen_first_name(fn)),
|
| 563 |
+
('deceased_middle_name', lambda: gen_middle_name(ln)),
|
| 564 |
+
('deceased_last_name', lambda: gen_last_name(ln)),
|
| 565 |
+
('sex', lambda: gen_sex()),
|
| 566 |
+
('religion', lambda: gen_religion()),
|
| 567 |
+
('age_years', lambda: gen_age()),
|
| 568 |
+
('place_death_full', lambda: f"{gen_place_city()}, {gen_place_province()}"),
|
| 569 |
+
('dod_day', lambda: gen_date_day()),
|
| 570 |
+
('dod_month', lambda: gen_date_month()),
|
| 571 |
+
('dod_year', lambda: gen_date_year()),
|
| 572 |
+
('citizenship', lambda: gen_citizenship()),
|
| 573 |
+
('residence_full', lambda: gen_address()),
|
| 574 |
+
('civil_status', lambda: gen_civil_status()),
|
| 575 |
+
('occupation', lambda: gen_occupation()),
|
| 576 |
+
('cause_immediate', lambda: gen_death_cause()),
|
| 577 |
+
('cause_antecedent', lambda: gen_death_cause()),
|
| 578 |
+
('cause_underlying', lambda: gen_death_cause()),
|
| 579 |
+
('cause_other', lambda: gen_death_cause()),
|
| 580 |
+
('informant_name', lambda: gen_full_name(fn, ln, False)),
|
| 581 |
+
('informant_address', lambda: gen_address()),
|
| 582 |
+
('informant_date', lambda: gen_date_slash()),
|
| 583 |
+
]
|
| 584 |
+
|
| 585 |
+
elif form_type == 'form3a': # Marriage Certificate
|
| 586 |
+
return [
|
| 587 |
+
('province', lambda: gen_place_province()),
|
| 588 |
+
('city_municipality', lambda: gen_place_city()),
|
| 589 |
+
('registry_no', lambda: gen_registry_no()),
|
| 590 |
+
('husband_first_name', lambda: gen_first_name(fn)),
|
| 591 |
+
('husband_middle_name', lambda: gen_middle_name(ln)),
|
| 592 |
+
('husband_last_name', lambda: gen_last_name(ln)),
|
| 593 |
+
('wife_first_name', lambda: gen_first_name(fn)),
|
| 594 |
+
('wife_middle_name', lambda: gen_middle_name(ln)),
|
| 595 |
+
('wife_last_name', lambda: gen_last_name(ln)),
|
| 596 |
+
('husband_dob_day', lambda: gen_date_day()),
|
| 597 |
+
('husband_dob_month', lambda: gen_date_month()),
|
| 598 |
+
('husband_dob_year', lambda: gen_date_year()),
|
| 599 |
+
('husband_age', lambda: gen_age()),
|
| 600 |
+
('wife_dob_day', lambda: gen_date_day()),
|
| 601 |
+
('wife_dob_month', lambda: gen_date_month()),
|
| 602 |
+
('wife_dob_year', lambda: gen_date_year()),
|
| 603 |
+
('wife_age', lambda: gen_age()),
|
| 604 |
+
('husband_place_birth_city', lambda: gen_place_city()),
|
| 605 |
+
('husband_place_birth_province', lambda: gen_place_province()),
|
| 606 |
+
('wife_place_birth_city', lambda: gen_place_city()),
|
| 607 |
+
('wife_place_birth_province', lambda: gen_place_province()),
|
| 608 |
+
('husband_citizenship', lambda: gen_citizenship()),
|
| 609 |
+
('wife_citizenship', lambda: gen_citizenship()),
|
| 610 |
+
('husband_religion', lambda: gen_religion()),
|
| 611 |
+
('wife_religion', lambda: gen_religion()),
|
| 612 |
+
('husband_civil_status', lambda: gen_civil_status()),
|
| 613 |
+
('wife_civil_status', lambda: gen_civil_status()),
|
| 614 |
+
('husband_father_first', lambda: gen_first_name(fn)),
|
| 615 |
+
('husband_father_last', lambda: gen_last_name(ln)),
|
| 616 |
+
('wife_father_first', lambda: gen_first_name(fn)),
|
| 617 |
+
('wife_father_last', lambda: gen_last_name(ln)),
|
| 618 |
+
('husband_mother_first', lambda: gen_first_name(fn)),
|
| 619 |
+
('husband_mother_last', lambda: gen_last_name(ln)),
|
| 620 |
+
('wife_mother_first', lambda: gen_first_name(fn)),
|
| 621 |
+
('wife_mother_last', lambda: gen_last_name(ln)),
|
| 622 |
+
('place_marriage_city', lambda: gen_place_city()),
|
| 623 |
+
('place_marriage_province', lambda: gen_place_province()),
|
| 624 |
+
('date_marriage_day', lambda: gen_date_day()),
|
| 625 |
+
('date_marriage_month', lambda: gen_date_month()),
|
| 626 |
+
('date_marriage_year', lambda: gen_date_year()),
|
| 627 |
+
]
|
| 628 |
+
|
| 629 |
+
elif form_type == 'form90': # Marriage License Application
|
| 630 |
+
return [
|
| 631 |
+
('province', lambda: gen_place_province()),
|
| 632 |
+
('city_municipality', lambda: gen_place_city()),
|
| 633 |
+
('registry_no', lambda: gen_registry_no()),
|
| 634 |
+
('husband_first_name', lambda: gen_first_name(fn)),
|
| 635 |
+
('husband_middle_name', lambda: gen_middle_name(ln)),
|
| 636 |
+
('husband_last_name', lambda: gen_last_name(ln)),
|
| 637 |
+
('wife_first_name', lambda: gen_first_name(fn)),
|
| 638 |
+
('wife_middle_name', lambda: gen_middle_name(ln)),
|
| 639 |
+
('wife_last_name', lambda: gen_last_name(ln)),
|
| 640 |
+
('husband_age', lambda: gen_age()),
|
| 641 |
+
('wife_age', lambda: gen_age()),
|
| 642 |
+
('husband_citizenship', lambda: gen_citizenship()),
|
| 643 |
+
('wife_citizenship', lambda: gen_citizenship()),
|
| 644 |
+
('husband_residence', lambda: gen_address()),
|
| 645 |
+
('wife_residence', lambda: gen_address()),
|
| 646 |
+
('application_date', lambda: gen_date_slash()),
|
| 647 |
+
]
|
| 648 |
+
|
| 649 |
+
return []
|
| 650 |
+
|
| 651 |
+
|
| 652 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 653 |
+
# MAIN GENERATOR
|
| 654 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 655 |
+
|
| 656 |
+
def generate_dataset():
|
| 657 |
+
print("=" * 65)
|
| 658 |
+
print(" fix_data.py — Synthetic Training Data Generator")
|
| 659 |
+
print("=" * 65)
|
| 660 |
+
|
| 661 |
+
# Load Filipino names
|
| 662 |
+
print("\n[1/4] Loading Filipino names...")
|
| 663 |
+
first_names, last_names, middle_names = load_ph_names()
|
| 664 |
+
|
| 665 |
+
# Populate global MIDDLE_NAMES so all generators use the full 700+ pool
|
| 666 |
+
global MIDDLE_NAMES
|
| 667 |
+
MIDDLE_NAMES.clear()
|
| 668 |
+
MIDDLE_NAMES.extend(middle_names)
|
| 669 |
+
print(f" Middle names pool active: {len(MIDDLE_NAMES)} entries")
|
| 670 |
+
|
| 671 |
+
# Create output directories
|
| 672 |
+
print("\n[2/4] Creating output directories...")
|
| 673 |
+
for split in ['train', 'val']:
|
| 674 |
+
for form in ['form1a', 'form2a', 'form3a', 'form90']:
|
| 675 |
+
Path(f'data/{split}/{form}').mkdir(parents=True, exist_ok=True)
|
| 676 |
+
print(" ✓ Directories ready")
|
| 677 |
+
|
| 678 |
+
# Load font pool — multiple typefaces so model generalises across fonts
|
| 679 |
+
print("\n[3/4] Loading fonts...")
|
| 680 |
+
font_pool = load_font_pool(FONT_SIZE)
|
| 681 |
+
print(f" ✓ {len(font_pool)} font(s) loaded")
|
| 682 |
+
|
| 683 |
+
# Generate images
|
| 684 |
+
print("\n[4/4] Generating images...")
|
| 685 |
+
print(f" {'Form':<10} {'Total':>7} {'Train':>7} {'Val':>7}")
|
| 686 |
+
print(f" {'-'*35}")
|
| 687 |
+
|
| 688 |
+
train_annotations = []
|
| 689 |
+
val_annotations = []
|
| 690 |
+
total_generated = 0
|
| 691 |
+
|
| 692 |
+
for form_type, n_samples in SAMPLES_PER_FORM.items():
|
| 693 |
+
fields = get_form_fields(form_type, first_names, last_names)
|
| 694 |
+
samples_per_field = max(1, n_samples // len(fields))
|
| 695 |
+
form_train = 0
|
| 696 |
+
form_val = 0
|
| 697 |
+
|
| 698 |
+
# Pre-build shuffled val assignment for unbiased 10% split
|
| 699 |
+
total_this_form = samples_per_field * len(fields)
|
| 700 |
+
val_flags = [False] * total_this_form
|
| 701 |
+
val_indices = random.sample(
|
| 702 |
+
range(total_this_form),
|
| 703 |
+
max(1, int(total_this_form * VAL_SPLIT))
|
| 704 |
+
)
|
| 705 |
+
for vi in val_indices:
|
| 706 |
+
val_flags[vi] = True
|
| 707 |
+
|
| 708 |
+
img_idx = 0
|
| 709 |
+
for field_name, generator in fields:
|
| 710 |
+
for _ in range(samples_per_field):
|
| 711 |
+
text = generator()
|
| 712 |
+
if not text or not text.strip():
|
| 713 |
+
img_idx += 1
|
| 714 |
+
continue
|
| 715 |
+
|
| 716 |
+
# 70% handwriting / 30% printed
|
| 717 |
+
use_handwriting = random.random() < 0.70
|
| 718 |
+
# Pick a random font from the pool each image — forces
|
| 719 |
+
# the model to generalise across typefaces, not memorise one font
|
| 720 |
+
font = random.choice(font_pool)
|
| 721 |
+
img = render_text_image(text, font, handwriting=use_handwriting)
|
| 722 |
+
fname = f"{field_name}_{img_idx:06d}.jpg"
|
| 723 |
+
|
| 724 |
+
is_val = val_flags[img_idx] if img_idx < len(val_flags) else False
|
| 725 |
+
|
| 726 |
+
if is_val:
|
| 727 |
+
out_path = f"data/val/{form_type}/{fname}"
|
| 728 |
+
val_annotations.append({
|
| 729 |
+
'image_path': f"{form_type}/{fname}",
|
| 730 |
+
'text': text,
|
| 731 |
+
})
|
| 732 |
+
form_val += 1
|
| 733 |
+
else:
|
| 734 |
+
out_path = f"data/train/{form_type}/{fname}"
|
| 735 |
+
train_annotations.append({
|
| 736 |
+
'image_path': f"{form_type}/{fname}",
|
| 737 |
+
'text': text,
|
| 738 |
+
})
|
| 739 |
+
form_train += 1
|
| 740 |
+
|
| 741 |
+
img.save(out_path, quality=95)
|
| 742 |
+
img_idx += 1
|
| 743 |
+
|
| 744 |
+
total_generated += form_train + form_val
|
| 745 |
+
print(f" {form_type:<10} {form_train + form_val:>7,} "
|
| 746 |
+
f"{form_train:>7,} {form_val:>7,}")
|
| 747 |
+
|
| 748 |
+
# Save annotation files
|
| 749 |
+
with open('data/train_annotations.json', 'w', encoding='utf-8') as f:
|
| 750 |
+
json.dump(train_annotations, f, indent=2, ensure_ascii=False)
|
| 751 |
+
|
| 752 |
+
with open('data/val_annotations.json', 'w', encoding='utf-8') as f:
|
| 753 |
+
json.dump(val_annotations, f, indent=2, ensure_ascii=False)
|
| 754 |
+
|
| 755 |
+
# Summary
|
| 756 |
+
print(f"\n{'=' * 65}")
|
| 757 |
+
print(f" DONE!")
|
| 758 |
+
print(f"{'=' * 65}")
|
| 759 |
+
print(f" Total images generated : {total_generated:,}")
|
| 760 |
+
print(f" Train images : {len(train_annotations):,}")
|
| 761 |
+
print(f" Val images : {len(val_annotations):,}")
|
| 762 |
+
print(f"\n Saved:")
|
| 763 |
+
print(f" data/train_annotations.json ({len(train_annotations)} entries)")
|
| 764 |
+
print(f" data/val_annotations.json ({len(val_annotations)} entries)")
|
| 765 |
+
print(f"\n Next step: python train.py")
|
| 766 |
+
print(f"{'=' * 65}")
|
| 767 |
+
|
| 768 |
+
|
| 769 |
+
if __name__ == '__main__':
|
| 770 |
+
generate_dataset()
|
generate_dummy_forms.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
generate_dummy_forms.py
|
| 3 |
+
=======================
|
| 4 |
+
Generates dummy-filled civil registry forms by overlaying
|
| 5 |
+
handwritten-style text onto the ACTUAL blank PDF form templates.
|
| 6 |
+
|
| 7 |
+
Coordinates measured directly from grid images (200 DPI render).
|
| 8 |
+
Form 102: 1700 x 2800 px
|
| 9 |
+
Form 103: 1700 x 2600 px
|
| 10 |
+
Form 97: 1700 x 2600 px
|
| 11 |
+
Form 90: 1700 x 2600 px
|
| 12 |
+
|
| 13 |
+
Usage:
|
| 14 |
+
python generate_dummy_forms.py
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import os, random
|
| 18 |
+
import fitz
|
| 19 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 20 |
+
|
| 21 |
+
OUT_DIR = "dummy_forms"
|
| 22 |
+
FORMS_DIR = "CRNN+CTC"
|
| 23 |
+
os.makedirs(OUT_DIR, exist_ok=True)
|
| 24 |
+
|
| 25 |
+
PDF_102 = os.path.join(FORMS_DIR, "FORM 102 (BIRTH CERTIFICATE).pdf")
|
| 26 |
+
PDF_103 = os.path.join(FORMS_DIR, "FORM 103 (DEATH CERTIFICATE).pdf")
|
| 27 |
+
PDF_97 = os.path.join(FORMS_DIR, "FORM 97 (MARRIAGE CERTIFICATE).pdf")
|
| 28 |
+
PDF_90 = os.path.join(FORMS_DIR, "FORM 90-MARRIAGE-LICENCE-FORM.pdf")
|
| 29 |
+
|
| 30 |
+
HW_FONT = "C:/Windows/Fonts/Inkfree.ttf"
|
| 31 |
+
|
| 32 |
+
def get_font(size):
|
| 33 |
+
try: return ImageFont.truetype(HW_FONT, size)
|
| 34 |
+
except: return ImageFont.load_default()
|
| 35 |
+
|
| 36 |
+
# ── Filipino dummy data ──────────────────────────────────────────────────────
|
| 37 |
+
FM = ["Juan","Pedro","Jose","Carlos","Roberto","Eduardo","Miguel","Antonio","Ramon","Fernando","Andres","Ricardo"]
|
| 38 |
+
FF = ["Maria","Rosa","Elena","Luisa","Carmen","Gloria","Lourdes","Felicitas","Conchita","Remedios","Natividad","Cristina"]
|
| 39 |
+
MID = ["dela Cruz","Reyes","Santos","Garcia","Lopez","Mendoza","Torres","Aquino","Bautista","Villanueva","Castro","Ramos"]
|
| 40 |
+
LN = ["Santos","Reyes","Cruz","Garcia","Mendoza","Torres","Lopez","Ramos","Bautista","Aquino","Villanueva","Castro"]
|
| 41 |
+
CTY = ["Tarlac City","Makati City","Quezon City","Manila","Caloocan","Pasig City","Marikina City","Malabon"]
|
| 42 |
+
PRV = ["Tarlac","Metro Manila","Cavite","Laguna","Bulacan","Pampanga","Rizal","Batangas"]
|
| 43 |
+
HSP = ["Tarlac Provincial Hospital","Ospital ng Maynila","Philippine General Hospital",
|
| 44 |
+
"Quezon City Medical Center","San Juan de Dios Hospital","Capitol Medical Center"]
|
| 45 |
+
REL = ["Roman Catholic","Iglesia ni Cristo","Protestant","Born Again Christian"]
|
| 46 |
+
OCC = ["Farmer","Teacher","Engineer","Driver","Housewife","Businessman","Carpenter","Nurse","Laborer"]
|
| 47 |
+
CST = ["Single","Married","Widowed"]
|
| 48 |
+
MON = ["January","February","March","April","May","June",
|
| 49 |
+
"July","August","September","October","November","December"]
|
| 50 |
+
VEN = ["Saint Joseph Parish","City Hall","San Sebastian Cathedral","Sto. Nino Parish","Saint Peter Parish"]
|
| 51 |
+
COD = ["Cardiopulmonary Arrest","Pneumonia","Myocardial Infarction","Renal Failure","Sepsis"]
|
| 52 |
+
|
| 53 |
+
rm = lambda: random.choice(FM)
|
| 54 |
+
rf = lambda: random.choice(FF)
|
| 55 |
+
rmd = lambda: random.choice(MID)
|
| 56 |
+
rln = lambda: random.choice(LN)
|
| 57 |
+
rc = lambda: random.choice(CTY)
|
| 58 |
+
rp = lambda: random.choice(PRV)
|
| 59 |
+
rd = lambda: str(random.randint(1, 28))
|
| 60 |
+
rmo = lambda: random.choice(MON)
|
| 61 |
+
ry = lambda s=1960, e=2005: str(random.randint(s, e))
|
| 62 |
+
rrn = lambda pfx: f"{random.randint(2020,2025)}-{pfx}-{random.randint(1000,9999):05d}"
|
| 63 |
+
|
| 64 |
+
# ── Core helpers ─────────────────────────────────────────────────────────────
|
| 65 |
+
def pdf_to_image(pdf_path, dpi=200):
|
| 66 |
+
doc = fitz.open(pdf_path)
|
| 67 |
+
mat = fitz.Matrix(dpi/72, dpi/72)
|
| 68 |
+
pix = doc[0].get_pixmap(matrix=mat)
|
| 69 |
+
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
|
| 70 |
+
doc.close()
|
| 71 |
+
return img
|
| 72 |
+
|
| 73 |
+
X_OFFSET = 170 # shift all fields right — increase if still too far left
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def hw(draw, x, y, text, size=20, color="#1a1a6e"):
|
| 77 |
+
font = get_font(size)
|
| 78 |
+
ox = random.randint(-1, 1)
|
| 79 |
+
oy = random.randint(-1, 1)
|
| 80 |
+
draw.text((x + X_OFFSET + ox, y+oy), str(text), fill=color, font=font)
|
| 81 |
+
|
| 82 |
+
def save(img, name):
|
| 83 |
+
path = os.path.join(OUT_DIR, name)
|
| 84 |
+
img.save(path, dpi=(200, 200))
|
| 85 |
+
print(f" Saved: {path}")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 89 |
+
# FORM 102 — Certificate of Live Birth (1700 x 2800)
|
| 90 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 91 |
+
def generate_form_102(n):
|
| 92 |
+
img, draw = pdf_to_image(PDF_102), None
|
| 93 |
+
draw = ImageDraw.Draw(img)
|
| 94 |
+
|
| 95 |
+
def f(x, y, text, size=20): hw(draw, x, y, text, size)
|
| 96 |
+
|
| 97 |
+
# ── Header ──────────────────────────────────────────────────
|
| 98 |
+
f(178, 322, rp()) # Province
|
| 99 |
+
f(1255, 322, rrn("BC")) # Registry No
|
| 100 |
+
f(178, 370, rc()) # City/Municipality
|
| 101 |
+
|
| 102 |
+
# ── CHILD ─────────────��─────────────────────────────────────
|
| 103 |
+
f(205, 438, rm()) # 1. NAME First
|
| 104 |
+
f(600, 438, rmd()) # Middle
|
| 105 |
+
f(1060, 438, rln()) # Last
|
| 106 |
+
|
| 107 |
+
f(205, 495, random.choice(["Male","Female"])) # 2. SEX
|
| 108 |
+
f(585, 495, rd()) # 3. DATE OF BIRTH Day
|
| 109 |
+
f(742, 495, rmo()) # Month
|
| 110 |
+
f(975, 495, ry(1970,2024)) # Year
|
| 111 |
+
|
| 112 |
+
f(295, 552, random.choice(HSP)) # 4. PLACE OF BIRTH Hospital
|
| 113 |
+
f(738, 552, rc()) # City
|
| 114 |
+
f(1120, 552, rp()) # Province
|
| 115 |
+
|
| 116 |
+
f(205, 603, "Single") # 5a. TYPE OF BIRTH
|
| 117 |
+
f(900, 603, "First") # 5c. BIRTH ORDER
|
| 118 |
+
|
| 119 |
+
# ── MOTHER ──────────────────────────────────────────────────
|
| 120 |
+
f(205, 695, rf()) # 7. MAIDEN NAME First
|
| 121 |
+
f(600, 695, rmd()) # Middle
|
| 122 |
+
f(1060, 695, rln()) # Last
|
| 123 |
+
|
| 124 |
+
f(205, 752, "Filipino") # 8. CITIZENSHIP
|
| 125 |
+
f(685, 752, random.choice(REL)) # 9. RELIGION
|
| 126 |
+
|
| 127 |
+
f(645, 810, random.choice(OCC)) # 11. OCCUPATION
|
| 128 |
+
f(1415, 810, str(random.randint(20,50))) # 12. AGE
|
| 129 |
+
|
| 130 |
+
f(295, 870, f"{random.randint(1,999)} Rizal St., Brgy. San Antonio") # 13. RESIDENCE
|
| 131 |
+
f(738, 870, rc())
|
| 132 |
+
f(1120, 870, rp())
|
| 133 |
+
|
| 134 |
+
# ── FATHER ──────────────────────────────────────────────────
|
| 135 |
+
f(205, 985, rm()) # 14. NAME First
|
| 136 |
+
f(600, 985, rmd()) # Middle
|
| 137 |
+
f(1060, 985, rln()) # Last
|
| 138 |
+
|
| 139 |
+
f(205, 1048, "Filipino") # 15. CITIZENSHIP
|
| 140 |
+
f(425, 1048, random.choice(REL)) # 16. RELIGION
|
| 141 |
+
f(785, 1048, random.choice(OCC)) # 17. OCCUPATION
|
| 142 |
+
f(1415, 1048, str(random.randint(22,55))) # 18. AGE
|
| 143 |
+
|
| 144 |
+
f(295, 1105, f"{random.randint(1,999)} Mabini St., Brgy. Poblacion") # 19. RESIDENCE
|
| 145 |
+
f(738, 1105, rc())
|
| 146 |
+
f(1120, 1105, rp())
|
| 147 |
+
|
| 148 |
+
# ── MARRIAGE OF PARENTS ──────────────────────────────────────
|
| 149 |
+
f(175, 1215, rmo()) # 20a. DATE Month
|
| 150 |
+
f(385, 1215, rd()) # Day
|
| 151 |
+
f(510, 1215, ry(1960,2010)) # Year
|
| 152 |
+
f(762, 1215, rc()) # 20b. PLACE City
|
| 153 |
+
f(1062, 1215, rp()) # Province
|
| 154 |
+
|
| 155 |
+
save(img, f"form_102_{n:03d}.png")
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 159 |
+
# FORM 103 — Certificate of Death (1700 x 2600)
|
| 160 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 161 |
+
def generate_form_103(n):
|
| 162 |
+
img = pdf_to_image(PDF_103)
|
| 163 |
+
draw = ImageDraw.Draw(img)
|
| 164 |
+
|
| 165 |
+
def f(x, y, text, size=20): hw(draw, x, y, text, size)
|
| 166 |
+
|
| 167 |
+
# ── Header ──────────────────────────────────────────────────
|
| 168 |
+
f(178, 218, rp()) # Province
|
| 169 |
+
f(1255, 218, rrn("DC")) # Registry No
|
| 170 |
+
f(178, 260, rc()) # City/Municipality
|
| 171 |
+
|
| 172 |
+
# ── Row 1 NAME + 2 SEX ──────────────────────────────────────
|
| 173 |
+
f(178, 335, rm()) # 1. NAME First
|
| 174 |
+
f(535, 335, rmd()) # Middle
|
| 175 |
+
f(878, 335, rln()) # Last
|
| 176 |
+
f(1325, 335, random.choice(["Male","Female"])) # 2. SEX
|
| 177 |
+
|
| 178 |
+
# ── Row 3 DATE OF DEATH + 4 DATE OF BIRTH + 5 AGE ───────────
|
| 179 |
+
f(178, 418, f"{rd()} {rmo()} {ry(2010,2025)}") # 3. DATE OF DEATH
|
| 180 |
+
f(618, 418, rd()) # 4. DATE OF BIRTH Day
|
| 181 |
+
f(712, 418, rmo()) # Month
|
| 182 |
+
f(830, 418, ry(1930,1990)) # Year
|
| 183 |
+
f(1055, 418, str(random.randint(30,90))) # 5. AGE
|
| 184 |
+
|
| 185 |
+
# ── Row 6 PLACE OF DEATH + 7 CIVIL STATUS ───────────────────
|
| 186 |
+
f(178, 480, f"{random.choice(HSP)}, {rc()}") # 6. PLACE OF DEATH
|
| 187 |
+
f(1255, 480, random.choice(CST)) # 7. CIVIL STATUS
|
| 188 |
+
|
| 189 |
+
# ── Row 8 RELIGION + 9 CITIZENSHIP + 10 RESIDENCE ───────────
|
| 190 |
+
f(178, 548, random.choice(REL)) # 8. RELIGION
|
| 191 |
+
f(555, 548, "Filipino") # 9. CITIZENSHIP
|
| 192 |
+
f(818, 548, f"{random.randint(1,999)} Rizal St., {rc()}") # 10. RESIDENCE
|
| 193 |
+
|
| 194 |
+
# ── Row 11 OCCUPATION + 12 FATHER + 13 MOTHER ───────────────
|
| 195 |
+
f(178, 628, random.choice(OCC)) # 11. OCCUPATION
|
| 196 |
+
f(428, 628, f"{rm()} {rmd()} {rln()}") # 12. NAME OF FATHER
|
| 197 |
+
f(1028, 628, f"{rf()} {rmd()} {rln()}") # 13. MAIDEN NAME OF MOTHER
|
| 198 |
+
|
| 199 |
+
# ── 19b CAUSES OF DEATH ──────────────────────────────────────
|
| 200 |
+
f(368, 905, random.choice(COD)) # Immediate cause
|
| 201 |
+
f(368, 953, random.choice(["Hypertensive CVD","COPD","Septicemia"])) # Antecedent
|
| 202 |
+
f(368, 1003, random.choice(["Hypertension","Diabetes Mellitus","Old Age"])) # Underlying
|
| 203 |
+
|
| 204 |
+
save(img, f"form_103_{n:03d}.png")
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 208 |
+
# FORM 97 — Certificate of Marriage (1700 x 2600)
|
| 209 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 210 |
+
def generate_form_97(n):
|
| 211 |
+
img = pdf_to_image(PDF_97)
|
| 212 |
+
draw = ImageDraw.Draw(img)
|
| 213 |
+
|
| 214 |
+
def f(x, y, text, size=20): hw(draw, x, y, text, size)
|
| 215 |
+
|
| 216 |
+
# ── Header ──────────────────────────────────────────────────
|
| 217 |
+
f(178, 242, rp()) # Province
|
| 218 |
+
f(1385, 242, rrn("MC")) # Registry No
|
| 219 |
+
f(178, 282, rc()) # City/Municipality
|
| 220 |
+
|
| 221 |
+
# ── Row 1 NAME ───────────────────────────────────────────────
|
| 222 |
+
# HUSBAND # WIFE
|
| 223 |
+
f(178, 372, rm()); f(892, 372, rf()) # First
|
| 224 |
+
f(178, 410, rmd()); f(892, 410, rmd()) # Middle
|
| 225 |
+
f(178, 448, rln()); f(892, 448, rln()) # Last
|
| 226 |
+
|
| 227 |
+
# ── Row 2a DATE OF BIRTH / 2b AGE ───────────────────────────
|
| 228 |
+
h_dob_y = ry(1975, 2000)
|
| 229 |
+
w_dob_y = ry(1975, 2000)
|
| 230 |
+
h_age = str(random.randint(18,45))
|
| 231 |
+
w_age = str(random.randint(18,45))
|
| 232 |
+
|
| 233 |
+
f(178, 493, rd()); f(892, 493, rd()) # Day
|
| 234 |
+
f(308, 493, rmo()); f(1022, 493, rmo()) # Month
|
| 235 |
+
f(478, 493, h_dob_y); f(1188, 493, w_dob_y) # Year
|
| 236 |
+
f(635, 493, h_age); f(1348, 493, w_age) # Age
|
| 237 |
+
|
| 238 |
+
# ── Row 3 PLACE OF BIRTH ─────────────────────────────────────
|
| 239 |
+
f(178, 548, rc()); f(892, 548, rc()) # City
|
| 240 |
+
f(418, 548, rp()); f(1122, 548, rp()) # Province
|
| 241 |
+
|
| 242 |
+
# ── Row 4a SEX / 4b CITIZENSHIP ─────────────────────────────
|
| 243 |
+
f(178, 618, "Male"); f(892, 618, "Female")
|
| 244 |
+
f(352, 618, "Filipino"); f(1062, 618, "Filipino")
|
| 245 |
+
|
| 246 |
+
# ── Row 5 RESIDENCE ──────────────────────────────────────────
|
| 247 |
+
f(178, 672, f"{random.randint(1,999)} Rizal St., {rc()}, {rp()}")
|
| 248 |
+
f(892, 672, f"{random.randint(1,999)} Mabini St., {rc()}, {rp()}")
|
| 249 |
+
|
| 250 |
+
# ── Row 6 RELIGION ───────────────────────────────────────────
|
| 251 |
+
f(178, 768, random.choice(REL)); f(892, 768, random.choice(REL))
|
| 252 |
+
|
| 253 |
+
# ── Row 7 CIVIL STATUS ───────────────────────────────────────
|
| 254 |
+
f(178, 835, "Single"); f(892, 835, "Single")
|
| 255 |
+
|
| 256 |
+
# ── Row 8 NAME OF FATHER ─────────────────────────────────────
|
| 257 |
+
f(178, 902, rm()); f(892, 902, rm()) # First
|
| 258 |
+
f(368, 902, rmd()); f(1088, 902, rmd()) # Middle
|
| 259 |
+
f(562, 902, rln()); f(1278, 902, rln()) # Last
|
| 260 |
+
|
| 261 |
+
# ── Row 9 CITIZENSHIP (Father) ───────────────────────────────
|
| 262 |
+
f(178, 985, "Filipino"); f(892, 985, "Filipino")
|
| 263 |
+
|
| 264 |
+
# ── Row 10 NAME OF MOTHER ────────────────────────────────────
|
| 265 |
+
f(178, 1050, rf()); f(892, 1050, rf()) # First
|
| 266 |
+
f(368, 1050, rmd()); f(1088, 1050, rmd()) # Middle
|
| 267 |
+
f(562, 1050, rln()); f(1278, 1050, rln()) # Last
|
| 268 |
+
|
| 269 |
+
# ── Row 11 CITIZENSHIP (Mother) ──────────────────────────────
|
| 270 |
+
f(178, 1138, "Filipino"); f(892, 1138, "Filipino")
|
| 271 |
+
|
| 272 |
+
# ── Row 15 PLACE OF MARRIAGE ─────────────────────────────────
|
| 273 |
+
f(222, 1578, random.choice(VEN)) # Office/Church
|
| 274 |
+
f(698, 1578, rc()) # City
|
| 275 |
+
f(1102, 1578, rp()) # Province
|
| 276 |
+
|
| 277 |
+
# ── Row 16 DATE OF MARRIAGE ──────────────────────────────────
|
| 278 |
+
f(178, 1638, rd()) # Day
|
| 279 |
+
f(308, 1638, rmo()) # Month
|
| 280 |
+
f(502, 1638, ry(2015,2025)) # Year
|
| 281 |
+
f(1282, 1638, f"{random.randint(8,11)}:00 {random.choice(['AM','PM'])}") # 17. TIME
|
| 282 |
+
|
| 283 |
+
save(img, f"form_97_{n:03d}.png")
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 287 |
+
# FORM 90 — Application for Marriage License (1700 x 2600)
|
| 288 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 289 |
+
def generate_form_90(n):
|
| 290 |
+
img = pdf_to_image(PDF_90)
|
| 291 |
+
draw = ImageDraw.Draw(img)
|
| 292 |
+
|
| 293 |
+
def f(x, y, text, size=20): hw(draw, x, y, text, size)
|
| 294 |
+
|
| 295 |
+
# ── Header ──────────────────────────────────────────────────
|
| 296 |
+
f(178, 232, rp()) # Province
|
| 297 |
+
f(1252, 232, rrn("ML")) # Registry No
|
| 298 |
+
f(178, 272, rc()) # City/Municipality
|
| 299 |
+
f(1002, 308, rrn("LN")) # Marriage License No
|
| 300 |
+
f(1002, 348, f"{rmo()} {rd()}, {ry(2023,2025)}") # Date of Issuance
|
| 301 |
+
|
| 302 |
+
# ── 1. NAME OF APPLICANT ─────────────────────────────────────
|
| 303 |
+
# GROOM (left) # BRIDE (right)
|
| 304 |
+
f(102, 708, rm()); f(872, 708, rf()) # First
|
| 305 |
+
f(102, 752, rmd()); f(872, 752, rmd()) # Middle
|
| 306 |
+
f(102, 795, rln()); f(872, 795, rln()) # Last
|
| 307 |
+
|
| 308 |
+
# ── 2. DATE OF BIRTH / AGE ───────────────────────────────────
|
| 309 |
+
f(102, 835, rd()); f(872, 835, rd()) # Day
|
| 310 |
+
f(228, 835, rmo()); f(998, 835, rmo()) # Month
|
| 311 |
+
f(388, 835, ry(1980,2005)); f(1158, 835, ry(1980,2005)) # Year
|
| 312 |
+
f(568, 835, str(random.randint(18,45))); f(1338, 835, str(random.randint(18,45))) # Age
|
| 313 |
+
|
| 314 |
+
# ── 3. PLACE OF BIRTH ────────────────────────────────────────
|
| 315 |
+
f(102, 882, rc()); f(872, 882, rc()) # City
|
| 316 |
+
f(288, 882, rp()); f(1058, 882, rp()) # Province
|
| 317 |
+
|
| 318 |
+
# ── 4. SEX / CITIZENSHIP ─────────────────────────────────────
|
| 319 |
+
f(102, 952, "Male"); f(872, 952, "Female")
|
| 320 |
+
f(268, 952, "Filipino"); f(1038, 952, "Filipino")
|
| 321 |
+
|
| 322 |
+
# ── 5. RESIDENCE ─────────────────────────────────────────────
|
| 323 |
+
f(102, 1015, f"{random.randint(1,999)} Rizal St., {rc()}")
|
| 324 |
+
f(872, 1015, f"{random.randint(1,999)} Mabini St., {rc()}")
|
| 325 |
+
|
| 326 |
+
# ── 6. RELIGION ──────────────────────────────────────────────
|
| 327 |
+
f(102, 1100, random.choice(REL))
|
| 328 |
+
f(872, 1100, random.choice(REL))
|
| 329 |
+
|
| 330 |
+
# ── 7. CIVIL STATUS ──────────────────────────────────────────
|
| 331 |
+
f(102, 1175, "Single"); f(872, 1175, "Single")
|
| 332 |
+
|
| 333 |
+
# ── 12. NAME OF FATHER ───────────────────────────────────────
|
| 334 |
+
f(102, 1562, rm()); f(872, 1562, rm()) # First
|
| 335 |
+
f(272, 1562, rmd()); f(1042, 1562, rmd()) # Middle
|
| 336 |
+
f(462, 1562, rln()); f(1232, 1562, rln()) # Last
|
| 337 |
+
|
| 338 |
+
# ── 13. CITIZENSHIP (Father) ─────────────────────────────────
|
| 339 |
+
f(102, 1642, "Filipino"); f(872, 1642, "Filipino")
|
| 340 |
+
|
| 341 |
+
# ── 15. MAIDEN NAME OF MOTHER ────────────────────────────────
|
| 342 |
+
f(102, 1762, rf()); f(872, 1762, rf()) # First
|
| 343 |
+
f(272, 1762, rmd()); f(1042, 1762, rmd()) # Middle
|
| 344 |
+
f(462, 1762, rln()); f(1232, 1762, rln()) # Last
|
| 345 |
+
|
| 346 |
+
# ── 16. CITIZENSHIP (Mother) ─────────────────────────────────
|
| 347 |
+
f(102, 1842, "Filipino"); f(872, 1842, "Filipino")
|
| 348 |
+
|
| 349 |
+
save(img, f"form_90_{n:03d}.png")
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 353 |
+
# MAIN
|
| 354 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 355 |
+
if __name__ == "__main__":
|
| 356 |
+
COUNT = 1 # ← set to 30 when alignment is confirmed
|
| 357 |
+
|
| 358 |
+
missing = [p for p in [PDF_102, PDF_103, PDF_97, PDF_90] if not os.path.exists(p)]
|
| 359 |
+
if missing:
|
| 360 |
+
print("ERROR: Missing PDF form files:")
|
| 361 |
+
for m in missing: print(f" {m}")
|
| 362 |
+
exit(1)
|
| 363 |
+
|
| 364 |
+
print(f"Generating {COUNT} dummy forms per type ({COUNT*4} total)...")
|
| 365 |
+
print(f"Output: {os.path.abspath(OUT_DIR)}/\n")
|
| 366 |
+
|
| 367 |
+
for i in range(1, COUNT + 1):
|
| 368 |
+
print(f"[{i}/{COUNT}]")
|
| 369 |
+
generate_form_102(i)
|
| 370 |
+
generate_form_103(i)
|
| 371 |
+
generate_form_97(i)
|
| 372 |
+
generate_form_90(i)
|
| 373 |
+
|
| 374 |
+
print(f"\nDone! {COUNT*4} forms saved to: {os.path.abspath(OUT_DIR)}/")
|
| 375 |
+
print("\nNext: upload dummy_forms/ to Roboflow and annotate field bounding boxes.")
|
generate_form_samples.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
generate_form_samples.py
|
| 3 |
+
========================
|
| 4 |
+
Generates thousands of synthetic filled civil registry form images
|
| 5 |
+
using the blank PDF forms + template_matcher.py coordinates.
|
| 6 |
+
|
| 7 |
+
Each form is filled with random Filipino names/dates in handwriting fonts.
|
| 8 |
+
Crops are saved with labels → ready for CRNN+CTC fine-tuning.
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
python generate_form_samples.py
|
| 12 |
+
|
| 13 |
+
Output:
|
| 14 |
+
data/train/real_forms/ -- cropped field images
|
| 15 |
+
data/real_annotations.json -- labels for fine-tuning
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
import json
|
| 21 |
+
import random
|
| 22 |
+
import datetime
|
| 23 |
+
|
| 24 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 25 |
+
|
| 26 |
+
# ── Paths ─────────────────────────────────────────────────────
|
| 27 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 28 |
+
ROOT_DIR = os.path.dirname(BASE_DIR)
|
| 29 |
+
PYTHON_DIR = ROOT_DIR # template_matcher.py is here
|
| 30 |
+
|
| 31 |
+
NAMES_FILE = os.path.join(BASE_DIR, 'data', 'ph_names.json')
|
| 32 |
+
OUT_IMG_DIR = os.path.join(BASE_DIR, 'data', 'train', 'real_forms')
|
| 33 |
+
OUT_ANN = os.path.join(BASE_DIR, 'data', 'real_annotations.json')
|
| 34 |
+
|
| 35 |
+
FONTS_DIR = os.path.join(ROOT_DIR, 'test_images', 'handwriting_fonts')
|
| 36 |
+
|
| 37 |
+
# Only verified-working Google Fonts URLs
|
| 38 |
+
GOOGLE_FONTS = {
|
| 39 |
+
'Kalam-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/kalam/Kalam-Regular.ttf',
|
| 40 |
+
'Kalam-Bold.ttf': 'https://github.com/google/fonts/raw/main/ofl/kalam/Kalam-Bold.ttf',
|
| 41 |
+
'Kalam-Light.ttf': 'https://github.com/google/fonts/raw/main/ofl/kalam/Kalam-Light.ttf',
|
| 42 |
+
'PatrickHand-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/patrickhand/PatrickHand-Regular.ttf',
|
| 43 |
+
'IndieFlower-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/indieflower/IndieFlower-Regular.ttf',
|
| 44 |
+
'Handlee-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/handlee/Handlee-Regular.ttf',
|
| 45 |
+
'GochiHand-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/gochihand/GochiHand-Regular.ttf',
|
| 46 |
+
'ArchitectsDaughter.ttf': 'https://github.com/google/fonts/raw/main/ofl/architectsdaughter/ArchitectsDaughter-Regular.ttf',
|
| 47 |
+
'ShadowsIntoLight.ttf': 'https://github.com/google/fonts/raw/main/ofl/shadowsintolight/ShadowsIntoLight.ttf',
|
| 48 |
+
'ShadowsIntoLightTwo.ttf': 'https://github.com/google/fonts/raw/main/ofl/shadowsintolighttwo/ShadowsIntoLightTwo-Regular.ttf',
|
| 49 |
+
'Kristi-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/kristi/Kristi-Regular.ttf',
|
| 50 |
+
'AmaticSC-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/amaticsc/AmaticSC-Regular.ttf',
|
| 51 |
+
'AmaticSC-Bold.ttf': 'https://github.com/google/fonts/raw/main/ofl/amaticsc/AmaticSC-Bold.ttf',
|
| 52 |
+
'BadScript-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/badscript/BadScript-Regular.ttf',
|
| 53 |
+
'Sacramento-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/sacramento/Sacramento-Regular.ttf',
|
| 54 |
+
'GreatVibes-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/greatvibes/GreatVibes-Regular.ttf',
|
| 55 |
+
'Allura-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/allura/Allura-Regular.ttf',
|
| 56 |
+
'AlexBrush-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/alexbrush/AlexBrush-Regular.ttf',
|
| 57 |
+
'Parisienne-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/parisienne/Parisienne-Regular.ttf',
|
| 58 |
+
'Tangerine-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/tangerine/Tangerine-Regular.ttf',
|
| 59 |
+
'Tangerine-Bold.ttf': 'https://github.com/google/fonts/raw/main/ofl/tangerine/Tangerine-Bold.ttf',
|
| 60 |
+
'Courgette-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/courgette/Courgette-Regular.ttf',
|
| 61 |
+
'Niconne-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/niconne/Niconne-Regular.ttf',
|
| 62 |
+
'MarckScript-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/marckscript/MarckScript-Regular.ttf',
|
| 63 |
+
'Norican-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/norican/Norican-Regular.ttf',
|
| 64 |
+
'Damion-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/damion/Damion-Regular.ttf',
|
| 65 |
+
'Satisfy-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/satisfy/Satisfy-Regular.ttf',
|
| 66 |
+
'Pacifico-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/pacifico/Pacifico-Regular.ttf',
|
| 67 |
+
'Italianno-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/italianno/Italianno-Regular.ttf',
|
| 68 |
+
'Pompiere-Regular.ttf': 'https://github.com/google/fonts/raw/main/ofl/pompiere/Pompiere-Regular.ttf',
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
FONT_PATHS = [
|
| 72 |
+
# Downloaded handwriting fonts
|
| 73 |
+
*[os.path.join(FONTS_DIR, name) for name in GOOGLE_FONTS],
|
| 74 |
+
# Already available
|
| 75 |
+
os.path.join(ROOT_DIR, 'test_images', 'Caveat-Regular.ttf'),
|
| 76 |
+
# Windows fallbacks
|
| 77 |
+
r'C:\Windows\Fonts\segoepr.ttf',
|
| 78 |
+
r'C:\Windows\Fonts\segoeprb.ttf',
|
| 79 |
+
r'C:\Windows\Fonts\comic.ttf',
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
def download_fonts():
|
| 83 |
+
"""Download handwriting fonts from Google Fonts if not present."""
|
| 84 |
+
import urllib.request
|
| 85 |
+
os.makedirs(FONTS_DIR, exist_ok=True)
|
| 86 |
+
ok = 0
|
| 87 |
+
for fname, url in GOOGLE_FONTS.items():
|
| 88 |
+
dest = os.path.join(FONTS_DIR, fname)
|
| 89 |
+
if os.path.exists(dest) and os.path.getsize(dest) > 10000:
|
| 90 |
+
ok += 1
|
| 91 |
+
continue
|
| 92 |
+
try:
|
| 93 |
+
print(f" Downloading {fname}...")
|
| 94 |
+
with urllib.request.urlopen(url, timeout=10) as r, open(dest, 'wb') as f:
|
| 95 |
+
f.write(r.read())
|
| 96 |
+
# Validate: real TTF files are > 10KB
|
| 97 |
+
if os.path.getsize(dest) < 10000:
|
| 98 |
+
os.remove(dest)
|
| 99 |
+
print(f" Skipped {fname} (invalid file)")
|
| 100 |
+
else:
|
| 101 |
+
ok += 1
|
| 102 |
+
except Exception as e:
|
| 103 |
+
print(f" Failed {fname}: {e}")
|
| 104 |
+
if os.path.exists(dest):
|
| 105 |
+
os.remove(dest)
|
| 106 |
+
print(f" {ok} fonts ready")
|
| 107 |
+
|
| 108 |
+
PDF_FORMS = {
|
| 109 |
+
'97': os.path.join(ROOT_DIR, 'python', 'CRNN+CTC', 'FORM 97 (MARRIAGE CERTIFICATE).pdf'),
|
| 110 |
+
'102': os.path.join(ROOT_DIR, 'python', 'CRNN+CTC', 'FORM 102 (BIRTH CERTIFICATE).pdf'),
|
| 111 |
+
'103': os.path.join(ROOT_DIR, 'python', 'CRNN+CTC', 'FORM 103 (DEATH CERTIFICATE).pdf'),
|
| 112 |
+
'90': os.path.join(ROOT_DIR, 'python', 'CRNN+CTC', 'FORM 90-MARRIAGE-LICENCE-FORM.pdf'),
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
SAMPLES_PER_FORM = 1000 # forms to generate per type
|
| 116 |
+
IMG_W = 64
|
| 117 |
+
IMG_H = 512
|
| 118 |
+
|
| 119 |
+
# ── Load TEMPLATES from template_matcher ─────────────────────
|
| 120 |
+
sys.path.insert(0, PYTHON_DIR)
|
| 121 |
+
from template_matcher import TEMPLATES
|
| 122 |
+
|
| 123 |
+
# ── Load Filipino names ───────────────────────────────────────
|
| 124 |
+
def load_names():
|
| 125 |
+
if not os.path.exists(NAMES_FILE):
|
| 126 |
+
print(f"ERROR: {NAMES_FILE} not found. Run generate_ph_names.py first.")
|
| 127 |
+
sys.exit(1)
|
| 128 |
+
with open(NAMES_FILE) as f:
|
| 129 |
+
data = json.load(f)
|
| 130 |
+
return data
|
| 131 |
+
|
| 132 |
+
# ── Random data generators ────────────────────────────────────
|
| 133 |
+
MONTHS = ['January','February','March','April','May','June',
|
| 134 |
+
'July','August','September','October','November','December']
|
| 135 |
+
RELIGIONS = ['Roman Catholic','Islam','Baptist','Iglesia ni Cristo',
|
| 136 |
+
'Seventh Day Adventist','Born Again Christian']
|
| 137 |
+
CIVIL_STATUSES = ['Single','Married','Widowed','Legally Separated']
|
| 138 |
+
CITIZENSHIPS = ['Filipino','American','Chinese','Japanese']
|
| 139 |
+
PROVINCES = ['Cebu','Davao del Sur','Metro Manila','Iloilo','Pampanga',
|
| 140 |
+
'Batangas','Laguna','Cavite','Bulacan','Quezon City']
|
| 141 |
+
CITIES = ['Cebu City','Davao City','Manila','Iloilo City','San Fernando',
|
| 142 |
+
'Batangas City','Santa Rosa','Bacoor','Malolos','Quezon City']
|
| 143 |
+
|
| 144 |
+
def rand_name(names, key):
|
| 145 |
+
pool = names.get(key, ['Juan'])
|
| 146 |
+
return random.choice(pool).upper()
|
| 147 |
+
|
| 148 |
+
def rand_date():
|
| 149 |
+
y = random.randint(1950, 2005)
|
| 150 |
+
m = random.randint(1, 12)
|
| 151 |
+
d = random.randint(1, 28)
|
| 152 |
+
return f"{d:02d}", MONTHS[m-1], str(y)
|
| 153 |
+
|
| 154 |
+
def rand_age():
|
| 155 |
+
return str(random.randint(18, 80))
|
| 156 |
+
|
| 157 |
+
def rand_province():
|
| 158 |
+
return random.choice(PROVINCES).upper()
|
| 159 |
+
|
| 160 |
+
def rand_city():
|
| 161 |
+
return random.choice(CITIES).upper()
|
| 162 |
+
|
| 163 |
+
def rand_religion():
|
| 164 |
+
return random.choice(RELIGIONS).upper()
|
| 165 |
+
|
| 166 |
+
def rand_civil_status():
|
| 167 |
+
return random.choice(CIVIL_STATUSES).upper()
|
| 168 |
+
|
| 169 |
+
def rand_citizenship():
|
| 170 |
+
return random.choice(CITIZENSHIPS).upper()
|
| 171 |
+
|
| 172 |
+
def rand_registry_no():
|
| 173 |
+
return f"{random.randint(2000,2024)}-{random.randint(1000,9999)}"
|
| 174 |
+
|
| 175 |
+
def rand_time():
|
| 176 |
+
h = random.randint(6, 18)
|
| 177 |
+
m = random.choice(['00','15','30','45'])
|
| 178 |
+
return f"{h:02d}:{m} {'AM' if h < 12 else 'PM'}"
|
| 179 |
+
|
| 180 |
+
def generate_field_value(field_name, names):
|
| 181 |
+
"""Generate a plausible random value for a given field name."""
|
| 182 |
+
f = field_name.lower()
|
| 183 |
+
if 'province' in f: return rand_province()
|
| 184 |
+
if 'registry' in f: return rand_registry_no()
|
| 185 |
+
if 'city' in f or 'municipality' in f: return rand_city()
|
| 186 |
+
if 'first' in f and ('name' in f or 'father' in f or 'mother' in f):
|
| 187 |
+
return rand_name(names, 'first')
|
| 188 |
+
if 'middle' in f: return rand_name(names, 'middle')
|
| 189 |
+
if 'last' in f: return rand_name(names, 'last')
|
| 190 |
+
if '_name' in f and 'father' not in f and 'mother' not in f:
|
| 191 |
+
return rand_name(names, 'first')
|
| 192 |
+
if 'father_name' in f or 'mother_name' in f:
|
| 193 |
+
return f"{rand_name(names,'first')} {rand_name(names,'middle')} {rand_name(names,'last')}"
|
| 194 |
+
if 'dob_day' in f or 'day' in f: return rand_date()[0]
|
| 195 |
+
if 'dob_month' in f or 'month' in f: return rand_date()[1]
|
| 196 |
+
if 'dob_year' in f or 'year' in f: return rand_date()[2]
|
| 197 |
+
if 'dob' in f and 'day' not in f and 'month' not in f and 'year' not in f:
|
| 198 |
+
d,m,y = rand_date(); return f"{d} {m} {y}"
|
| 199 |
+
if 'age' in f: return rand_age()
|
| 200 |
+
if 'birth' in f and 'place' in f: return rand_city()
|
| 201 |
+
if 'place_of_birth' in f: return rand_city()
|
| 202 |
+
if 'sex' in f: return random.choice(['MALE','FEMALE'])
|
| 203 |
+
if 'citizenship' in f: return rand_citizenship()
|
| 204 |
+
if 'residence' in f: return f"{rand_city()}, {rand_province()}"
|
| 205 |
+
if 'religion' in f: return rand_religion()
|
| 206 |
+
if 'civil_status' in f: return rand_civil_status()
|
| 207 |
+
if 'place_of_marriage' in f: return rand_city()
|
| 208 |
+
if 'date_of_marriage' in f:
|
| 209 |
+
d,m,y = rand_date(); return f"{d} {m} {y}"
|
| 210 |
+
if 'time_of_marriage' in f: return rand_time()
|
| 211 |
+
if 'marriage_date' in f:
|
| 212 |
+
d,m,y = rand_date(); return f"{d} {m} {y}"
|
| 213 |
+
if 'marriage_place' in f: return rand_city()
|
| 214 |
+
if 'marriage_license' in f: return rand_registry_no()
|
| 215 |
+
if 'date_issued' in f:
|
| 216 |
+
d,m,y = rand_date(); return f"{d} {m} {y}"
|
| 217 |
+
if 'occupation' in f: return random.choice(['FARMER','TEACHER','NURSE','ENGINEER','DRIVER','HOUSEWIFE'])
|
| 218 |
+
if 'type_of_birth' in f: return random.choice(['SINGLE','TWIN','TRIPLET'])
|
| 219 |
+
if 'birth_order' in f: return random.choice(['1ST','2ND','3RD','4TH'])
|
| 220 |
+
if 'weight' in f: return f"{random.randint(2,5)}.{random.randint(0,9)} KG"
|
| 221 |
+
if 'cause' in f: return random.choice(['CARDIAC ARREST','PNEUMONIA','DIABETES','HYPERTENSION'])
|
| 222 |
+
if 'father_name' in f: return f"{rand_name(names,'first')} {rand_name(names,'last')}"
|
| 223 |
+
if 'mother_name' in f: return f"{rand_name(names,'first')} {rand_name(names,'last')}"
|
| 224 |
+
return rand_name(names, 'first')
|
| 225 |
+
|
| 226 |
+
# ── Load fonts ────────────────────────────────────────────────
|
| 227 |
+
def load_fonts():
|
| 228 |
+
fonts = []
|
| 229 |
+
for path in FONT_PATHS:
|
| 230 |
+
if os.path.exists(path):
|
| 231 |
+
for size in [14, 16, 18, 20]:
|
| 232 |
+
try:
|
| 233 |
+
fonts.append(ImageFont.truetype(path, size))
|
| 234 |
+
except:
|
| 235 |
+
pass
|
| 236 |
+
if not fonts:
|
| 237 |
+
fonts = [ImageFont.load_default()]
|
| 238 |
+
print(f" Loaded {len(fonts)} font variants")
|
| 239 |
+
return fonts
|
| 240 |
+
|
| 241 |
+
# ── Load blank form image ─────────────────────────────────────
|
| 242 |
+
def load_blank_form(form_type):
|
| 243 |
+
"""Convert PDF to image or use a reference scan as background."""
|
| 244 |
+
pdf_path = PDF_FORMS.get(form_type)
|
| 245 |
+
|
| 246 |
+
# Try pdf2image first
|
| 247 |
+
if pdf_path and os.path.exists(pdf_path):
|
| 248 |
+
try:
|
| 249 |
+
from pdf2image import convert_from_path
|
| 250 |
+
pages = convert_from_path(pdf_path, dpi=150)
|
| 251 |
+
if pages:
|
| 252 |
+
return pages[0].convert('RGB')
|
| 253 |
+
except Exception as e:
|
| 254 |
+
print(f" pdf2image failed: {e}")
|
| 255 |
+
|
| 256 |
+
# Fallback: use reference image (try png, jpg, jpeg)
|
| 257 |
+
for ext in ['png', 'jpg', 'jpeg']:
|
| 258 |
+
ref_path = os.path.join(ROOT_DIR, 'references', f'reference_{form_type}.{ext}')
|
| 259 |
+
if os.path.exists(ref_path):
|
| 260 |
+
return Image.open(ref_path).convert('RGB')
|
| 261 |
+
# Also try hyphen variant (e.g. reference-90.jpg)
|
| 262 |
+
for ext in ['png', 'jpg', 'jpeg']:
|
| 263 |
+
ref_path = os.path.join(ROOT_DIR, 'references', f'reference-{form_type}.{ext}')
|
| 264 |
+
if os.path.exists(ref_path):
|
| 265 |
+
return Image.open(ref_path).convert('RGB')
|
| 266 |
+
|
| 267 |
+
print(f" WARNING: No blank form found for {form_type} — skipping")
|
| 268 |
+
return None
|
| 269 |
+
|
| 270 |
+
# ── Render text on form ───────────────────────────────────────
|
| 271 |
+
def render_field(draw, x1r, y1r, x2r, y2r, text, img_w, img_h, fonts):
|
| 272 |
+
"""Draw handwritten-style text in a field box."""
|
| 273 |
+
x1 = int(x1r * img_w)
|
| 274 |
+
y1 = int(y1r * img_h)
|
| 275 |
+
x2 = int(x2r * img_w)
|
| 276 |
+
y2 = int(y2r * img_h)
|
| 277 |
+
|
| 278 |
+
box_w = max(x2 - x1, 1)
|
| 279 |
+
box_h = max(y2 - y1, 1)
|
| 280 |
+
|
| 281 |
+
# Pick a font that fits
|
| 282 |
+
font = random.choice(fonts)
|
| 283 |
+
for f in fonts:
|
| 284 |
+
bbox = f.getbbox(text)
|
| 285 |
+
fw = bbox[2] - bbox[0]
|
| 286 |
+
fh = bbox[3] - bbox[1]
|
| 287 |
+
if fw <= box_w * 0.95 and fh <= box_h * 1.2:
|
| 288 |
+
font = f
|
| 289 |
+
break
|
| 290 |
+
|
| 291 |
+
# Random pen color (dark blue/black like ballpen)
|
| 292 |
+
r = random.randint(0, 40)
|
| 293 |
+
g = random.randint(0, 40)
|
| 294 |
+
b = random.randint(60, 120)
|
| 295 |
+
color = (r, g, b)
|
| 296 |
+
|
| 297 |
+
# Center text vertically in box
|
| 298 |
+
bbox = font.getbbox(text)
|
| 299 |
+
fh = bbox[3] - bbox[1]
|
| 300 |
+
ty = y1 + (box_h - fh) // 2
|
| 301 |
+
|
| 302 |
+
# Slight random x offset
|
| 303 |
+
tx = x1 + random.randint(2, max(3, box_w // 10))
|
| 304 |
+
|
| 305 |
+
draw.text((tx, ty), text, fill=color, font=font)
|
| 306 |
+
|
| 307 |
+
# ── Crop a field ──────────────────────────────────────────────
|
| 308 |
+
def crop_field(img, x1r, y1r, x2r, y2r):
|
| 309 |
+
w, h = img.size
|
| 310 |
+
x1 = max(0, int(x1r * w) - 4)
|
| 311 |
+
y1 = max(0, int(y1r * h) - 4)
|
| 312 |
+
x2 = min(w, int(x2r * w) + 4)
|
| 313 |
+
y2 = min(h, int(y2r * h) + 4)
|
| 314 |
+
return img.crop((x1, y1, x2, y2))
|
| 315 |
+
|
| 316 |
+
# ── Main ────────────────────────────────────────────────────��─
|
| 317 |
+
def main():
|
| 318 |
+
print("=" * 60)
|
| 319 |
+
print(" Form Sample Generator")
|
| 320 |
+
print("=" * 60)
|
| 321 |
+
|
| 322 |
+
os.makedirs(OUT_IMG_DIR, exist_ok=True)
|
| 323 |
+
print("\n Downloading handwriting fonts...")
|
| 324 |
+
download_fonts()
|
| 325 |
+
names = load_names()
|
| 326 |
+
fonts = load_fonts()
|
| 327 |
+
annotations = []
|
| 328 |
+
total = 0
|
| 329 |
+
|
| 330 |
+
for form_type, template in TEMPLATES.items():
|
| 331 |
+
print(f"\n Generating Form {form_type}...")
|
| 332 |
+
|
| 333 |
+
blank = load_blank_form(form_type)
|
| 334 |
+
if blank is None:
|
| 335 |
+
continue
|
| 336 |
+
|
| 337 |
+
for i in range(SAMPLES_PER_FORM):
|
| 338 |
+
# Fresh copy of blank form
|
| 339 |
+
form_img = blank.copy()
|
| 340 |
+
draw = ImageDraw.Draw(form_img)
|
| 341 |
+
img_w, img_h = form_img.size
|
| 342 |
+
|
| 343 |
+
field_values = {}
|
| 344 |
+
for field_name, coords in template.items():
|
| 345 |
+
x1r, y1r, x2r, y2r, _ = coords
|
| 346 |
+
text = generate_field_value(field_name, names)
|
| 347 |
+
field_values[field_name] = text
|
| 348 |
+
render_field(draw, x1r, y1r, x2r, y2r, text, img_w, img_h, fonts)
|
| 349 |
+
|
| 350 |
+
# Save full form preview (first sample only)
|
| 351 |
+
if i == 0:
|
| 352 |
+
preview_path = os.path.join(OUT_IMG_DIR, f'form{form_type}_preview.png')
|
| 353 |
+
form_img.save(preview_path)
|
| 354 |
+
print(f" Preview saved: {preview_path}")
|
| 355 |
+
|
| 356 |
+
# Crop each field and save
|
| 357 |
+
for field_name, coords in template.items():
|
| 358 |
+
x1r, y1r, x2r, y2r, _ = coords
|
| 359 |
+
crop = crop_field(form_img, x1r, y1r, x2r, y2r)
|
| 360 |
+
crop = crop.convert('L') # grayscale
|
| 361 |
+
|
| 362 |
+
fname = f"form{form_type}_{i:05d}_{field_name}.png"
|
| 363 |
+
fpath = os.path.join(OUT_IMG_DIR, fname)
|
| 364 |
+
crop.save(fpath)
|
| 365 |
+
|
| 366 |
+
annotations.append({
|
| 367 |
+
"image_path": f"real_forms/{fname}",
|
| 368 |
+
"text": field_values[field_name]
|
| 369 |
+
})
|
| 370 |
+
total += 1
|
| 371 |
+
|
| 372 |
+
if (i + 1) % 100 == 0:
|
| 373 |
+
print(f" {i+1}/{SAMPLES_PER_FORM} forms done ({total} crops so far)")
|
| 374 |
+
|
| 375 |
+
print(f" Form {form_type} done.")
|
| 376 |
+
|
| 377 |
+
# Save annotations
|
| 378 |
+
with open(OUT_ANN, 'w') as f:
|
| 379 |
+
json.dump(annotations, f, indent=2)
|
| 380 |
+
|
| 381 |
+
print(f"\n{'='*60}")
|
| 382 |
+
print(f" DONE!")
|
| 383 |
+
print(f" Total crops : {total}")
|
| 384 |
+
print(f" Annotations : {OUT_ANN}")
|
| 385 |
+
print(f" Next step : upload to Kaggle and run fine-tune")
|
| 386 |
+
print(f"{'='*60}")
|
| 387 |
+
|
| 388 |
+
if __name__ == '__main__':
|
| 389 |
+
main()
|
generate_ph_names.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
generate_ph_names.py
|
| 3 |
+
====================
|
| 4 |
+
Run this file ONCE to extract Filipino names from the
|
| 5 |
+
names-dataset library and save them to data/ph_names.json.
|
| 6 |
+
|
| 7 |
+
Install first:
|
| 8 |
+
pip install names-dataset
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
python generate_ph_names.py
|
| 12 |
+
|
| 13 |
+
Output:
|
| 14 |
+
data/ph_names.json <-- used by fix_data.py every run
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
|
| 20 |
+
print("=" * 60)
|
| 21 |
+
print(" Filipino Name Extractor | names-dataset (PyPI)")
|
| 22 |
+
print("=" * 60)
|
| 23 |
+
|
| 24 |
+
# ── Step 1: Load NameDataset ──────────────────────────────────
|
| 25 |
+
print("\n[1/5] Loading NameDataset...")
|
| 26 |
+
print(" (This takes 30-60 seconds and needs ~3.2 GB RAM)")
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
from names_dataset import NameDataset
|
| 30 |
+
nd = NameDataset()
|
| 31 |
+
print(" OK - Dataset loaded!")
|
| 32 |
+
except ImportError:
|
| 33 |
+
print("\n ERROR: names-dataset is not installed.")
|
| 34 |
+
print(" Fix: pip install names-dataset")
|
| 35 |
+
exit(1)
|
| 36 |
+
except MemoryError:
|
| 37 |
+
print("\n ERROR: Not enough RAM. Need ~3.2 GB free.")
|
| 38 |
+
exit(1)
|
| 39 |
+
|
| 40 |
+
# ── Step 2: Extract Filipino FIRST names ─────────────────────
|
| 41 |
+
print("\n[2/5] Extracting Filipino first names (Male + Female)...")
|
| 42 |
+
|
| 43 |
+
ph_male = nd.get_top_names(n=300, gender='Male', country_alpha2='PH')
|
| 44 |
+
ph_female = nd.get_top_names(n=300, gender='Female', country_alpha2='PH')
|
| 45 |
+
|
| 46 |
+
# API returns: { 'PH': { 'M': [...] } }
|
| 47 |
+
male_first = ph_male.get('PH', {}).get('M', [])
|
| 48 |
+
female_first = ph_female.get('PH', {}).get('F', [])
|
| 49 |
+
all_first = male_first + female_first
|
| 50 |
+
|
| 51 |
+
print(f" Male first names : {len(male_first)}")
|
| 52 |
+
print(f" Female first names : {len(female_first)}")
|
| 53 |
+
print(f" Total first names : {len(all_first)}")
|
| 54 |
+
print(f" Sample (male) : {male_first[:5]}")
|
| 55 |
+
print(f" Sample (female) : {female_first[:5]}")
|
| 56 |
+
|
| 57 |
+
# ── Step 3: Extract Filipino LAST names ──────────────────────
|
| 58 |
+
print("\n[3/5] Extracting Filipino last names...")
|
| 59 |
+
|
| 60 |
+
ph_last_raw = nd.get_top_names(n=300, country_alpha2='PH', use_first_names=False)
|
| 61 |
+
print(f" Raw last name API type : {type(ph_last_raw)}")
|
| 62 |
+
|
| 63 |
+
ph_last_ph = ph_last_raw.get('PH', {})
|
| 64 |
+
print(f" PH entry type : {type(ph_last_ph)}")
|
| 65 |
+
|
| 66 |
+
raw_last = []
|
| 67 |
+
|
| 68 |
+
if isinstance(ph_last_ph, list):
|
| 69 |
+
raw_last = ph_last_ph
|
| 70 |
+
elif isinstance(ph_last_ph, dict):
|
| 71 |
+
first_val = next(iter(ph_last_ph.values()), None)
|
| 72 |
+
if isinstance(first_val, list):
|
| 73 |
+
for lst in ph_last_ph.values():
|
| 74 |
+
raw_last.extend(lst)
|
| 75 |
+
elif isinstance(first_val, dict):
|
| 76 |
+
raw_last = list(ph_last_ph.keys())
|
| 77 |
+
else:
|
| 78 |
+
raw_last = list(ph_last_ph.keys())
|
| 79 |
+
|
| 80 |
+
# Deduplicate while preserving order
|
| 81 |
+
seen = set()
|
| 82 |
+
all_last = []
|
| 83 |
+
for name in raw_last:
|
| 84 |
+
if isinstance(name, str) and name not in seen:
|
| 85 |
+
seen.add(name)
|
| 86 |
+
all_last.append(name)
|
| 87 |
+
|
| 88 |
+
print(f" Total last names : {len(all_last)}")
|
| 89 |
+
print(f" Sample : {all_last[:5]}")
|
| 90 |
+
|
| 91 |
+
if len(all_last) == 0:
|
| 92 |
+
print("\n WARNING: Could not extract last names from API.")
|
| 93 |
+
print(" Using common Filipino last names as fallback...")
|
| 94 |
+
all_last = [
|
| 95 |
+
'Santos', 'Reyes', 'Cruz', 'Bautista', 'Ocampo',
|
| 96 |
+
'Garcia', 'Mendoza', 'Torres', 'Flores', 'Aquino',
|
| 97 |
+
'Dela Cruz', 'Del Rosario', 'San Jose', 'De Guzman',
|
| 98 |
+
'Villanueva', 'Gonzales', 'Ramos', 'Diaz', 'Castro',
|
| 99 |
+
'Morales', 'Ortega', 'Gutierrez', 'Lopez', 'Ramirez',
|
| 100 |
+
'Navarro', 'Aguilar', 'Espinosa', 'Mercado', 'Tolentino',
|
| 101 |
+
'Lim', 'Tan', 'Go', 'Chua', 'Sy', 'Ong', 'Co',
|
| 102 |
+
'Macaraeg', 'Macapagal', 'Magsaysay', 'Magno',
|
| 103 |
+
'Pascual', 'Buenaventura', 'Concepcion', 'Resurreccion',
|
| 104 |
+
'Ilagan', 'Manalo', 'Soriano', 'Evangelista', 'Salazar',
|
| 105 |
+
]
|
| 106 |
+
print(f" Fallback last names: {len(all_last)}")
|
| 107 |
+
|
| 108 |
+
# ── Step 4: Build MIDDLE names pool ──────────────────────────
|
| 109 |
+
# Middle names in Filipino naming convention are the mother's
|
| 110 |
+
# maiden last name. We build a large pool by combining:
|
| 111 |
+
# A) The last names pool already extracted (primary source)
|
| 112 |
+
# B) A curated extended list of common Filipino surnames
|
| 113 |
+
# used specifically as middle names
|
| 114 |
+
print("\n[4/5] Building middle names pool...")
|
| 115 |
+
|
| 116 |
+
EXTENDED_MIDDLE_NAMES = [
|
| 117 |
+
# Common Filipino surnames used as middle names
|
| 118 |
+
'Abad', 'Abaya', 'Abella', 'Ablaza', 'Abrera',
|
| 119 |
+
'Acosta', 'Adriano', 'Afable', 'Africa', 'Agcaoili',
|
| 120 |
+
'Agno', 'Agpalo', 'Aguinaldo', 'Agustin', 'Ahorro',
|
| 121 |
+
'Alano', 'Alba', 'Albano', 'Alberto', 'Alcantara',
|
| 122 |
+
'Alcazar', 'Alcon', 'Aldana', 'Alegre', 'Alejandro',
|
| 123 |
+
'Aligaen', 'Alim', 'Alinea', 'Alipio', 'Almario',
|
| 124 |
+
'Almeda', 'Almendras', 'Alminiana', 'Almodiel', 'Alonto',
|
| 125 |
+
'Alvarado', 'Alvarez', 'Amante', 'Amaro', 'Ambrocio',
|
| 126 |
+
'Amor', 'Amores', 'Amparo', 'Anastacio', 'Andal',
|
| 127 |
+
'Andaya', 'Angeles', 'Angsioco', 'Antiporda', 'Antonio',
|
| 128 |
+
'Apalisok', 'Apolinario', 'Apostol', 'Aquino', 'Araneta',
|
| 129 |
+
'Aranas', 'Aranda', 'Arceo', 'Arenas', 'Arias',
|
| 130 |
+
'Ariate', 'Arillo', 'Arimado', 'Arjona', 'Arlante',
|
| 131 |
+
'Arnaldo', 'Arnaiz', 'Arnoco', 'Arocena', 'Arroyo',
|
| 132 |
+
'Asejo', 'Asuncion', 'Austria', 'Avecilla', 'Avena',
|
| 133 |
+
'Avila', 'Avinante', 'Ayala', 'Azucena', 'Azul',
|
| 134 |
+
'Bacani', 'Bacunawa', 'Baguio', 'Bagunu', 'Balagtas',
|
| 135 |
+
'Balangue', 'Balbin', 'Balde', 'Baldeo', 'Balgos',
|
| 136 |
+
'Balili', 'Balinas', 'Balitaan', 'Balladares', 'Ballesteros',
|
| 137 |
+
'Balmeo', 'Balmores', 'Banaag', 'Banaag', 'Bandola',
|
| 138 |
+
'Bangayan', 'Bansil', 'Bansode', 'Bantigue', 'Bantug',
|
| 139 |
+
'Barbin', 'Barcenas', 'Bareng', 'Barrion', 'Barroga',
|
| 140 |
+
'Bartolome', 'Bases', 'Batac', 'Bataller', 'Batanes',
|
| 141 |
+
'Batungbakal', 'Bautista', 'Bayani', 'Bayot', 'Baysic',
|
| 142 |
+
'Belarmino', 'Beldia', 'Belen', 'Belgica', 'Bello',
|
| 143 |
+
'Benavides', 'Bendaña', 'Benedicto', 'Benigno', 'Benitez',
|
| 144 |
+
'Bernardino', 'Bernardo', 'Bernarte', 'Besares', 'Billones',
|
| 145 |
+
'Binay', 'Binayas', 'Biscocho', 'Blanco', 'Bondoc',
|
| 146 |
+
'Borja', 'Borromeo', 'Bravo', 'Buenaobra', 'Buenaflor',
|
| 147 |
+
'Buenafe', 'Buenaseda', 'Buenconsejo', 'Buendia', 'Bugarin',
|
| 148 |
+
'Bulalacao', 'Bulalacao', 'Bulatao', 'Bumanlag', 'Bunag',
|
| 149 |
+
'Caballero', 'Cabigting', 'Cabral', 'Cabreros', 'Cacal',
|
| 150 |
+
'Cagampan', 'Cagas', 'Caguioa', 'Cahilig', 'Cajucom',
|
| 151 |
+
'Calagos', 'Calamba', 'Calasanz', 'Calatrava', 'Calderon',
|
| 152 |
+
'Calimag', 'Calimutan', 'Calinawan', 'Calleja', 'Callejo',
|
| 153 |
+
'Caluag', 'Calugay', 'Camacho', 'Camino', 'Campaner',
|
| 154 |
+
'Camposano', 'Candelario', 'Canete', 'Caning', 'Canlas',
|
| 155 |
+
'Caoile', 'Capili', 'Carandang', 'Carbonell', 'Cariaga',
|
| 156 |
+
'Carino', 'Carunungan', 'Casaje', 'Casas', 'Casidsid',
|
| 157 |
+
'Castañeda', 'Castillo', 'Castillo', 'Catalan', 'Catapang',
|
| 158 |
+
'Cayabyab', 'Cayco', 'Celdran', 'Cerillo', 'Cervantes',
|
| 159 |
+
'Chico', 'Chikiamco', 'Chiongbian', 'Cipriano', 'Clarin',
|
| 160 |
+
'Claudio', 'Clavecillas', 'Climaco', 'Cobankiat', 'Colambo',
|
| 161 |
+
'Collado', 'Comafay', 'Comia', 'Concepcion', 'Condino',
|
| 162 |
+
'Consing', 'Contraras', 'Coquia', 'Cordero', 'Corotan',
|
| 163 |
+
'Corpus', 'Cosico', 'Costales', 'Crisostomo', 'Cristobal',
|
| 164 |
+
'Cueto', 'Culala', 'Cunanan', 'Cunanon', 'Curato',
|
| 165 |
+
'Dadivas', 'Daep', 'Daez', 'Daguplo', 'Dalida',
|
| 166 |
+
'Dalisay', 'Dalmacion', 'Dalusong', 'Damasco', 'Damo',
|
| 167 |
+
'Danao', 'Dancel', 'Dandan', 'Danila', 'Daquigan',
|
| 168 |
+
'Dario', 'Datoc', 'Datumanong', 'David', 'Dayao',
|
| 169 |
+
'Dayrit', 'De Borja', 'De Castro', 'De Jesus', 'De Jose',
|
| 170 |
+
'De La Cruz', 'De La Pena', 'De La Rosa', 'De Leon', 'De Lima',
|
| 171 |
+
'De Los Angeles', 'De Los Reyes', 'De Los Santos', 'De Luna', 'De Mesa',
|
| 172 |
+
'De Ocampo', 'De Paz', 'De Vera', 'De Villa', 'Delos Reyes',
|
| 173 |
+
'Demaisip', 'Delos Santos', 'Demillo', 'Demonteverde', 'Denosta',
|
| 174 |
+
'Derequito', 'Deri', 'Detablan', 'Deveraturda', 'Diaz',
|
| 175 |
+
'Dichoso', 'Diego', 'Diesto', 'Dimaano', 'Dimabuyu',
|
| 176 |
+
'Dimagiba', 'Dimaguila', 'Dimaio', 'Dimanlig', 'Dimayuga',
|
| 177 |
+
'Dingal', 'Dinglasan', 'Dionisio', 'Dioquino', 'Ditan',
|
| 178 |
+
'Diwata', 'Domingo', 'Dominguez', 'Donato', 'Dorado',
|
| 179 |
+
'Doria', 'Duallo', 'Duenas', 'Duerme', 'Dulay',
|
| 180 |
+
'Dumalaog', 'Dumpit', 'Duque', 'Duran', 'Durante',
|
| 181 |
+
'Ebdane', 'Echavez', 'Echevarria', 'Edralin', 'Ejercito',
|
| 182 |
+
'Elago', 'Elazegui', 'Elises', 'Elumba', 'Enage',
|
| 183 |
+
'Encarnacion', 'Enriquez', 'Escobar', 'Escueta', 'Escutin',
|
| 184 |
+
'Esguerra', 'Eslit', 'Espejo', 'Espeleta', 'Espinas',
|
| 185 |
+
'Espino', 'Espiritu', 'Estepa', 'Esteves', 'Estrada',
|
| 186 |
+
'Estrellas', 'Evangelista', 'Evasco', 'Evidente', 'Eyas',
|
| 187 |
+
'Fabella', 'Fabros', 'Faelnar', 'Fajardo', 'Fajutag',
|
| 188 |
+
'Famadico', 'Famador', 'Faustino', 'Favila', 'Feliciano',
|
| 189 |
+
'Felipe', 'Fermin', 'Fernandez', 'Fernando', 'Ferrer',
|
| 190 |
+
'Figueras', 'Fider', 'Florendo', 'Florentino', 'Floreta',
|
| 191 |
+
'Flores', 'Florido', 'Floriza', 'Foja', 'Fonacier',
|
| 192 |
+
'Fontanilla', 'Formoso', 'Fornier', 'Fortich', 'Fortuna',
|
| 193 |
+
'Francisco', 'Frano', 'Frasco', 'Frias', 'Fuentes',
|
| 194 |
+
'Gaabucayan', 'Gabutero', 'Gaerlan', 'Gaffud', 'Galapon',
|
| 195 |
+
'Galera', 'Galicia', 'Galindez', 'Gallardo', 'Gallo',
|
| 196 |
+
'Galvez', 'Gamalinda', 'Gamboa', 'Gammad', 'Gandionco',
|
| 197 |
+
'Ganzon', 'Garado', 'Garayblas', 'Garcia', 'Garduce',
|
| 198 |
+
'Garrido', 'Gatdula', 'Gatmaitan', 'Gatus', 'Gawat',
|
| 199 |
+
'Gelera', 'Gelua', 'Gemora', 'Genato', 'Generoso',
|
| 200 |
+
'Gequillana', 'Gerona', 'Gerundio', 'Gianan', 'Gimenez',
|
| 201 |
+
'Gloria', 'Glorioso', 'Glova', 'Golez', 'Gomez',
|
| 202 |
+
'Gonzaga', 'Gonzales', 'Gordoncillo', 'Gorre', 'Grafilo',
|
| 203 |
+
'Gregorio', 'Griño', 'Guanzon', 'Guerrero', 'Guevara',
|
| 204 |
+
'Guiao', 'Guillen', 'Guinto', 'Guison', 'Gullas',
|
| 205 |
+
'Gutierrez', 'Guzman', 'Hernandez', 'Herrera', 'Hizon',
|
| 206 |
+
'Honasan', 'Hontiveros', 'Horca', 'Hufana', 'Humilde',
|
| 207 |
+
'Ibañez', 'Ignacio', 'Ilustre', 'Imbong', 'Imperial',
|
| 208 |
+
'Infante', 'Inion', 'Inocentes', 'Inso', 'Iringan',
|
| 209 |
+
'Jacinto', 'Javier', 'Jimenez', 'Jose', 'Joson',
|
| 210 |
+
'Juan', 'Juico', 'Jurado', 'Kabigting', 'Kalaw',
|
| 211 |
+
'Kho', 'Lacaba', 'Lacadin', 'Lacson', 'Ladesma',
|
| 212 |
+
'Laderas', 'Lagman', 'Lagua', 'Laguna', 'Lainez',
|
| 213 |
+
'Lajarca', 'Lamayo', 'Lambino', 'Lapid', 'Lapuz',
|
| 214 |
+
'Lara', 'Largo', 'Lariza', 'Larizal', 'Laserna',
|
| 215 |
+
'Latorre', 'Laurel', 'Laurente', 'Lazaro', 'Leano',
|
| 216 |
+
'Legarda', 'Leonor', 'Leynes', 'Libunao', 'Licup',
|
| 217 |
+
'Lim', 'Limkaichong', 'Limpag', 'Liwanag', 'Llanes',
|
| 218 |
+
'Llamado', 'Llaneta', 'Locsin', 'Logarta', 'Lopez',
|
| 219 |
+
'Lorenzo', 'Lorilla', 'Lozada', 'Lucero', 'Luistro',
|
| 220 |
+
'Luna', 'Luneta', 'Luzon', 'Macalintal', 'Macam',
|
| 221 |
+
'Maceda', 'Madera', 'Madrazo', 'Magtanggol', 'Malabanan',
|
| 222 |
+
'Malacaman', 'Malajacan', 'Malanyaon', 'Malaya', 'Malbas',
|
| 223 |
+
'Malcampo', 'Maldia', 'Maligalig', 'Malinao', 'Malonzo',
|
| 224 |
+
'Mangahas', 'Mangubat', 'Manigbas', 'Manila', 'Manlangit',
|
| 225 |
+
'Manlapaz', 'Manlongat', 'Manrique', 'Mansalay', 'Mante',
|
| 226 |
+
'Manuel', 'Manzano', 'Marcelo', 'Marcos', 'Mariano',
|
| 227 |
+
'Maristela', 'Marquez', 'Maravilla', 'Masangkay', 'Masapol',
|
| 228 |
+
'Mateo', 'Matienzo', 'Matining', 'Matugas', 'Maula',
|
| 229 |
+
'Maulion', 'Mayuga', 'Medina', 'Mejia', 'Melchor',
|
| 230 |
+
'Melo', 'Menor', 'Mercado', 'Mesina', 'Miguel',
|
| 231 |
+
'Miralles', 'Miranda', 'Molano', 'Molina', 'Mondejar',
|
| 232 |
+
'Monreal', 'Montano', 'Montenegro', 'Montero', 'Montes',
|
| 233 |
+
'Montesa', 'Montoya', 'Moraga', 'Moraleda', 'Moreno',
|
| 234 |
+
'Morial', 'Muncal', 'Muñoz', 'Murillo', 'Musni',
|
| 235 |
+
'Nacion', 'Nadal', 'Nagrampa', 'Nalzaro', 'Napeñas',
|
| 236 |
+
'Narciso', 'Natividad', 'Navales', 'Navarro', 'Neri',
|
| 237 |
+
'Nicolas', 'Nisperos', 'Nolasco', 'Noynay', 'Nuñez',
|
| 238 |
+
'Oaminal', 'Ocampo', 'Ocfemia', 'Ochoa', 'Olaguera',
|
| 239 |
+
'Olano', 'Oliva', 'Olivares', 'Oliveros', 'Olpindo',
|
| 240 |
+
'Omadto', 'Ombion', 'Onate', 'Ong', 'Orbeta',
|
| 241 |
+
'Orbita', 'Ordoño', 'Orendain', 'Orense', 'Orobia',
|
| 242 |
+
'Orozco', 'Ortega', 'Osmeña', 'Osorio', 'Ostrea',
|
| 243 |
+
'Ouano', 'Pabiton', 'Pableo', 'Pabriaga', 'Pacanan',
|
| 244 |
+
'Padayao', 'Padilla', 'Padua', 'Paguio', 'Pagulayan',
|
| 245 |
+
'Palad', 'Palacios', 'Palafox', 'Palaganas', 'Palattao',
|
| 246 |
+
'Palencia', 'Palma', 'Palo', 'Paloma', 'Palomares',
|
| 247 |
+
'Pamaran', 'Pamintuan', 'Panaligan', 'Panganiban', 'Pangilinan',
|
| 248 |
+
'Panopio', 'Papa', 'Paqueo', 'Paras', 'Paredes',
|
| 249 |
+
'Parreño', 'Pascua', 'Pascual', 'Pastor', 'Paterno',
|
| 250 |
+
'Patron', 'Pavia', 'Pecaña', 'Pecho', 'Pedrosa',
|
| 251 |
+
'Pelayo', 'Peña', 'Peñaflor', 'Peñaranda', 'Penarroyo',
|
| 252 |
+
'Peralta', 'Perez', 'Perlas', 'Pernia', 'Pesquera',
|
| 253 |
+
'Pestano', 'Piccio', 'Picardal', 'Pineda', 'Pimentel',
|
| 254 |
+
'Pilapil', 'Pili', 'Piliin', 'Pillar', 'Pilorin',
|
| 255 |
+
'Poblete', 'Poliquit', 'Ponce', 'Ponferrada', 'Porras',
|
| 256 |
+
'Prado', 'Prieto', 'Prodigalidad', 'Prudente', 'Punsalan',
|
| 257 |
+
'Quezon', 'Quiambao', 'Quiaoit', 'Quijano', 'Quimpo',
|
| 258 |
+
'Quinit', 'Quinones', 'Quiogue', 'Quirino', 'Quisao',
|
| 259 |
+
'Racelis', 'Rada', 'Ramirez', 'Ramon', 'Ramos',
|
| 260 |
+
'Ravalo', 'Rayala', 'Razon', 'Recinto', 'Recometa',
|
| 261 |
+
'Reforma', 'Regalado', 'Reganit', 'Regio', 'Regidor',
|
| 262 |
+
'Regis', 'Reodica', 'Respicio', 'Revilla', 'Reyes',
|
| 263 |
+
'Ricafort', 'Ricalde', 'Ridad', 'Rillo', 'Rivera',
|
| 264 |
+
'Rivero', 'Rizal', 'Robles', 'Roca', 'Rocamora',
|
| 265 |
+
'Rocero', 'Rodriguez', 'Rojas', 'Romero', 'Ronquillo',
|
| 266 |
+
'Rosales', 'Rosario', 'Rosete', 'Rotor', 'Roxas',
|
| 267 |
+
'Rubio', 'Rufino', 'Ruiz', 'Sabal', 'Sabando',
|
| 268 |
+
'Sabido', 'Sabijon', 'Sabio', 'Saceda', 'Saclolo',
|
| 269 |
+
'Sagum', 'Salceda', 'Salcedo', 'Salgado', 'Salinas',
|
| 270 |
+
'Saludar', 'Saluta', 'Salvador', 'Sambrano', 'Samson',
|
| 271 |
+
'Sanchez', 'Sandoval', 'Sangalang', 'Santiago', 'Santillan',
|
| 272 |
+
'Sanz', 'Sarino', 'Sarmiento', 'Sarona', 'Savellano',
|
| 273 |
+
'Sebastian', 'Segovia', 'Sendin', 'Seneres', 'Serafica',
|
| 274 |
+
'Sereno', 'Senga', 'Serrano', 'Sierra', 'Sigua',
|
| 275 |
+
'Silva', 'Silvestre', 'Simon', 'Sinco', 'Singson',
|
| 276 |
+
'Siy', 'Sobejana', 'Soberano', 'Socrates', 'Soliman',
|
| 277 |
+
'Solis', 'Soliven', 'Solomon', 'Sotto', 'Suansing',
|
| 278 |
+
'Suarez', 'Subido', 'Sulit', 'Sultan', 'Sumagaysay',
|
| 279 |
+
'Sunga', 'Tabamo', 'Tabinas', 'Tabuena', 'Tagle',
|
| 280 |
+
'Taguba', 'Tajonera', 'Talabong', 'Talavera', 'Talento',
|
| 281 |
+
'Taleon', 'Talosig', 'Tamano', 'Tambalo', 'Tanada',
|
| 282 |
+
'Tandoc', 'Tañada', 'Tarriela', 'Tating', 'Tautho',
|
| 283 |
+
'Tayag', 'Tayco', 'Tecson', 'Tejano', 'Tejero',
|
| 284 |
+
'Teodoro', 'Tibay', 'Tigas', 'Tiglao', 'Timbol',
|
| 285 |
+
'Tingzon', 'Tiongco', 'Tiongson', 'Tirol', 'Tobias',
|
| 286 |
+
'Toledo', 'Tolentino', 'Tomelden', 'Tomas', 'Tomaro',
|
| 287 |
+
'Tomaroy', 'Torino', 'Torralba', 'Torrente', 'Torno',
|
| 288 |
+
'Trea', 'Trinidad', 'Tuazon', 'Tubig', 'Tubigan',
|
| 289 |
+
'Tugade', 'Tumbocon', 'Tupas', 'Tuquero', 'Turla',
|
| 290 |
+
'Umagat', 'Umali', 'Usman', 'Uson', 'Uy',
|
| 291 |
+
'Valdez', 'Valencia', 'Valenciano', 'Valentin', 'Valera',
|
| 292 |
+
'Valiao', 'Varela', 'Vargas', 'Vasquez', 'Velarde',
|
| 293 |
+
'Velasco', 'Velasquez', 'Velez', 'Vera', 'Vergara',
|
| 294 |
+
'Vibandor', 'Vicente', 'Victorino', 'Vidal', 'Viernes',
|
| 295 |
+
'Villacorta', 'Villaflor', 'Villafranca', 'Villagomez', 'Villagonzalo',
|
| 296 |
+
'Villanueva', 'Villar', 'Villareal', 'Villaruel', 'Villaverde',
|
| 297 |
+
'Villena', 'Virata', 'Vista', 'Vivar', 'Vizconde',
|
| 298 |
+
'Yabes', 'Yap', 'Yasay', 'Yatco', 'Ylagan',
|
| 299 |
+
'Yñiguez', 'Yorac', 'Yulo', 'Zabala', 'Zaldivar',
|
| 300 |
+
'Zamora', 'Zapanta', 'Zaragoza', 'Zosa', 'Zulueta',
|
| 301 |
+
]
|
| 302 |
+
|
| 303 |
+
# Combine last names pool + extended middle names, deduplicated
|
| 304 |
+
middle_seen = set()
|
| 305 |
+
all_middle = []
|
| 306 |
+
for name in (all_last + EXTENDED_MIDDLE_NAMES):
|
| 307 |
+
if isinstance(name, str) and name not in middle_seen:
|
| 308 |
+
middle_seen.add(name)
|
| 309 |
+
all_middle.append(name)
|
| 310 |
+
|
| 311 |
+
print(f" Total middle names : {len(all_middle)}")
|
| 312 |
+
print(f" Sample : {all_middle[:5]}")
|
| 313 |
+
|
| 314 |
+
# ── Step 5: Save to JSON ──────────────────────────────────────
|
| 315 |
+
print("\n[5/5] Saving to data/ph_names.json ...")
|
| 316 |
+
|
| 317 |
+
os.makedirs('data', exist_ok=True)
|
| 318 |
+
|
| 319 |
+
output = {
|
| 320 |
+
"first_names": {
|
| 321 |
+
"male": male_first,
|
| 322 |
+
"female": female_first,
|
| 323 |
+
"all": all_first
|
| 324 |
+
},
|
| 325 |
+
"last_names": all_last,
|
| 326 |
+
"middle_names": all_middle,
|
| 327 |
+
"metadata": {
|
| 328 |
+
"source": "names-dataset (PyPI) -- country_alpha2='PH'",
|
| 329 |
+
"total_first": len(all_first),
|
| 330 |
+
"total_last": len(all_last),
|
| 331 |
+
"total_middle": len(all_middle),
|
| 332 |
+
"total_name_combos": len(all_first) * len(all_middle) * len(all_last),
|
| 333 |
+
}
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
with open('data/ph_names.json', 'w', encoding='utf-8') as f:
|
| 337 |
+
json.dump(output, f, indent=2, ensure_ascii=False)
|
| 338 |
+
|
| 339 |
+
# ── Summary ───────────────────────────────────────────────────
|
| 340 |
+
print("\n" + "=" * 60)
|
| 341 |
+
print(" DONE!")
|
| 342 |
+
print("=" * 60)
|
| 343 |
+
print(f" Male first names : {len(male_first)}")
|
| 344 |
+
print(f" Female first names : {len(female_first)}")
|
| 345 |
+
print(f" Last names : {len(all_last)}")
|
| 346 |
+
print(f" Middle names : {len(all_middle)}")
|
| 347 |
+
print(f" Possible 3-part name combos : {len(all_first) * len(all_middle) * len(all_last):,}")
|
| 348 |
+
print(f"\n Saved to: data/ph_names.json")
|
| 349 |
+
print(f"\n Next step: python fix_data.py")
|
| 350 |
+
print("=" * 60)
|
inference.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Inference Script for CRNN+CTC Civil Registry OCR
|
| 3 |
+
|
| 4 |
+
TWO NORMALIZERS:
|
| 5 |
+
SimpleNormalizer — for PIL-rendered synthetic images (matches training exactly)
|
| 6 |
+
AdaptiveNormalizer — for physical/scanned images (any zoom, any size)
|
| 7 |
+
|
| 8 |
+
AUTO-DETECT MODE: automatically decides which pipeline to use based on
|
| 9 |
+
text density in the image — zoomed-in images get adaptive treatment,
|
| 10 |
+
clean synthetic images get simple treatment.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
import cv2
|
| 15 |
+
import numpy as np
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Dict, List
|
| 18 |
+
|
| 19 |
+
from crnn_model import get_crnn_model
|
| 20 |
+
from utils import decode_ctc_predictions, extract_form_fields
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 24 |
+
# HELPERS
|
| 25 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 26 |
+
|
| 27 |
+
def _to_gray(img: np.ndarray) -> np.ndarray:
|
| 28 |
+
if len(img.shape) == 3:
|
| 29 |
+
return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 30 |
+
return img.copy()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _binarize(gray: np.ndarray) -> np.ndarray:
|
| 34 |
+
"""Otsu, falls back to adaptive for uneven backgrounds."""
|
| 35 |
+
_, otsu = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
| 36 |
+
white_ratio = np.mean(otsu == 255)
|
| 37 |
+
if white_ratio < 0.30 or white_ratio > 0.97:
|
| 38 |
+
return cv2.adaptiveThreshold(
|
| 39 |
+
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
| 40 |
+
cv2.THRESH_BINARY, 11, 2)
|
| 41 |
+
return otsu
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _crop_to_text(gray: np.ndarray, pad_ratio=0.15) -> np.ndarray:
|
| 45 |
+
"""Crop tightly around dark pixels (the text)."""
|
| 46 |
+
inv = cv2.bitwise_not(gray)
|
| 47 |
+
_, thresh = cv2.threshold(inv, 20, 255, cv2.THRESH_BINARY)
|
| 48 |
+
coords = np.column_stack(np.where(thresh > 0))
|
| 49 |
+
if len(coords) == 0:
|
| 50 |
+
return gray
|
| 51 |
+
y_min, x_min = coords.min(axis=0)
|
| 52 |
+
y_max, x_max = coords.max(axis=0)
|
| 53 |
+
pad = max(4, int((y_max - y_min) * pad_ratio))
|
| 54 |
+
y_min = max(0, y_min - pad)
|
| 55 |
+
x_min = max(0, x_min - pad)
|
| 56 |
+
y_max = min(gray.shape[0] - 1, y_max + pad)
|
| 57 |
+
x_max = min(gray.shape[1] - 1, x_max + pad)
|
| 58 |
+
return gray[y_min:y_max+1, x_min:x_max+1]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _aspect_resize(gray: np.ndarray, H: int, W: int) -> np.ndarray:
|
| 62 |
+
"""Resize preserving aspect ratio, pad with white to fill canvas."""
|
| 63 |
+
h, w = gray.shape
|
| 64 |
+
if h == 0 or w == 0:
|
| 65 |
+
return np.ones((H, W), dtype=np.uint8) * 255
|
| 66 |
+
scale = H / h
|
| 67 |
+
new_w = int(w * scale)
|
| 68 |
+
new_h = H
|
| 69 |
+
if new_w > W:
|
| 70 |
+
scale = W / w
|
| 71 |
+
new_h = int(h * scale)
|
| 72 |
+
new_w = W
|
| 73 |
+
resized = cv2.resize(gray, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4)
|
| 74 |
+
canvas = np.ones((H, W), dtype=np.uint8) * 255
|
| 75 |
+
y_off = (H - new_h) // 2
|
| 76 |
+
x_off = (W - new_w) // 2
|
| 77 |
+
canvas[y_off:y_off+new_h, x_off:x_off+new_w] = resized
|
| 78 |
+
return canvas
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _detect_mode(gray: np.ndarray) -> str:
|
| 82 |
+
"""
|
| 83 |
+
Auto-detect whether image needs adaptive or simple normalization.
|
| 84 |
+
|
| 85 |
+
Logic:
|
| 86 |
+
- If >25% of pixels are dark, text is very large/zoomed → adaptive.
|
| 87 |
+
- If image size is far from training size (512x64) → adaptive.
|
| 88 |
+
- Otherwise → simple (matches training pipeline).
|
| 89 |
+
"""
|
| 90 |
+
h, w = gray.shape
|
| 91 |
+
_, bw = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY)
|
| 92 |
+
dark_px = np.mean(bw == 0)
|
| 93 |
+
|
| 94 |
+
# Text fills too much of the image → zoomed in (like shane.jpg)
|
| 95 |
+
if dark_px > 0.25:
|
| 96 |
+
return 'adaptive'
|
| 97 |
+
|
| 98 |
+
# Image is far from expected training size (allow 50% tolerance)
|
| 99 |
+
if not (256 <= w <= 1024 and 32 <= h <= 128):
|
| 100 |
+
return 'adaptive'
|
| 101 |
+
|
| 102 |
+
return 'simple'
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _to_tensor(img: np.ndarray) -> torch.Tensor:
|
| 106 |
+
return torch.FloatTensor(
|
| 107 |
+
img.astype(np.float32) / 255.0
|
| 108 |
+
).unsqueeze(0).unsqueeze(0)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 112 |
+
# SIMPLE NORMALIZER ← for PIL-rendered / training-matched images
|
| 113 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 114 |
+
|
| 115 |
+
class SimpleNormalizer:
|
| 116 |
+
"""
|
| 117 |
+
Matches fix_data.py training pipeline exactly:
|
| 118 |
+
grayscale → resize → binarize
|
| 119 |
+
Best for test images created by create_test_images.py.
|
| 120 |
+
"""
|
| 121 |
+
def __init__(self, H=64, W=512):
|
| 122 |
+
self.H, self.W = H, W
|
| 123 |
+
|
| 124 |
+
def normalize(self, img: np.ndarray) -> np.ndarray:
|
| 125 |
+
gray = _to_gray(img)
|
| 126 |
+
resized = cv2.resize(gray, (self.W, self.H), interpolation=cv2.INTER_LANCZOS4)
|
| 127 |
+
return _binarize(resized)
|
| 128 |
+
|
| 129 |
+
def normalize_from_path(self, path: str) -> np.ndarray:
|
| 130 |
+
img = cv2.imread(str(path))
|
| 131 |
+
if img is None:
|
| 132 |
+
raise ValueError(f"Cannot load: {path}")
|
| 133 |
+
return self.normalize(img)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 137 |
+
# ADAPTIVE NORMALIZER ← for real / physical / scanned images
|
| 138 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 139 |
+
|
| 140 |
+
class AdaptiveNormalizer:
|
| 141 |
+
"""
|
| 142 |
+
For physical documents or images with non-standard zoom/size:
|
| 143 |
+
grayscale → denoise → crop text → aspect-ratio resize → binarize
|
| 144 |
+
|
| 145 |
+
Crops to actual text first, so a zoomed-in image like shane.jpg
|
| 146 |
+
gets scaled down to training size instead of being squeezed/stretched.
|
| 147 |
+
"""
|
| 148 |
+
def __init__(self, H=64, W=512):
|
| 149 |
+
self.H, self.W = H, W
|
| 150 |
+
|
| 151 |
+
def normalize(self, img: np.ndarray) -> np.ndarray:
|
| 152 |
+
gray = _to_gray(img)
|
| 153 |
+
gray = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)
|
| 154 |
+
gray = _crop_to_text(gray)
|
| 155 |
+
canvas = _aspect_resize(gray, self.H, self.W)
|
| 156 |
+
return _binarize(canvas)
|
| 157 |
+
|
| 158 |
+
def normalize_from_path(self, path: str) -> np.ndarray:
|
| 159 |
+
img = cv2.imread(str(path))
|
| 160 |
+
if img is None:
|
| 161 |
+
raise ValueError(f"Cannot load: {path}")
|
| 162 |
+
return self.normalize(img)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 166 |
+
# AUTO NORMALIZER ← detects which pipeline to use per image automatically
|
| 167 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 168 |
+
|
| 169 |
+
class AutoNormalizer:
|
| 170 |
+
"""
|
| 171 |
+
Automatically picks Simple or Adaptive based on image characteristics.
|
| 172 |
+
|
| 173 |
+
Examples:
|
| 174 |
+
demo.jpg (clean 512x64 PIL) → Simple (matches training)
|
| 175 |
+
name1.jpg (clean 512x64 PIL) → Simple
|
| 176 |
+
shane.jpg (huge zoomed text) → Adaptive (crop then resize)
|
| 177 |
+
real scan (any size/zoom) → Adaptive
|
| 178 |
+
"""
|
| 179 |
+
def __init__(self, H=64, W=512, verbose=False):
|
| 180 |
+
self.H, self.W = H, W
|
| 181 |
+
self.verbose = verbose
|
| 182 |
+
self._simple = SimpleNormalizer(H, W)
|
| 183 |
+
self._adaptive = AdaptiveNormalizer(H, W)
|
| 184 |
+
|
| 185 |
+
def normalize(self, img: np.ndarray) -> np.ndarray:
|
| 186 |
+
gray = _to_gray(img)
|
| 187 |
+
mode = _detect_mode(gray)
|
| 188 |
+
if self.verbose:
|
| 189 |
+
print(f" auto → {mode}")
|
| 190 |
+
return self._simple.normalize(img) if mode == 'simple' \
|
| 191 |
+
else self._adaptive.normalize(img)
|
| 192 |
+
|
| 193 |
+
def normalize_from_path(self, path: str) -> np.ndarray:
|
| 194 |
+
img = cv2.imread(str(path))
|
| 195 |
+
if img is None:
|
| 196 |
+
raise ValueError(f"Cannot load: {path}")
|
| 197 |
+
gray = _to_gray(img)
|
| 198 |
+
mode = _detect_mode(gray)
|
| 199 |
+
if self.verbose:
|
| 200 |
+
print(f" [{Path(path).name}] → {mode}")
|
| 201 |
+
return self._simple.normalize(img) if mode == 'simple' \
|
| 202 |
+
else self._adaptive.normalize(img)
|
| 203 |
+
|
| 204 |
+
def to_tensor(self, img: np.ndarray) -> torch.Tensor:
|
| 205 |
+
return _to_tensor(img)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 209 |
+
# MAIN OCR CLASS
|
| 210 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 211 |
+
|
| 212 |
+
class CivilRegistryOCR:
|
| 213 |
+
|
| 214 |
+
def __init__(self, checkpoint_path, device='cuda', mode='auto', verbose=False):
|
| 215 |
+
"""
|
| 216 |
+
Args:
|
| 217 |
+
checkpoint_path : path to best_model_v4.pth
|
| 218 |
+
device : 'cuda' or 'cpu'
|
| 219 |
+
mode : 'auto' → auto-detect per image (recommended)
|
| 220 |
+
'simple' → always use simple pipeline
|
| 221 |
+
'adaptive' → always use adaptive pipeline
|
| 222 |
+
verbose : print which mode was chosen per image
|
| 223 |
+
"""
|
| 224 |
+
if device == 'cuda' and not torch.cuda.is_available():
|
| 225 |
+
device = 'cpu'
|
| 226 |
+
|
| 227 |
+
self.device = torch.device(device)
|
| 228 |
+
self.verbose = verbose
|
| 229 |
+
print(f"Loading model from {checkpoint_path}...")
|
| 230 |
+
|
| 231 |
+
checkpoint = torch.load(checkpoint_path, map_location=self.device,
|
| 232 |
+
weights_only=False)
|
| 233 |
+
|
| 234 |
+
self.char_to_idx = checkpoint['char_to_idx']
|
| 235 |
+
self.idx_to_char = checkpoint['idx_to_char']
|
| 236 |
+
self.config = checkpoint.get('config', {})
|
| 237 |
+
|
| 238 |
+
img_height = self.config.get('img_height', 64)
|
| 239 |
+
img_width = self.config.get('img_width', 512)
|
| 240 |
+
|
| 241 |
+
if mode == 'simple':
|
| 242 |
+
self.normalizer = SimpleNormalizer(img_height, img_width)
|
| 243 |
+
elif mode == 'adaptive':
|
| 244 |
+
self.normalizer = AdaptiveNormalizer(img_height, img_width)
|
| 245 |
+
else:
|
| 246 |
+
self.normalizer = AutoNormalizer(img_height, img_width, verbose=verbose)
|
| 247 |
+
|
| 248 |
+
self.model = get_crnn_model(
|
| 249 |
+
model_type=self.config.get('model_type', 'standard'),
|
| 250 |
+
img_height=img_height,
|
| 251 |
+
num_chars=checkpoint['model_state_dict']['fc.weight'].shape[0],
|
| 252 |
+
hidden_size=self.config.get('hidden_size', 128),
|
| 253 |
+
num_lstm_layers=self.config.get('num_lstm_layers', 1)
|
| 254 |
+
)
|
| 255 |
+
self.model.load_state_dict(checkpoint['model_state_dict'])
|
| 256 |
+
self.model = self.model.to(self.device)
|
| 257 |
+
self.model.eval()
|
| 258 |
+
|
| 259 |
+
print(f"Model loaded successfully")
|
| 260 |
+
# Support both key names: val_loss (fine-tuned) and val_cer (synthetic baseline)
|
| 261 |
+
# FIXED Bug 5: removed incorrect `val_cer < 10` heuristic that mislabelled
|
| 262 |
+
# the metric. The key name alone is the reliable indicator.
|
| 263 |
+
val_loss = checkpoint.get('val_loss', None)
|
| 264 |
+
val_cer = checkpoint.get('val_cer', None)
|
| 265 |
+
if val_loss is not None and val_cer is not None:
|
| 266 |
+
print(f" Val Loss : {val_loss:.4f} | Val CER: {val_cer:.2f}%")
|
| 267 |
+
elif val_loss is not None:
|
| 268 |
+
print(f" Val Loss : {val_loss:.4f} (fine-tuned checkpoint — run compare_live_cer.py for true CER)")
|
| 269 |
+
elif val_cer is not None:
|
| 270 |
+
print(f" Val CER : {val_cer:.2f}%")
|
| 271 |
+
else:
|
| 272 |
+
print(f" Val CER : N/A (run check_cer.py for true CER)")
|
| 273 |
+
print(f" Device : {self.device}")
|
| 274 |
+
print(f" Mode : {mode} ({img_height}x{img_width})")
|
| 275 |
+
|
| 276 |
+
def _preprocess(self, image_path) -> torch.Tensor:
|
| 277 |
+
normalized = self.normalizer.normalize_from_path(str(image_path))
|
| 278 |
+
return _to_tensor(normalized)
|
| 279 |
+
|
| 280 |
+
def predict(self, image_path, decode_method='greedy') -> str:
|
| 281 |
+
img = self._preprocess(image_path).to(self.device)
|
| 282 |
+
with torch.no_grad():
|
| 283 |
+
outputs = self.model(img)
|
| 284 |
+
decoded = decode_ctc_predictions(
|
| 285 |
+
outputs.cpu(), self.idx_to_char, method=decode_method)
|
| 286 |
+
return decoded[0]
|
| 287 |
+
|
| 288 |
+
def predict_batch(self, image_paths, decode_method='greedy') -> List[Dict]:
|
| 289 |
+
results = []
|
| 290 |
+
for image_path in image_paths:
|
| 291 |
+
try:
|
| 292 |
+
text = self.predict(image_path, decode_method)
|
| 293 |
+
results.append({'image_path': str(image_path),
|
| 294 |
+
'text': text, 'success': True})
|
| 295 |
+
except Exception as e:
|
| 296 |
+
results.append({'image_path': str(image_path),
|
| 297 |
+
'error': str(e), 'success': False})
|
| 298 |
+
return results
|
| 299 |
+
|
| 300 |
+
def process_form(self, form_image_path, form_type) -> Dict:
|
| 301 |
+
text = self.predict(form_image_path)
|
| 302 |
+
fields = extract_form_fields(text, form_type)
|
| 303 |
+
fields['raw_text'] = text
|
| 304 |
+
return fields
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 308 |
+
# FORM FIELD EXTRACTOR
|
| 309 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 310 |
+
|
| 311 |
+
class FormFieldExtractor:
|
| 312 |
+
def __init__(self, ocr_model: CivilRegistryOCR):
|
| 313 |
+
self.ocr = ocr_model
|
| 314 |
+
|
| 315 |
+
def extract_form1a_fields(self, path):
|
| 316 |
+
text = self.ocr.predict(path)
|
| 317 |
+
return {'form_type': 'Form 1A - Birth Certificate', 'raw_text': text}
|
| 318 |
+
|
| 319 |
+
def extract_form2a_fields(self, path):
|
| 320 |
+
text = self.ocr.predict(path)
|
| 321 |
+
return {'form_type': 'Form 2A - Death Certificate', 'raw_text': text}
|
| 322 |
+
|
| 323 |
+
def extract_form3a_fields(self, path):
|
| 324 |
+
text = self.ocr.predict(path)
|
| 325 |
+
return {'form_type': 'Form 3A - Marriage Certificate', 'raw_text': text}
|
| 326 |
+
|
| 327 |
+
def extract_form90_fields(self, path):
|
| 328 |
+
text = self.ocr.predict(path)
|
| 329 |
+
return {'form_type': 'Form 90 - Marriage License Application',
|
| 330 |
+
'raw_text': text}
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 334 |
+
# DEMO
|
| 335 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 336 |
+
|
| 337 |
+
def demo_inference():
|
| 338 |
+
print("=" * 70)
|
| 339 |
+
print("Civil Registry OCR (auto-adaptive normalizer)")
|
| 340 |
+
print("=" * 70)
|
| 341 |
+
|
| 342 |
+
ocr = CivilRegistryOCR(
|
| 343 |
+
checkpoint_path='checkpoints/best_model_v4.pth',
|
| 344 |
+
device='cuda',
|
| 345 |
+
mode='adaptive', # force adaptive for demo images (many are zoomed/physical)
|
| 346 |
+
verbose=True # shows which mode each image triggers
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
print("\n1. Single Prediction:")
|
| 350 |
+
try:
|
| 351 |
+
result = ocr.predict('test_images/date1.jpg')
|
| 352 |
+
print(f" Recognized text: {result}")
|
| 353 |
+
except Exception as e:
|
| 354 |
+
print(f" Error: {e}")
|
| 355 |
+
|
| 356 |
+
print("\n2. Batch Prediction:")
|
| 357 |
+
'''batch_results = ocr.predict_batch([
|
| 358 |
+
'test_images/name1.jpg',
|
| 359 |
+
'test_images/shane.jpg',
|
| 360 |
+
'test_images/date1.jpg',
|
| 361 |
+
'test_images/place1.jpg',
|
| 362 |
+
])
|
| 363 |
+
for r in batch_results:
|
| 364 |
+
status = r['text'] if r['success'] else f"ERROR - {r['error']}"
|
| 365 |
+
print(f" {r['image_path']}: {status}")'''
|
| 366 |
+
|
| 367 |
+
print("\n3. Form Processing:")
|
| 368 |
+
try:
|
| 369 |
+
form_data = ocr.process_form('test_images/form1a_sample.jpg', 'form1a')
|
| 370 |
+
print(f" Form Type: Form 1A - Birth Certificate")
|
| 371 |
+
print(f" Raw Text: {form_data['raw_text']}")
|
| 372 |
+
except Exception as e:
|
| 373 |
+
print(f" Error: {e}")
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def create_inference_api():
|
| 377 |
+
class OCR_API:
|
| 378 |
+
def __init__(self, checkpoint_path, mode='auto'):
|
| 379 |
+
self.ocr = CivilRegistryOCR(checkpoint_path, mode=mode)
|
| 380 |
+
self.extractor = FormFieldExtractor(self.ocr)
|
| 381 |
+
def recognize_text(self, p):
|
| 382 |
+
return {'text': self.ocr.predict(p), 'success': True}
|
| 383 |
+
def process_birth_certificate(self, p):
|
| 384 |
+
return self.extractor.extract_form1a_fields(p)
|
| 385 |
+
def process_death_certificate(self, p):
|
| 386 |
+
return self.extractor.extract_form2a_fields(p)
|
| 387 |
+
def process_marriage_certificate(self, p):
|
| 388 |
+
return self.extractor.extract_form3a_fields(p)
|
| 389 |
+
def process_marriage_license(self, p):
|
| 390 |
+
return self.extractor.extract_form90_fields(p)
|
| 391 |
+
return OCR_API
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
if __name__ == "__main__":
|
| 395 |
+
demo_inference()
|
pipeline.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pipeline.py
|
| 2 |
+
# ============================================================
|
| 3 |
+
# FULL PIPELINE — connects all three algorithms
|
| 4 |
+
#
|
| 5 |
+
# CRNN+CTC (field_extractor.py)
|
| 6 |
+
# ↓ field dict
|
| 7 |
+
# bridge.py → MNB (form_classifier.py)
|
| 8 |
+
# ↓ form type
|
| 9 |
+
# spaCy NER (extractor.py)
|
| 10 |
+
# ↓
|
| 11 |
+
# Populated Form object (Form1A / Form2A / Form3A / Form90)
|
| 12 |
+
#
|
| 13 |
+
# USAGE:
|
| 14 |
+
# from pipeline import CivilRegistryPipeline
|
| 15 |
+
#
|
| 16 |
+
# pipeline = CivilRegistryPipeline()
|
| 17 |
+
#
|
| 18 |
+
# # Path A — single cert PDF (birth / death / marriage)
|
| 19 |
+
# result = pipeline.process_pdf("form_102.pdf", form_type="birth")
|
| 20 |
+
# print(result["name_of_child"])
|
| 21 |
+
#
|
| 22 |
+
# # Path B — Form 90 (needs two birth certs)
|
| 23 |
+
# result = pipeline.process_form90(
|
| 24 |
+
# groom_pdf="groom_birth_cert.pdf",
|
| 25 |
+
# bride_pdf="bride_birth_cert.pdf"
|
| 26 |
+
# )
|
| 27 |
+
# ============================================================
|
| 28 |
+
|
| 29 |
+
import sys
|
| 30 |
+
import os
|
| 31 |
+
import json
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
|
| 34 |
+
# ── Make sure all three algorithm folders are importable ─────
|
| 35 |
+
_ROOT = Path(__file__).parent
|
| 36 |
+
for folder in ["CRNN+CTC", "MNB", "spacyNER"]:
|
| 37 |
+
p = str(_ROOT / folder)
|
| 38 |
+
if p not in sys.path:
|
| 39 |
+
sys.path.insert(0, p)
|
| 40 |
+
sys.path.insert(0, str(_ROOT))
|
| 41 |
+
|
| 42 |
+
# ── Import CRNN+CTC (Irish's module) ─────────────────────────
|
| 43 |
+
# CRNN+CTC folder is already on sys.path, import directly
|
| 44 |
+
from field_extractor import (
|
| 45 |
+
pdf_to_image,
|
| 46 |
+
extract_field_images,
|
| 47 |
+
run_crnn_ocr,
|
| 48 |
+
load_crnn_model,
|
| 49 |
+
BIRTH_FIELDS,
|
| 50 |
+
DEATH_FIELDS,
|
| 51 |
+
MARRIAGE_FIELDS,
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
# ── Import bridge (MNB + NER connector) ──────────────────────
|
| 55 |
+
from bridge import CivilRegistryBridge, crnn_birth_to_text
|
| 56 |
+
|
| 57 |
+
import torch
|
| 58 |
+
|
| 59 |
+
# ── Config ────────────────────────────────────────────────────
|
| 60 |
+
CRNN_CHECKPOINT = str(_ROOT / "CRNN+CTC" / "checkpoints" / "best_model.pth")
|
| 61 |
+
NER_MODEL_PATH = str(_ROOT / "spacyNER" / "models" / "civil_registry_model" / "model-best")
|
| 62 |
+
MNB_MODEL_DIR = str(_ROOT / "MNB" / "models")
|
| 63 |
+
|
| 64 |
+
FORM_FIELDS_MAP = {
|
| 65 |
+
"birth": BIRTH_FIELDS,
|
| 66 |
+
"death": DEATH_FIELDS,
|
| 67 |
+
"marriage": MARRIAGE_FIELDS,
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class CivilRegistryPipeline:
|
| 72 |
+
"""
|
| 73 |
+
Connects CRNN+CTC → MNB → spaCy NER in one call.
|
| 74 |
+
|
| 75 |
+
Each member's code is untouched:
|
| 76 |
+
- Irish : crnn_ctc/field_extractor.py (CRNN+CTC)
|
| 77 |
+
- Princess: mnb/form_classifier.py (MNB)
|
| 78 |
+
- Shane : spacyNER/extractor.py (NER)
|
| 79 |
+
"""
|
| 80 |
+
|
| 81 |
+
def __init__(self,
|
| 82 |
+
crnn_checkpoint: str = CRNN_CHECKPOINT,
|
| 83 |
+
ner_model_path: str = NER_MODEL_PATH,
|
| 84 |
+
mnb_model_dir: str = MNB_MODEL_DIR):
|
| 85 |
+
|
| 86 |
+
print("=" * 55)
|
| 87 |
+
print(" Initializing Civil Registry Pipeline")
|
| 88 |
+
print("=" * 55)
|
| 89 |
+
|
| 90 |
+
# ── 1. Load CRNN model (Irish) ────────────────────────
|
| 91 |
+
self.device = torch.device(
|
| 92 |
+
"cuda" if torch.cuda.is_available() else "cpu"
|
| 93 |
+
)
|
| 94 |
+
print(f"\n [CRNN] Loading model...")
|
| 95 |
+
self.crnn_model, self.idx_to_char, self.img_h, self.img_w = (
|
| 96 |
+
load_crnn_model(crnn_checkpoint, self.device)
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
# ── 2. Load MNB + NER via bridge ─────────────────────
|
| 100 |
+
print(f"\n [MNB + NER] Loading models...")
|
| 101 |
+
self.bridge = CivilRegistryBridge(ner_model_path=ner_model_path, mnb_model_dir=mnb_model_dir)
|
| 102 |
+
|
| 103 |
+
print("\n ✅ Pipeline ready\n")
|
| 104 |
+
|
| 105 |
+
# ─────────────────────────────────────────────────────────
|
| 106 |
+
# PATH A — Single certification form (102 / 103 / 97)
|
| 107 |
+
# ─────────────────────────────────────────────────────────
|
| 108 |
+
def process_pdf(self,
|
| 109 |
+
pdf_path: str,
|
| 110 |
+
form_type: str = None,
|
| 111 |
+
dpi: int = 200) -> dict:
|
| 112 |
+
"""
|
| 113 |
+
Process one PDF through the full pipeline.
|
| 114 |
+
|
| 115 |
+
Parameters
|
| 116 |
+
----------
|
| 117 |
+
pdf_path : str Path to scanned PDF
|
| 118 |
+
form_type : str 'birth' | 'death' | 'marriage'
|
| 119 |
+
If None, MNB auto-detects.
|
| 120 |
+
dpi : int Render DPI (default 200)
|
| 121 |
+
|
| 122 |
+
Returns
|
| 123 |
+
-------
|
| 124 |
+
dict with all extracted form fields
|
| 125 |
+
"""
|
| 126 |
+
print(f"\n Processing: {pdf_path}")
|
| 127 |
+
|
| 128 |
+
# Step 1 — CRNN+CTC: PDF → field dict
|
| 129 |
+
crnn_fields = self._run_crnn(pdf_path, form_type, dpi)
|
| 130 |
+
|
| 131 |
+
# Step 2+3 — MNB → NER via bridge
|
| 132 |
+
form_obj = self.bridge.process(crnn_fields, form_hint=form_type)
|
| 133 |
+
|
| 134 |
+
result = form_obj.to_dict()
|
| 135 |
+
print(f" ✅ Done — {len([v for v in result.values() if v])} fields extracted")
|
| 136 |
+
return result
|
| 137 |
+
|
| 138 |
+
# ─────────────────────────────────────────────────────────
|
| 139 |
+
# PATH B — Form 90 (two birth certs → marriage license)
|
| 140 |
+
# ─────────────────────────────────────────────────────────
|
| 141 |
+
def process_form90(self,
|
| 142 |
+
groom_pdf: str,
|
| 143 |
+
bride_pdf: str,
|
| 144 |
+
dpi: int = 200) -> dict:
|
| 145 |
+
"""
|
| 146 |
+
Process two birth cert PDFs into a Form 90.
|
| 147 |
+
|
| 148 |
+
Parameters
|
| 149 |
+
----------
|
| 150 |
+
groom_pdf : str Path to groom's birth certificate PDF
|
| 151 |
+
bride_pdf : str Path to bride's birth certificate PDF
|
| 152 |
+
dpi : int Render DPI (default 200)
|
| 153 |
+
|
| 154 |
+
Returns
|
| 155 |
+
-------
|
| 156 |
+
dict with all Form 90 fields (groom_* and bride_*)
|
| 157 |
+
"""
|
| 158 |
+
print(f"\n Processing Form 90")
|
| 159 |
+
print(f" Groom: {groom_pdf}")
|
| 160 |
+
print(f" Bride: {bride_pdf}")
|
| 161 |
+
|
| 162 |
+
# Step 1 — CRNN+CTC both birth certs
|
| 163 |
+
groom_fields = self._run_crnn(groom_pdf, "birth", dpi)
|
| 164 |
+
bride_fields = self._run_crnn(bride_pdf, "birth", dpi)
|
| 165 |
+
|
| 166 |
+
# Step 2+3 — bridge fills Form 90
|
| 167 |
+
form90 = self.bridge.process_marriage_license(groom_fields, bride_fields)
|
| 168 |
+
|
| 169 |
+
result = form90.to_dict()
|
| 170 |
+
print(f" ✅ Done — {len([v for v in result.values() if v])} fields extracted")
|
| 171 |
+
return result
|
| 172 |
+
|
| 173 |
+
# ─────────────────────────────────────────────────────────
|
| 174 |
+
# Internal — run CRNN on one PDF
|
| 175 |
+
# ─────────────────────────────────────────────────────────
|
| 176 |
+
def _run_crnn(self, pdf_path: str, form_type: str, dpi: int) -> dict:
|
| 177 |
+
"""Convert PDF → run CRNN → return field dict."""
|
| 178 |
+
# 1. PDF → image
|
| 179 |
+
page_image = pdf_to_image(pdf_path, dpi=dpi)
|
| 180 |
+
|
| 181 |
+
# 2. Resolve form_type — default to 'birth' if unknown/None
|
| 182 |
+
# extract_field_images uses the form_type string to pick the
|
| 183 |
+
# correct field map (BIRTH_FIELDS / DEATH_FIELDS / MARRIAGE_FIELDS)
|
| 184 |
+
resolved_type = form_type if form_type in FORM_FIELDS_MAP else "birth"
|
| 185 |
+
|
| 186 |
+
# 3. Crop fields from image using form_type string
|
| 187 |
+
crops = extract_field_images(page_image, form_type=resolved_type)
|
| 188 |
+
|
| 189 |
+
# 4. CRNN OCR each crop
|
| 190 |
+
crnn_output = run_crnn_ocr(
|
| 191 |
+
crops, self.crnn_model, self.idx_to_char,
|
| 192 |
+
self.img_h, self.img_w, self.device
|
| 193 |
+
)
|
| 194 |
+
return crnn_output
|
| 195 |
+
# ── Standalone demo ───────────────────────────────────────────
|
| 196 |
+
if __name__ == "__main__":
|
| 197 |
+
import argparse
|
| 198 |
+
|
| 199 |
+
parser = argparse.ArgumentParser(
|
| 200 |
+
description="Civil Registry Pipeline — CRNN + MNB + NER"
|
| 201 |
+
)
|
| 202 |
+
parser.add_argument("--pdf", required=True, help="Path to PDF")
|
| 203 |
+
parser.add_argument("--form", default=None,
|
| 204 |
+
choices=["birth", "death", "marriage", "form90"],
|
| 205 |
+
help="Form type (optional, MNB auto-detects if not given)")
|
| 206 |
+
parser.add_argument("--groom", default=None, help="Groom birth cert PDF (Form 90 only)")
|
| 207 |
+
parser.add_argument("--bride", default=None, help="Bride birth cert PDF (Form 90 only)")
|
| 208 |
+
parser.add_argument("--output", default=None, help="Save result to JSON file")
|
| 209 |
+
parser.add_argument("--dpi", type=int, default=200)
|
| 210 |
+
args = parser.parse_args()
|
| 211 |
+
|
| 212 |
+
pipeline = CivilRegistryPipeline()
|
| 213 |
+
|
| 214 |
+
if args.form == "form90":
|
| 215 |
+
if not args.groom or not args.bride:
|
| 216 |
+
print("ERROR: --groom and --bride required for form90")
|
| 217 |
+
else:
|
| 218 |
+
result = pipeline.process_form90(args.groom, args.bride, dpi=args.dpi)
|
| 219 |
+
else:
|
| 220 |
+
result = pipeline.process_pdf(args.pdf, form_type=args.form, dpi=args.dpi)
|
| 221 |
+
|
| 222 |
+
# Print result
|
| 223 |
+
print("\n" + "=" * 55)
|
| 224 |
+
print(" EXTRACTED FIELDS")
|
| 225 |
+
print("=" * 55)
|
| 226 |
+
for field, value in result.items():
|
| 227 |
+
if value:
|
| 228 |
+
print(f" {field:<35} {value}")
|
| 229 |
+
|
| 230 |
+
# Save to JSON
|
| 231 |
+
if args.output:
|
| 232 |
+
with open(args.output, "w", encoding="utf-8") as f:
|
| 233 |
+
json.dump(result, f, ensure_ascii=False, indent=2)
|
| 234 |
+
print(f"\n Saved → {args.output}")
|
prepare_emnist.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torchvision
|
| 2 |
+
import torchvision.transforms as transforms
|
| 3 |
+
from PIL import Image
|
| 4 |
+
import numpy as np
|
| 5 |
+
import os
|
| 6 |
+
import json
|
| 7 |
+
|
| 8 |
+
print("Preparing EMNIST data for CRNN training...")
|
| 9 |
+
print("Using 'balanced' split (47 classes — digits, uppercase, selected lowercase)")
|
| 10 |
+
|
| 11 |
+
# MAX_SAMPLES: how many EMNIST images to use out of 112,800 available.
|
| 12 |
+
# 50,000 chosen deliberately:
|
| 13 |
+
# - ~1,064 images per class (47 classes) — enough for solid character recognition
|
| 14 |
+
# - Keeps a healthy ~3:1 ratio vs synthetic data (16,000) in mixed training
|
| 15 |
+
# - Going higher (e.g. full 112,800) would drown out synthetic Filipino-specific
|
| 16 |
+
# patterns since EMNIST would be 88% of the mixed dataset
|
| 17 |
+
# - IAM fine-tuning and physical scans handle remaining handwriting gaps
|
| 18 |
+
MAX_SAMPLES = 50000
|
| 19 |
+
VAL_RATIO = 0.10 # 90% train, 10% val — proper percentage split
|
| 20 |
+
|
| 21 |
+
train_data = torchvision.datasets.EMNIST(
|
| 22 |
+
root='datasets/emnist',
|
| 23 |
+
split='balanced', # balanced split — already downloaded
|
| 24 |
+
train=True,
|
| 25 |
+
download=False, # files already exist, skip download
|
| 26 |
+
transform=transforms.ToTensor()
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
# balanced split has 47 classes:
|
| 30 |
+
# 0-9 digits, A-Z uppercase, and selected lowercase
|
| 31 |
+
# mapping follows EMNIST balanced label order
|
| 32 |
+
LABELS = [
|
| 33 |
+
'0','1','2','3','4','5','6','7','8','9',
|
| 34 |
+
'A','B','C','D','E','F','G','H','I','J','K','L','M',
|
| 35 |
+
'N','O','P','Q','R','S','T','U','V','W','X','Y','Z',
|
| 36 |
+
'a','b','d','e','f','g','h','n','q','r','t',
|
| 37 |
+
] # 47 classes exactly matching balanced split label indices
|
| 38 |
+
|
| 39 |
+
os.makedirs('data/train/emnist', exist_ok=True)
|
| 40 |
+
os.makedirs('data/val/emnist', exist_ok=True)
|
| 41 |
+
|
| 42 |
+
annotations_train = []
|
| 43 |
+
annotations_val = []
|
| 44 |
+
|
| 45 |
+
val_cutoff = int(MAX_SAMPLES * (1 - VAL_RATIO)) # 45,000 train / 5,000 val
|
| 46 |
+
|
| 47 |
+
print(f"Dataset size : {len(train_data)} images available")
|
| 48 |
+
print(f"Using : {MAX_SAMPLES} ({MAX_SAMPLES/len(train_data)*100:.1f}% of full dataset)")
|
| 49 |
+
print(f"Train / Val : {val_cutoff} / {MAX_SAMPLES - val_cutoff} (90/10 split)")
|
| 50 |
+
print("Saving images...")
|
| 51 |
+
|
| 52 |
+
saved = 0 # count of successfully saved images (skips bad label indices)
|
| 53 |
+
for i, (img_tensor, label_idx) in enumerate(train_data):
|
| 54 |
+
if saved >= MAX_SAMPLES:
|
| 55 |
+
break
|
| 56 |
+
|
| 57 |
+
# Safety check — skip if label index is out of range for our LABELS list
|
| 58 |
+
if label_idx >= len(LABELS):
|
| 59 |
+
continue
|
| 60 |
+
|
| 61 |
+
char = LABELS[label_idx]
|
| 62 |
+
img = img_tensor.squeeze().numpy()
|
| 63 |
+
img = (img * 255).astype(np.uint8)
|
| 64 |
+
|
| 65 |
+
# EMNIST images are transposed — rotate and flip to correct orientation
|
| 66 |
+
img = np.rot90(img, k=3)
|
| 67 |
+
img = np.fliplr(img)
|
| 68 |
+
|
| 69 |
+
pil_img = Image.fromarray(img).convert('RGB')
|
| 70 |
+
pil_img = pil_img.resize((512, 64)) # must match IMG_WIDTH=512
|
| 71 |
+
|
| 72 |
+
fname = f'emnist_{saved:05d}.jpg' # sequential filenames based on saved count
|
| 73 |
+
|
| 74 |
+
# FIXED: proper percentage-based split (was hardcoded `if i < 5000`)
|
| 75 |
+
if saved < val_cutoff:
|
| 76 |
+
pil_img.save(f'data/train/emnist/{fname}')
|
| 77 |
+
annotations_train.append({'image_path': f'emnist/{fname}', 'text': char})
|
| 78 |
+
else:
|
| 79 |
+
pil_img.save(f'data/val/emnist/{fname}')
|
| 80 |
+
annotations_val.append({'image_path': f'emnist/{fname}', 'text': char})
|
| 81 |
+
|
| 82 |
+
saved += 1
|
| 83 |
+
if saved % 5000 == 0:
|
| 84 |
+
print(f" Processed {saved}/{MAX_SAMPLES} images...")
|
| 85 |
+
|
| 86 |
+
with open('data/emnist_train_annotations.json', 'w') as f:
|
| 87 |
+
json.dump(annotations_train, f, indent=2)
|
| 88 |
+
with open('data/emnist_val_annotations.json', 'w') as f:
|
| 89 |
+
json.dump(annotations_val, f, indent=2)
|
| 90 |
+
|
| 91 |
+
print(f"\nDone!")
|
| 92 |
+
print(f" Train : {len(annotations_train)} images (~{len(annotations_train)//47} per class)")
|
| 93 |
+
print(f" Val : {len(annotations_val)} images")
|
| 94 |
+
print(f" Total : {len(annotations_train) + len(annotations_val)} / {len(train_data)} used")
|
| 95 |
+
print(f" Labels: {sorted(set(a['text'] for a in annotations_train))}")
|
| 96 |
+
print(f"\nClass coverage: {len(set(a['text'] for a in annotations_train))}/47 classes in train")
|
| 97 |
+
print("\nNext step: python train_with_emnist.py")
|
requirements.txt
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
paddleocr==3.4.0
|
| 2 |
+
scikit-learn==1.7.2
|
| 3 |
+
opencv-python-headless>=4.8.0
|
| 4 |
+
Pillow>=10.0.0
|
| 5 |
+
pdf2image>=1.17.0
|
| 6 |
+
pytesseract>=0.3.13
|
| 7 |
+
|
| 8 |
+
numpy>=1.24.0
|
| 9 |
+
pandas>=2.0.0
|
| 10 |
+
editdistance>=0.6.2
|
| 11 |
+
tqdm>=4.65.0
|
| 12 |
+
|
| 13 |
+
flask>=3.0.0
|
| 14 |
+
flask-cors>=4.0.0
|
| 15 |
+
|
| 16 |
+
pymysql>=1.1.0
|
| 17 |
+
|
| 18 |
+
spacy>=3.7.0
|
| 19 |
+
transformers>=4.35.0
|
| 20 |
+
sentencepiece>=0.1.99
|
| 21 |
+
|
| 22 |
+
python-dotenv>=1.0.0
|
| 23 |
+
requests>=2.31.0
|
| 24 |
+
pyyaml>=6.0
|
| 25 |
+
albumentations>=1.3.0
|
| 26 |
+
python-docx>=1.1.0
|
template_matcher.py
ADDED
|
@@ -0,0 +1,1569 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
template_matcher.py
|
| 3 |
+
================================================
|
| 4 |
+
Extracts field values from Philippine civil registry scanned forms.
|
| 5 |
+
|
| 6 |
+
PIPELINE
|
| 7 |
+
--------
|
| 8 |
+
1. Pre-flight image quality check (upside-down, skew, blur, aspect, ORB fit)
|
| 9 |
+
2. Auto-correct image (rotate 180° if upside-down, de-skew if tilted)
|
| 10 |
+
3. Detect form type
|
| 11 |
+
4. Align image to reference (perspective + ECC + ORB)
|
| 12 |
+
5. Preprocess aligned image
|
| 13 |
+
6. Use PaddleOCR ONLY for text-box detection / field localization
|
| 14 |
+
7. Batch all field crops → single CRNN+CTC forward pass
|
| 15 |
+
8. Smart-merge CRNN and PaddleOCR text using _text_quality_score
|
| 16 |
+
|
| 17 |
+
NOTES
|
| 18 |
+
-----
|
| 19 |
+
- PaddleOCR is not the final OCR engine for all fields; CRNN+CTC remains the
|
| 20 |
+
primary text reader.
|
| 21 |
+
- PaddleOCR is used for detection/localization and as selective assist text
|
| 22 |
+
for certain fields such as province, registry number, municipality, etc.
|
| 23 |
+
- This file is written to be a drop-in replacement for the EasyOCR-based version.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
import sys as _sys
|
| 27 |
+
import os
|
| 28 |
+
import sys
|
| 29 |
+
import re as _re
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
from PIL import Image
|
| 33 |
+
|
| 34 |
+
try:
|
| 35 |
+
import cv2 as _cv2
|
| 36 |
+
_CV2_OK = True
|
| 37 |
+
except ImportError:
|
| 38 |
+
_CV2_OK = False
|
| 39 |
+
|
| 40 |
+
# ── Reference images ─────────────────────────────────────────────
|
| 41 |
+
_REF_DIR = os.path.join(os.path.dirname(__file__), 'references')
|
| 42 |
+
REFERENCE_IMAGES = {
|
| 43 |
+
'102': os.path.join(_REF_DIR, 'reference-102.png'),
|
| 44 |
+
'103': os.path.join(_REF_DIR, 'reference-103.png'),
|
| 45 |
+
'90': os.path.join(_REF_DIR, 'reference-90.png'),
|
| 46 |
+
'97': os.path.join(_REF_DIR, 'reference-97.png'),
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
# ── Reference image cache (avoid repeated disk reads) ────────────
|
| 50 |
+
_REF_CACHE: dict = {}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _get_ref_gray(form_type: str):
|
| 54 |
+
"""Return cached grayscale reference image for form_type, or None."""
|
| 55 |
+
if form_type not in _REF_CACHE:
|
| 56 |
+
path = REFERENCE_IMAGES.get(form_type)
|
| 57 |
+
if path and os.path.exists(path) and _CV2_OK:
|
| 58 |
+
_REF_CACHE[form_type] = _cv2.imread(path, _cv2.IMREAD_GRAYSCALE)
|
| 59 |
+
else:
|
| 60 |
+
_REF_CACHE[form_type] = None
|
| 61 |
+
return _REF_CACHE[form_type]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ── CRNN+CTC engine ──────────────────────────────────────────────
|
| 65 |
+
_CRNN_DIR = os.path.join(os.path.dirname(__file__), 'CRNN+CTC')
|
| 66 |
+
if _CRNN_DIR not in _sys.path:
|
| 67 |
+
_sys.path.insert(0, _CRNN_DIR)
|
| 68 |
+
|
| 69 |
+
_CRNN_CHECKPOINT = os.path.join(_CRNN_DIR, 'checkpoints', 'best_model_v6.pth')
|
| 70 |
+
_crnn_ocr = None
|
| 71 |
+
_crnn_decode = None
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _get_crnn():
|
| 75 |
+
global _crnn_ocr, _crnn_decode
|
| 76 |
+
if _crnn_ocr is None:
|
| 77 |
+
try:
|
| 78 |
+
import torch
|
| 79 |
+
from inference import CivilRegistryOCR
|
| 80 |
+
from utils import decode_ctc_predictions as _dcp
|
| 81 |
+
|
| 82 |
+
print('[template_matcher] Loading CRNN+CTC model...')
|
| 83 |
+
device = 'cuda' if torch.cuda.is_available() else 'cpu'
|
| 84 |
+
_crnn_ocr = CivilRegistryOCR(
|
| 85 |
+
checkpoint_path=_CRNN_CHECKPOINT,
|
| 86 |
+
device=device,
|
| 87 |
+
mode='adaptive',
|
| 88 |
+
)
|
| 89 |
+
_crnn_decode = _dcp
|
| 90 |
+
print('[template_matcher] CRNN+CTC ready.')
|
| 91 |
+
except Exception as e:
|
| 92 |
+
print(f'[template_matcher] CRNN+CTC load error: {e}')
|
| 93 |
+
return _crnn_ocr
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _crnn_read(crop_img: Image.Image) -> str:
|
| 97 |
+
"""Run CRNN+CTC on a single PIL Image crop and return decoded text."""
|
| 98 |
+
ocr = _get_crnn()
|
| 99 |
+
if ocr is None or _crnn_decode is None:
|
| 100 |
+
return ''
|
| 101 |
+
try:
|
| 102 |
+
import torch
|
| 103 |
+
|
| 104 |
+
rgb = np.array(crop_img.convert('RGB'))
|
| 105 |
+
bgr = rgb[:, :, ::-1].copy()
|
| 106 |
+
normalized = ocr.normalizer.normalize(bgr)
|
| 107 |
+
tensor = torch.FloatTensor(
|
| 108 |
+
normalized.astype(np.float32) / 255.0
|
| 109 |
+
).unsqueeze(0).unsqueeze(0).to(ocr.device)
|
| 110 |
+
|
| 111 |
+
with torch.no_grad():
|
| 112 |
+
outputs = ocr.model(tensor)
|
| 113 |
+
|
| 114 |
+
decoded = _crnn_decode(outputs.cpu(), ocr.idx_to_char, method='greedy')
|
| 115 |
+
return decoded[0].strip()
|
| 116 |
+
except Exception as e:
|
| 117 |
+
print(f'[template_matcher] CRNN+CTC read error: {e}')
|
| 118 |
+
return ''
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _crnn_read_batch(crops: list) -> list:
|
| 122 |
+
"""
|
| 123 |
+
Run CRNN+CTC on a list of PIL Image crops in one forward pass.
|
| 124 |
+
"""
|
| 125 |
+
if not crops:
|
| 126 |
+
return []
|
| 127 |
+
|
| 128 |
+
ocr = _get_crnn()
|
| 129 |
+
if ocr is None or _crnn_decode is None:
|
| 130 |
+
return [''] * len(crops)
|
| 131 |
+
|
| 132 |
+
try:
|
| 133 |
+
import torch
|
| 134 |
+
|
| 135 |
+
tensors = []
|
| 136 |
+
for crop in crops:
|
| 137 |
+
rgb = np.array(crop.convert('RGB'))
|
| 138 |
+
bgr = rgb[:, :, ::-1].copy()
|
| 139 |
+
normalized = ocr.normalizer.normalize(bgr)
|
| 140 |
+
t = torch.FloatTensor(
|
| 141 |
+
normalized.astype(np.float32) / 255.0
|
| 142 |
+
).unsqueeze(0).unsqueeze(0)
|
| 143 |
+
tensors.append(t)
|
| 144 |
+
|
| 145 |
+
batch = torch.cat(tensors, dim=0).to(ocr.device)
|
| 146 |
+
|
| 147 |
+
with torch.no_grad():
|
| 148 |
+
outputs = ocr.model(batch)
|
| 149 |
+
|
| 150 |
+
decoded = _crnn_decode(outputs.cpu(), ocr.idx_to_char, method='greedy')
|
| 151 |
+
return [d.strip() for d in decoded]
|
| 152 |
+
|
| 153 |
+
except Exception as e:
|
| 154 |
+
print(f'[template_matcher] CRNN batch error: {e}; falling back to serial')
|
| 155 |
+
return [_crnn_read(c) for c in crops]
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# ── PaddleOCR engine (DETECTION + OPTIONAL ASSIST TEXT) ──────────
|
| 159 |
+
_paddle_reader = None
|
| 160 |
+
_PADDLE_DETECT_SCALE = 0.75
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _get_paddleocr():
|
| 164 |
+
global _paddle_reader
|
| 165 |
+
if _paddle_reader is None:
|
| 166 |
+
try:
|
| 167 |
+
from paddleocr import PaddleOCR
|
| 168 |
+
print('[template_matcher] Loading PaddleOCR...')
|
| 169 |
+
_paddle_reader = PaddleOCR(
|
| 170 |
+
use_angle_cls=True,
|
| 171 |
+
lang='en',
|
| 172 |
+
)
|
| 173 |
+
print('[template_matcher] PaddleOCR ready.')
|
| 174 |
+
except Exception as e:
|
| 175 |
+
print(f'[template_matcher] PaddleOCR unavailable: {e}')
|
| 176 |
+
return _paddle_reader
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _paddle_detect(img: Image.Image, scale: float = _PADDLE_DETECT_SCALE):
|
| 180 |
+
"""
|
| 181 |
+
Return PaddleOCR detections from a downscaled image and scale boxes back
|
| 182 |
+
to the original image coordinates.
|
| 183 |
+
|
| 184 |
+
Output:
|
| 185 |
+
[
|
| 186 |
+
{
|
| 187 |
+
'box': (x1, y1, x2, y2),
|
| 188 |
+
'text': 'detected text',
|
| 189 |
+
'conf': 0.95,
|
| 190 |
+
'cx': center_x,
|
| 191 |
+
'cy': center_y,
|
| 192 |
+
'poly': [[x, y], ...]
|
| 193 |
+
},
|
| 194 |
+
...
|
| 195 |
+
]
|
| 196 |
+
"""
|
| 197 |
+
ocr = _get_paddleocr()
|
| 198 |
+
if ocr is None:
|
| 199 |
+
return []
|
| 200 |
+
|
| 201 |
+
try:
|
| 202 |
+
orig_w, orig_h = img.size
|
| 203 |
+
small_w = max(1, int(orig_w * scale))
|
| 204 |
+
small_h = max(1, int(orig_h * scale))
|
| 205 |
+
small = img.resize((small_w, small_h), Image.BILINEAR)
|
| 206 |
+
arr = np.array(small.convert('RGB'))
|
| 207 |
+
|
| 208 |
+
raw = ocr.ocr(arr, cls=True)
|
| 209 |
+
if not raw:
|
| 210 |
+
return []
|
| 211 |
+
|
| 212 |
+
detections = []
|
| 213 |
+
pages = raw if isinstance(raw, list) else [raw]
|
| 214 |
+
for page in pages:
|
| 215 |
+
if not page:
|
| 216 |
+
continue
|
| 217 |
+
for item in page:
|
| 218 |
+
if not item or len(item) < 2:
|
| 219 |
+
continue
|
| 220 |
+
box, rec = item
|
| 221 |
+
text, conf = rec if isinstance(rec, (list, tuple)) and len(rec) >= 2 else ('', 0.0)
|
| 222 |
+
xs = [p[0] / scale for p in box]
|
| 223 |
+
ys = [p[1] / scale for p in box]
|
| 224 |
+
x1, y1 = int(min(xs)), int(min(ys))
|
| 225 |
+
x2, y2 = int(max(xs)), int(max(ys))
|
| 226 |
+
detections.append({
|
| 227 |
+
'box': (x1, y1, x2, y2),
|
| 228 |
+
'text': (text or '').strip(),
|
| 229 |
+
'conf': float(conf),
|
| 230 |
+
'cx': (x1 + x2) // 2,
|
| 231 |
+
'cy': (y1 + y2) // 2,
|
| 232 |
+
'poly': [[float(px) / scale, float(py) / scale] for px, py in box],
|
| 233 |
+
})
|
| 234 |
+
|
| 235 |
+
return detections
|
| 236 |
+
except Exception as e:
|
| 237 |
+
print(f'[template_matcher] PaddleOCR detect error: {e}')
|
| 238 |
+
return []
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def _paddle_read(crop_img: Image.Image) -> str:
|
| 242 |
+
"""
|
| 243 |
+
Optional helper for debugging only.
|
| 244 |
+
Not used as final OCR in extraction unless selected by smart merge.
|
| 245 |
+
"""
|
| 246 |
+
ocr = _get_paddleocr()
|
| 247 |
+
if ocr is None:
|
| 248 |
+
return ''
|
| 249 |
+
|
| 250 |
+
try:
|
| 251 |
+
arr = np.array(crop_img.convert('RGB'))
|
| 252 |
+
raw = ocr.ocr(arr, cls=True)
|
| 253 |
+
if not raw:
|
| 254 |
+
return ''
|
| 255 |
+
|
| 256 |
+
pieces = []
|
| 257 |
+
pages = raw if isinstance(raw, list) else [raw]
|
| 258 |
+
for page in pages:
|
| 259 |
+
if not page:
|
| 260 |
+
continue
|
| 261 |
+
page_sorted = sorted(
|
| 262 |
+
page,
|
| 263 |
+
key=lambda item: min(pt[0] for pt in item[0]) if item and item[0] else 0
|
| 264 |
+
)
|
| 265 |
+
for item in page_sorted:
|
| 266 |
+
if item and len(item) >= 2 and item[1]:
|
| 267 |
+
pieces.append((item[1][0] or '').strip())
|
| 268 |
+
|
| 269 |
+
return ' '.join([p for p in pieces if p]).strip()
|
| 270 |
+
except Exception as e:
|
| 271 |
+
print(f'[template_matcher] PaddleOCR read error: {e}')
|
| 272 |
+
return ''
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
# Backward-compatible aliases so old code paths still work.
|
| 276 |
+
def _easyocr_detect(img: Image.Image, scale: float = _PADDLE_DETECT_SCALE):
|
| 277 |
+
return _paddle_detect(img, scale=scale)
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def _easyocr_read(crop_img: Image.Image) -> str:
|
| 281 |
+
return _paddle_read(crop_img)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
# Hint constants
|
| 285 |
+
_LINE = 'line'
|
| 286 |
+
_BLOCK = 'block'
|
| 287 |
+
_WORD = 'word'
|
| 288 |
+
|
| 289 |
+
# ── Post-processing ───────────────────────────────────────────────
|
| 290 |
+
_SEX_KEYWORDS = {
|
| 291 |
+
'female': 'FEMALE', 'fem': 'FEMALE', 'f': 'FEMALE',
|
| 292 |
+
'male': 'MALE', 'm': 'MALE',
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
_NATIONALITY_CANONICAL = {
|
| 296 |
+
'filipino': 'Filipino', 'filipine': 'Filipino', 'filipioo': 'Filipino',
|
| 297 |
+
'filipiao': 'Filipino', 'filipinc': 'Filipino', 'filipin': 'Filipino',
|
| 298 |
+
'filipina': 'Filipino', 'fillipino': 'Filipino', 'fillipine': 'Filipino',
|
| 299 |
+
'philipino': 'Filipino', 'philippino': 'Filipino', 'pilipino': 'Filipino',
|
| 300 |
+
'pilipina': 'Filipino', 'pilipiino': 'Filipino', 'fiipino': 'Filipino',
|
| 301 |
+
'fllipino': 'Filipino', 'fiiipino': 'Filipino', 'filipno': 'Filipino',
|
| 302 |
+
'filipimo': 'Filipino', 'fihpino': 'Filipino',
|
| 303 |
+
'american': 'American', 'americian': 'American', 'amercan': 'American', 'amrican': 'American',
|
| 304 |
+
'chinese': 'Chinese', 'chineze': 'Chinese', 'chines': 'Chinese',
|
| 305 |
+
'japanese': 'Japanese', 'japanase': 'Japanese', 'japanes': 'Japanese',
|
| 306 |
+
'korean': 'Korean', 'koreon': 'Korean',
|
| 307 |
+
'british': 'British', 'britsh': 'British',
|
| 308 |
+
'australian': 'Australian', 'australan': 'Australian',
|
| 309 |
+
'indian': 'Indian', 'indin': 'Indian',
|
| 310 |
+
'spanish': 'Spanish', 'spansh': 'Spanish',
|
| 311 |
+
'indonesian': 'Indonesian', 'malaysian': 'Malaysian', 'thai': 'Thai',
|
| 312 |
+
'vietnamese': 'Vietnamese', 'singaporean': 'Singaporean', 'canadian': 'Canadian',
|
| 313 |
+
'german': 'German', 'french': 'French', 'italian': 'Italian', 'dutch': 'Dutch',
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def _fix_nationality(text: str) -> str:
|
| 318 |
+
key = _re.sub(r'[^a-z]', '', text.lower())
|
| 319 |
+
if not key:
|
| 320 |
+
return text
|
| 321 |
+
|
| 322 |
+
if key in _NATIONALITY_CANONICAL:
|
| 323 |
+
return _NATIONALITY_CANONICAL[key]
|
| 324 |
+
|
| 325 |
+
if len(key) >= 5:
|
| 326 |
+
for canon_key, canon_val in _NATIONALITY_CANONICAL.items():
|
| 327 |
+
if canon_key.startswith(key) or key.startswith(canon_key[:max(5, len(key) - 1)]):
|
| 328 |
+
return canon_val
|
| 329 |
+
|
| 330 |
+
best_val = None
|
| 331 |
+
best_ratio = 0.0
|
| 332 |
+
for canon_key, canon_val in _NATIONALITY_CANONICAL.items():
|
| 333 |
+
longer = max(len(key), len(canon_key))
|
| 334 |
+
if longer == 0:
|
| 335 |
+
continue
|
| 336 |
+
matches = sum(a == b for a, b in zip(key, canon_key))
|
| 337 |
+
ratio = matches / longer
|
| 338 |
+
if ratio > best_ratio:
|
| 339 |
+
best_ratio = ratio
|
| 340 |
+
best_val = canon_val
|
| 341 |
+
|
| 342 |
+
if best_ratio >= 0.78 and best_val is not None:
|
| 343 |
+
return best_val
|
| 344 |
+
|
| 345 |
+
return text
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
_MONTH_CANONICAL = {
|
| 349 |
+
'january': 'January', 'januray': 'January', 'janury': 'January',
|
| 350 |
+
'janaury': 'January', 'janary': 'January', 'januarry': 'January', 'jan': 'January',
|
| 351 |
+
'february': 'February', 'feburary': 'February', 'febuary': 'February',
|
| 352 |
+
'febraury': 'February', 'februray': 'February', 'februay': 'February', 'feb': 'February',
|
| 353 |
+
'march': 'March', 'marct': 'March', 'mauct': 'March', 'mauch': 'March',
|
| 354 |
+
'marh': 'March', 'marc': 'March', 'mach': 'March', 'mrach': 'March', 'mar': 'March',
|
| 355 |
+
'april': 'April', 'apirl': 'April', 'apil': 'April', 'aprl': 'April', 'apri': 'April', 'apr': 'April',
|
| 356 |
+
'may': 'May',
|
| 357 |
+
'june': 'June', 'jun': 'June', 'juen': 'June',
|
| 358 |
+
'july': 'July', 'jully': 'July', 'jul': 'July', 'juy': 'July', 'jly': 'July',
|
| 359 |
+
'august': 'August', 'augst': 'August', 'auguts': 'August', 'agust': 'August', 'aug': 'August',
|
| 360 |
+
'september': 'September', 'septmber': 'September', 'septembar': 'September',
|
| 361 |
+
'sepember': 'September', 'sepetmber': 'September', 'sep': 'September', 'sept': 'September',
|
| 362 |
+
'october': 'October', 'ocober': 'October', 'octber': 'October', 'octobr': 'October', 'oct': 'October',
|
| 363 |
+
'november': 'November', 'novmber': 'November', 'noveber': 'November', 'novembr': 'November', 'nov': 'November',
|
| 364 |
+
'december': 'December', 'decmber': 'December', 'deceber': 'December', 'decembr': 'December', 'dec': 'December',
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
_MONTH_ORDER = {
|
| 368 |
+
'January': 1, 'February': 2, 'March': 3, 'April': 4,
|
| 369 |
+
'May': 5, 'June': 6, 'July': 7, 'August': 8,
|
| 370 |
+
'September': 9, 'October': 10, 'November': 11, 'December': 12,
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def _fix_month_word(word: str) -> str:
|
| 375 |
+
key = _re.sub(r'[^a-z]', '', word.lower())
|
| 376 |
+
if not key:
|
| 377 |
+
return word
|
| 378 |
+
if key in _MONTH_CANONICAL:
|
| 379 |
+
return _MONTH_CANONICAL[key]
|
| 380 |
+
if len(key) >= 3:
|
| 381 |
+
for mkey, mval in _MONTH_CANONICAL.items():
|
| 382 |
+
if mkey.startswith(key) or key.startswith(mkey):
|
| 383 |
+
return mval
|
| 384 |
+
return word
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def _fix_year(year_str: str, context_text: str = '') -> str:
|
| 388 |
+
y = _re.sub(r'[^0-9]', '', year_str)
|
| 389 |
+
if not y:
|
| 390 |
+
return year_str
|
| 391 |
+
|
| 392 |
+
if len(y) == 4:
|
| 393 |
+
yr = int(y)
|
| 394 |
+
if 1900 <= yr <= 2030:
|
| 395 |
+
return y
|
| 396 |
+
if y.startswith('0'):
|
| 397 |
+
candidate = '2' + y[1:]
|
| 398 |
+
if 1900 <= int(candidate) <= 2030:
|
| 399 |
+
return candidate
|
| 400 |
+
return y
|
| 401 |
+
|
| 402 |
+
if len(y) == 3:
|
| 403 |
+
specific = {
|
| 404 |
+
'202': '2022', '201': '2015', '200': '2000',
|
| 405 |
+
'199': '1999', '198': '1985', '197': '1975',
|
| 406 |
+
'196': '1965', '195': '1955',
|
| 407 |
+
}
|
| 408 |
+
if y in specific:
|
| 409 |
+
return specific[y]
|
| 410 |
+
return y + '0'
|
| 411 |
+
|
| 412 |
+
if len(y) == 2:
|
| 413 |
+
yr = int(y)
|
| 414 |
+
return str(1900 + yr) if yr >= 40 else str(2000 + yr)
|
| 415 |
+
|
| 416 |
+
return y
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
def _fix_date_string(text: str) -> str:
|
| 420 |
+
text = _re.sub(r'[^\w\s\-/,.]', '', text).strip()
|
| 421 |
+
if not text:
|
| 422 |
+
return text
|
| 423 |
+
|
| 424 |
+
if _re.fullmatch(r'\d{4}[-/]\d{1,2}[-/]\d{1,2}', text):
|
| 425 |
+
return text
|
| 426 |
+
if _re.fullmatch(r'\d{1,2}[-/]\d{1,2}[-/]\d{2,4}', text):
|
| 427 |
+
parts = _re.split(r'[-/]', text)
|
| 428 |
+
sep = '-' if '-' in text else '/'
|
| 429 |
+
parts[-1] = _fix_year(parts[-1], text)
|
| 430 |
+
return sep.join(parts)
|
| 431 |
+
|
| 432 |
+
tokens = _re.split(r'([\s,\-/.]+)', text)
|
| 433 |
+
result = []
|
| 434 |
+
|
| 435 |
+
for tok in tokens:
|
| 436 |
+
stripped = tok.strip(' ,.-/')
|
| 437 |
+
if not stripped:
|
| 438 |
+
result.append(tok)
|
| 439 |
+
continue
|
| 440 |
+
|
| 441 |
+
if _re.fullmatch(r'\d+', stripped):
|
| 442 |
+
num = int(stripped)
|
| 443 |
+
if 1 <= num <= 31 and len(stripped) <= 2:
|
| 444 |
+
result.append(tok)
|
| 445 |
+
elif len(stripped) in (2, 3, 4):
|
| 446 |
+
fixed = _fix_year(stripped, text)
|
| 447 |
+
result.append(tok.replace(stripped, fixed))
|
| 448 |
+
else:
|
| 449 |
+
result.append(tok)
|
| 450 |
+
continue
|
| 451 |
+
|
| 452 |
+
corrected_month = _fix_month_word(stripped)
|
| 453 |
+
if corrected_month != stripped:
|
| 454 |
+
result.append(tok.replace(stripped, corrected_month))
|
| 455 |
+
continue
|
| 456 |
+
|
| 457 |
+
result.append(tok)
|
| 458 |
+
|
| 459 |
+
return ''.join(result).strip()
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
_FIELD_TYPE = {
|
| 463 |
+
'sex': 'sex', 'groom_sex': 'sex', 'bride_sex': 'sex',
|
| 464 |
+
'husband_sex': 'sex', 'wife_sex': 'sex',
|
| 465 |
+
'dob_year': 'year',
|
| 466 |
+
'age': 'digits', 'groom_age': 'digits', 'bride_age': 'digits',
|
| 467 |
+
'husband_age': 'digits', 'wife_age': 'digits', 'dob_day': 'digits',
|
| 468 |
+
'registration_date': 'date', 'marriage_date': 'date',
|
| 469 |
+
'date_of_marriage': 'date', 'date_of_death': 'date',
|
| 470 |
+
'date_of_birth': 'date', 'date_issued': 'date',
|
| 471 |
+
'groom_dob': 'date', 'bride_dob': 'date',
|
| 472 |
+
'husband_dob': 'date', 'wife_dob': 'date',
|
| 473 |
+
'registry_no': 'registry', 'marriage_license_no': 'registry',
|
| 474 |
+
'mother_citizenship': 'nationality', 'father_citizenship': 'nationality',
|
| 475 |
+
'citizenship': 'nationality',
|
| 476 |
+
'groom_citizenship': 'nationality', 'bride_citizenship': 'nationality',
|
| 477 |
+
'husband_citizenship': 'nationality', 'wife_citizenship': 'nationality',
|
| 478 |
+
'groom_father_citizenship': 'nationality', 'groom_mother_citizenship': 'nationality',
|
| 479 |
+
'bride_father_citizenship': 'nationality', 'bride_mother_citizenship': 'nationality',
|
| 480 |
+
'husband_father_citizenship': 'nationality', 'husband_mother_citizenship': 'nationality',
|
| 481 |
+
'wife_father_citizenship': 'nationality', 'wife_mother_citizenship': 'nationality',
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
def _postprocess(text: str, field_name: str) -> str:
|
| 486 |
+
text = text.strip()
|
| 487 |
+
if not text:
|
| 488 |
+
return ''
|
| 489 |
+
|
| 490 |
+
rule = _FIELD_TYPE.get(field_name)
|
| 491 |
+
|
| 492 |
+
if rule == 'sex':
|
| 493 |
+
tl = text.lower()
|
| 494 |
+
for kw in sorted(_SEX_KEYWORDS, key=len, reverse=True):
|
| 495 |
+
if kw in tl:
|
| 496 |
+
return _SEX_KEYWORDS[kw]
|
| 497 |
+
return ''
|
| 498 |
+
|
| 499 |
+
if rule == 'nationality':
|
| 500 |
+
parts = text.split()
|
| 501 |
+
whole = _fix_nationality(text)
|
| 502 |
+
if whole.lower() != text.lower():
|
| 503 |
+
return whole
|
| 504 |
+
fixed = [_fix_nationality(p) for p in parts]
|
| 505 |
+
return ' '.join(fixed)
|
| 506 |
+
|
| 507 |
+
if rule == 'year':
|
| 508 |
+
m = _re.search(r'(19|20)\d{2}', text)
|
| 509 |
+
if m:
|
| 510 |
+
return m.group(0)
|
| 511 |
+
m3 = _re.search(r'\b(19\d|20\d)\b', text)
|
| 512 |
+
if m3:
|
| 513 |
+
return _fix_year(m3.group(0))
|
| 514 |
+
digits = _re.sub(r'\D', '', text)
|
| 515 |
+
if len(digits) >= 4:
|
| 516 |
+
return digits[:4]
|
| 517 |
+
if len(digits) == 3:
|
| 518 |
+
return _fix_year(digits)
|
| 519 |
+
return ''
|
| 520 |
+
|
| 521 |
+
if rule == 'digits':
|
| 522 |
+
d = _re.sub(r'\D', '', text)
|
| 523 |
+
return d if d else ''
|
| 524 |
+
|
| 525 |
+
if rule == 'date':
|
| 526 |
+
cleaned = _re.sub(r'[^\w\s\-/,.]', '', text).strip()
|
| 527 |
+
if len(cleaned) < 3:
|
| 528 |
+
return ''
|
| 529 |
+
return _fix_date_string(cleaned)
|
| 530 |
+
|
| 531 |
+
if rule == 'registry':
|
| 532 |
+
cleaned = _re.sub(r'[^\w\s\-/]', '', text).strip()
|
| 533 |
+
return cleaned if len(cleaned) >= 2 else ''
|
| 534 |
+
|
| 535 |
+
cleaned = _re.sub(r'\s+', ' ', text).strip()
|
| 536 |
+
|
| 537 |
+
if len(cleaned) == 1:
|
| 538 |
+
return ''
|
| 539 |
+
|
| 540 |
+
if len(cleaned) <= 2 and not _re.search(r'[aeiou0-9]', cleaned.lower()):
|
| 541 |
+
return ''
|
| 542 |
+
|
| 543 |
+
return cleaned
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
def _is_valid_field_value(field_name: str, text: str) -> bool:
|
| 547 |
+
if not text:
|
| 548 |
+
return False
|
| 549 |
+
|
| 550 |
+
rule = _FIELD_TYPE.get(field_name)
|
| 551 |
+
if rule in ('digits', 'year', 'date', 'registry', 'sex', 'nationality'):
|
| 552 |
+
return True
|
| 553 |
+
|
| 554 |
+
cleaned = text.strip()
|
| 555 |
+
if not _re.search(r'[A-Za-z0-9]', cleaned):
|
| 556 |
+
return False
|
| 557 |
+
if len(cleaned) <= 1:
|
| 558 |
+
return False
|
| 559 |
+
return True
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
def _text_quality_score(field_name: str, text: str) -> float:
|
| 563 |
+
if not text:
|
| 564 |
+
return -999.0
|
| 565 |
+
|
| 566 |
+
score = 0.0
|
| 567 |
+
t = text.strip()
|
| 568 |
+
|
| 569 |
+
score += len(t)
|
| 570 |
+
score -= len(_re.findall(r'[^A-Za-z0-9\s\-/,.]', t)) * 2.0
|
| 571 |
+
score += len(_re.findall(r'[A-Za-z0-9]', t)) * 0.5
|
| 572 |
+
|
| 573 |
+
rule = _FIELD_TYPE.get(field_name)
|
| 574 |
+
|
| 575 |
+
if rule == 'digits':
|
| 576 |
+
if _re.fullmatch(r'\d+', _re.sub(r'\D', '', t)):
|
| 577 |
+
score += 8.0
|
| 578 |
+
elif rule == 'year':
|
| 579 |
+
if _re.search(r'(19|20)\d{2}', t):
|
| 580 |
+
score += 10.0
|
| 581 |
+
elif rule == 'date':
|
| 582 |
+
if _re.search(r'\b\d{1,2}\b', t) or _re.search(
|
| 583 |
+
r'(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)', t.upper()
|
| 584 |
+
):
|
| 585 |
+
score += 8.0
|
| 586 |
+
for month in _MONTH_ORDER:
|
| 587 |
+
if month in t:
|
| 588 |
+
score += 5.0
|
| 589 |
+
break
|
| 590 |
+
if _re.search(r'(19|20)\d{2}', t):
|
| 591 |
+
score += 5.0
|
| 592 |
+
elif rule == 'sex':
|
| 593 |
+
tl = t.lower()
|
| 594 |
+
if 'male' in tl or 'female' in tl or tl in ('m', 'f'):
|
| 595 |
+
score += 10.0
|
| 596 |
+
elif rule == 'registry':
|
| 597 |
+
if _re.search(r'[A-Za-z0-9]', t):
|
| 598 |
+
score += 8.0
|
| 599 |
+
elif rule == 'nationality':
|
| 600 |
+
key = _re.sub(r'[^a-z]', '', t.lower())
|
| 601 |
+
if key in _NATIONALITY_CANONICAL:
|
| 602 |
+
score += 12.0
|
| 603 |
+
elif len(key) >= 5 and any(k.startswith(key[:5]) for k in _NATIONALITY_CANONICAL):
|
| 604 |
+
score += 6.0
|
| 605 |
+
|
| 606 |
+
return score
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
def _smart_merge(field_name: str, crnn_text: str, assist_text: str) -> str:
|
| 610 |
+
crnn_post = _postprocess(crnn_text, field_name)
|
| 611 |
+
assist_post = _postprocess(assist_text, field_name)
|
| 612 |
+
|
| 613 |
+
crnn_ok = _is_valid_field_value(field_name, crnn_post)
|
| 614 |
+
assist_ok = _is_valid_field_value(field_name, assist_post)
|
| 615 |
+
|
| 616 |
+
if crnn_ok and not assist_ok:
|
| 617 |
+
return crnn_post
|
| 618 |
+
if assist_ok and not crnn_ok:
|
| 619 |
+
return assist_post
|
| 620 |
+
if not crnn_ok and not assist_ok:
|
| 621 |
+
return crnn_post or assist_post or ''
|
| 622 |
+
|
| 623 |
+
crnn_score = _text_quality_score(field_name, crnn_post)
|
| 624 |
+
assist_score = _text_quality_score(field_name, assist_post)
|
| 625 |
+
return crnn_post if crnn_score >= assist_score else assist_post
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
TEMPLATES = {
|
| 629 |
+
'102': {
|
| 630 |
+
'province': (0.169, 0.109, 0.608, 0.134, _LINE),
|
| 631 |
+
'registry_no': (0.613, 0.119, 0.884, 0.152, _LINE),
|
| 632 |
+
'city_municipality': (0.220, 0.132, 0.608, 0.153, _LINE),
|
| 633 |
+
'name_first': (0.132, 0.165, 0.398, 0.185, _LINE),
|
| 634 |
+
'name_middle': (0.397, 0.165, 0.646, 0.186, _LINE),
|
| 635 |
+
'name_last': (0.646, 0.165, 0.882, 0.185, _LINE),
|
| 636 |
+
'sex': (0.122, 0.195, 0.325, 0.215, _WORD),
|
| 637 |
+
'dob_day': (0.458, 0.197, 0.565, 0.216, _WORD),
|
| 638 |
+
'dob_month': (0.564, 0.195, 0.750, 0.216, _LINE),
|
| 639 |
+
'dob_year': (0.748, 0.196, 0.883, 0.216, _WORD),
|
| 640 |
+
'place_of_birth': (0.380, 0.225, 0.886, 0.244, _LINE),
|
| 641 |
+
'type_of_birth': (0.124, 0.268, 0.329, 0.290, _WORD),
|
| 642 |
+
'birth_order': (0.543, 0.275, 0.746, 0.290, _WORD),
|
| 643 |
+
'weight_at_birth': (0.752, 0.257, 0.838, 0.289, _WORD),
|
| 644 |
+
'mother_name': (0.184, 0.302, 0.885, 0.322, _LINE),
|
| 645 |
+
'mother_citizenship': (0.126, 0.332, 0.503, 0.354, _LINE),
|
| 646 |
+
'mother_religion': (0.508, 0.335, 0.882, 0.354, _LINE),
|
| 647 |
+
'mother_occupation': (0.512, 0.364, 0.759, 0.392, _LINE),
|
| 648 |
+
'mother_age_at_birth': (0.758, 0.373, 0.888, 0.392, _WORD),
|
| 649 |
+
'mother_residence': (0.139, 0.402, 0.888, 0.426, _LINE),
|
| 650 |
+
'father_name': (0.129, 0.437, 0.885, 0.458, _LINE),
|
| 651 |
+
'father_citizenship': (0.124, 0.470, 0.314, 0.497, _LINE),
|
| 652 |
+
'father_religion': (0.316, 0.470, 0.546, 0.498, _LINE),
|
| 653 |
+
'father_occupation': (0.546, 0.470, 0.750, 0.496, _LINE),
|
| 654 |
+
'father_age_at_birth': (0.750, 0.478, 0.887, 0.498, _WORD),
|
| 655 |
+
'father_residence': (0.139, 0.508, 0.889, 0.531, _LINE),
|
| 656 |
+
'marriage_date': (0.105, 0.556, 0.397, 0.581, _LINE),
|
| 657 |
+
'marriage_place': (0.399, 0.557, 0.887, 0.582, _LINE),
|
| 658 |
+
'registration_date': (0.540, 0.898, 0.880, 0.917, _LINE),
|
| 659 |
+
},
|
| 660 |
+
'103': {
|
| 661 |
+
'province': (0.164, 0.082, 0.628, 0.102, _LINE),
|
| 662 |
+
'registry_no': (0.636, 0.093, 0.925, 0.123, _LINE),
|
| 663 |
+
'city_municipality': (0.219, 0.099, 0.629, 0.122, _LINE),
|
| 664 |
+
'deceased_name': (0.106, 0.144, 0.721, 0.174, _LINE),
|
| 665 |
+
'sex': (0.723, 0.140, 0.925, 0.174, _WORD),
|
| 666 |
+
'date_of_death': (0.094, 0.192, 0.311, 0.220, _LINE),
|
| 667 |
+
'date_of_birth': (0.315, 0.192, 0.560, 0.218, _LINE),
|
| 668 |
+
'age': (0.562, 0.199, 0.703, 0.218, _WORD),
|
| 669 |
+
'place_of_death': (0.092, 0.233, 0.703, 0.258, _LINE),
|
| 670 |
+
'civil_status': (0.701, 0.236, 0.930, 0.258, _WORD),
|
| 671 |
+
'religion': (0.092, 0.273, 0.312, 0.298, _LINE),
|
| 672 |
+
'citizenship': (0.311, 0.272, 0.507, 0.298, _LINE),
|
| 673 |
+
'residence': (0.507, 0.269, 0.929, 0.297, _LINE),
|
| 674 |
+
'occupation': (0.090, 0.309, 0.285, 0.336, _LINE),
|
| 675 |
+
'father_name': (0.284, 0.311, 0.603, 0.334, _LINE),
|
| 676 |
+
'mother_name': (0.601, 0.309, 0.932, 0.333, _LINE),
|
| 677 |
+
'cause_immediate': (0.295, 0.373, 0.690, 0.389, _LINE),
|
| 678 |
+
'cause_antecedent': (0.301, 0.388, 0.697, 0.407, _LINE),
|
| 679 |
+
'cause_underlying': (0.301, 0.406, 0.685, 0.425, _LINE),
|
| 680 |
+
'registration_date': (0.559, 0.955, 0.922, 0.974, _LINE),
|
| 681 |
+
},
|
| 682 |
+
'90': {
|
| 683 |
+
'province': (0.199, 0.094, 0.637, 0.116, _LINE),
|
| 684 |
+
'registry_no': (0.645, 0.108, 0.909, 0.133, _LINE),
|
| 685 |
+
'city_municipality': (0.248, 0.114, 0.634, 0.133, _LINE),
|
| 686 |
+
'marriage_license_no': (0.666, 0.133, 0.916, 0.151, _LINE),
|
| 687 |
+
'date_issued': (0.766, 0.148, 0.916, 0.166, _LINE),
|
| 688 |
+
'groom_name_first': (0.170, 0.292, 0.467, 0.311, _LINE),
|
| 689 |
+
'groom_name_middle': (0.172, 0.307, 0.471, 0.323, _LINE),
|
| 690 |
+
'groom_name_last': (0.172, 0.323, 0.471, 0.338, _LINE),
|
| 691 |
+
'bride_name_first': (0.617, 0.292, 0.918, 0.307, _LINE),
|
| 692 |
+
'bride_name_middle': (0.621, 0.308, 0.917, 0.324, _LINE),
|
| 693 |
+
'bride_name_last': (0.615, 0.323, 0.915, 0.338, _LINE),
|
| 694 |
+
'groom_dob': (0.133, 0.348, 0.396, 0.370, _LINE),
|
| 695 |
+
'groom_age': (0.396, 0.347, 0.473, 0.368, _WORD),
|
| 696 |
+
'bride_dob': (0.574, 0.349, 0.840, 0.369, _LINE),
|
| 697 |
+
'bride_age': (0.842, 0.348, 0.921, 0.370, _WORD),
|
| 698 |
+
'groom_place_of_birth': (0.136, 0.380, 0.480, 0.402, _LINE),
|
| 699 |
+
'bride_place_of_birth': (0.577, 0.379, 0.923, 0.402, _LINE),
|
| 700 |
+
'groom_sex': (0.133, 0.408, 0.267, 0.426, _WORD),
|
| 701 |
+
'groom_citizenship': (0.265, 0.409, 0.476, 0.428, _LINE),
|
| 702 |
+
'bride_sex': (0.581, 0.408, 0.711, 0.429, _WORD),
|
| 703 |
+
'bride_citizenship': (0.708, 0.410, 0.921, 0.430, _LINE),
|
| 704 |
+
'groom_residence': (0.133, 0.437, 0.479, 0.463, _LINE),
|
| 705 |
+
'bride_residence': (0.579, 0.439, 0.932, 0.466, _LINE),
|
| 706 |
+
'groom_religion': (0.129, 0.465, 0.480, 0.494, _LINE),
|
| 707 |
+
'bride_religion': (0.580, 0.464, 0.927, 0.490, _LINE),
|
| 708 |
+
'groom_civil_status': (0.128, 0.493, 0.480, 0.518, _WORD),
|
| 709 |
+
'bride_civil_status': (0.580, 0.493, 0.925, 0.517, _WORD),
|
| 710 |
+
'groom_father_name': (0.132, 0.648, 0.477, 0.670, _LINE),
|
| 711 |
+
'groom_father_citizenship': (0.128, 0.668, 0.475, 0.691, _LINE),
|
| 712 |
+
'bride_father_name': (0.575, 0.649, 0.925, 0.670, _LINE),
|
| 713 |
+
'bride_father_citizenship': (0.575, 0.671, 0.925, 0.693, _LINE),
|
| 714 |
+
'groom_mother_name': (0.125, 0.740, 0.476, 0.762, _LINE),
|
| 715 |
+
'groom_mother_citizenship': (0.122, 0.762, 0.477, 0.780, _LINE),
|
| 716 |
+
'bride_mother_name': (0.575, 0.739, 0.923, 0.762, _LINE),
|
| 717 |
+
'bride_mother_citizenship': (0.572, 0.760, 0.922, 0.780, _LINE),
|
| 718 |
+
},
|
| 719 |
+
'97': {
|
| 720 |
+
'province': (0.186, 0.092, 0.603, 0.113, _LINE),
|
| 721 |
+
'registry_no': (0.743, 0.094, 0.941, 0.129, _LINE),
|
| 722 |
+
'city_municipality': (0.184, 0.112, 0.603, 0.132, _LINE),
|
| 723 |
+
'husband_name_first': (0.244, 0.154, 0.553, 0.175, _LINE),
|
| 724 |
+
'husband_name_middle': (0.245, 0.175, 0.549, 0.196, _LINE),
|
| 725 |
+
'husband_name_last': (0.244, 0.198, 0.553, 0.215, _LINE),
|
| 726 |
+
'wife_name_first': (0.631, 0.154, 0.940, 0.176, _LINE),
|
| 727 |
+
'wife_name_middle': (0.630, 0.174, 0.941, 0.195, _LINE),
|
| 728 |
+
'wife_name_last': (0.633, 0.197, 0.942, 0.216, _LINE),
|
| 729 |
+
'husband_dob': (0.191, 0.228, 0.475, 0.249, _LINE),
|
| 730 |
+
'husband_age': (0.480, 0.230, 0.543, 0.248, _WORD),
|
| 731 |
+
'wife_dob': (0.579, 0.226, 0.862, 0.248, _LINE),
|
| 732 |
+
'wife_age': (0.863, 0.228, 0.937, 0.248, _WORD),
|
| 733 |
+
'husband_place_of_birth': (0.169, 0.259, 0.554, 0.279, _LINE),
|
| 734 |
+
'wife_place_of_birth': (0.557, 0.258, 0.953, 0.280, _LINE),
|
| 735 |
+
'husband_sex': (0.211, 0.282, 0.309, 0.309, _WORD),
|
| 736 |
+
'wife_sex': (0.597, 0.281, 0.701, 0.310, _WORD),
|
| 737 |
+
'husband_citizenship': (0.309, 0.290, 0.553, 0.310, _LINE),
|
| 738 |
+
'wife_citizenship': (0.698, 0.289, 0.939, 0.310, _LINE),
|
| 739 |
+
'husband_residence': (0.177, 0.324, 0.550, 0.361, _LINE),
|
| 740 |
+
'wife_residence': (0.566, 0.323, 0.942, 0.362, _LINE),
|
| 741 |
+
'husband_religion': (0.177, 0.363, 0.550, 0.391, _LINE),
|
| 742 |
+
'wife_religion': (0.563, 0.363, 0.943, 0.387, _LINE),
|
| 743 |
+
'husband_civil_status': (0.171, 0.392, 0.554, 0.416, _WORD),
|
| 744 |
+
'wife_civil_status': (0.570, 0.395, 0.955, 0.415, _WORD),
|
| 745 |
+
'husband_father_name': (0.181, 0.427, 0.551, 0.448, _LINE),
|
| 746 |
+
'wife_father_name': (0.561, 0.425, 0.955, 0.446, _LINE),
|
| 747 |
+
'husband_father_citizenship': (0.175, 0.449, 0.551, 0.466, _LINE),
|
| 748 |
+
'wife_father_citizenship': (0.561, 0.447, 0.943, 0.467, _LINE),
|
| 749 |
+
'husband_mother_name': (0.181, 0.476, 0.557, 0.496, _LINE),
|
| 750 |
+
'wife_mother_name': (0.564, 0.477, 0.955, 0.499, _LINE),
|
| 751 |
+
'husband_mother_citizenship': (0.184, 0.500, 0.550, 0.518, _LINE),
|
| 752 |
+
'wife_mother_citizenship': (0.561, 0.499, 0.939, 0.518, _LINE),
|
| 753 |
+
'place_of_marriage': (0.179, 0.640, 0.941, 0.665, _LINE),
|
| 754 |
+
'date_of_marriage': (0.182, 0.674, 0.556, 0.696, _LINE),
|
| 755 |
+
'time_of_marriage': (0.734, 0.674, 0.889, 0.696, _LINE),
|
| 756 |
+
'registration_date': (0.655, 0.749, 0.935, 0.769, _LINE),
|
| 757 |
+
},
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
USE_SELECTIVE_PADDLE_ASSIST = True
|
| 761 |
+
PADDLE_ASSIST_FIELDS = {
|
| 762 |
+
'province',
|
| 763 |
+
'registry_no',
|
| 764 |
+
'city_municipality',
|
| 765 |
+
'date_issued',
|
| 766 |
+
'registration_date',
|
| 767 |
+
'marriage_license_no',
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
+
|
| 771 |
+
def warmup():
|
| 772 |
+
print('[template_matcher] Warming up models and caches...')
|
| 773 |
+
_get_crnn()
|
| 774 |
+
_get_paddleocr()
|
| 775 |
+
for ft in REFERENCE_IMAGES:
|
| 776 |
+
img = _get_ref_gray(ft)
|
| 777 |
+
status = 'OK' if img is not None else 'NOT FOUND'
|
| 778 |
+
print(f'[template_matcher] Reference {ft}: {status}')
|
| 779 |
+
print('[template_matcher] Warmup complete.')
|
| 780 |
+
|
| 781 |
+
|
| 782 |
+
def _order_corners(pts: np.ndarray) -> np.ndarray:
|
| 783 |
+
s = pts.sum(axis=1)
|
| 784 |
+
d = np.diff(pts, axis=1).flatten()
|
| 785 |
+
return np.array([
|
| 786 |
+
pts[np.argmin(s)],
|
| 787 |
+
pts[np.argmin(d)],
|
| 788 |
+
pts[np.argmax(s)],
|
| 789 |
+
pts[np.argmax(d)],
|
| 790 |
+
], dtype=np.float32)
|
| 791 |
+
|
| 792 |
+
|
| 793 |
+
def _correct_perspective(scan_rgb: np.ndarray, ref_w: int, ref_h: int) -> np.ndarray:
|
| 794 |
+
if not _CV2_OK:
|
| 795 |
+
return scan_rgb
|
| 796 |
+
|
| 797 |
+
gray = _cv2.cvtColor(scan_rgb, _cv2.COLOR_RGB2GRAY)
|
| 798 |
+
kernel = _cv2.getStructuringElement(_cv2.MORPH_RECT, (5, 5))
|
| 799 |
+
blur = _cv2.GaussianBlur(gray, (7, 7), 0)
|
| 800 |
+
_, thresh = _cv2.threshold(blur, 0, 255, _cv2.THRESH_BINARY + _cv2.THRESH_OTSU)
|
| 801 |
+
dilated = _cv2.dilate(thresh, kernel, iterations=2)
|
| 802 |
+
contours, _ = _cv2.findContours(dilated, _cv2.RETR_EXTERNAL, _cv2.CHAIN_APPROX_SIMPLE)
|
| 803 |
+
|
| 804 |
+
if not contours:
|
| 805 |
+
return scan_rgb
|
| 806 |
+
|
| 807 |
+
c = max(contours, key=_cv2.contourArea)
|
| 808 |
+
area = _cv2.contourArea(c)
|
| 809 |
+
if area < 0.30 * gray.shape[0] * gray.shape[1]:
|
| 810 |
+
print('[align] perspective: contour too small, skipping')
|
| 811 |
+
return scan_rgb
|
| 812 |
+
|
| 813 |
+
peri = _cv2.arcLength(c, True)
|
| 814 |
+
approx = _cv2.approxPolyDP(c, 0.02 * peri, True)
|
| 815 |
+
if len(approx) != 4:
|
| 816 |
+
print(f'[align] perspective: {len(approx)} corners (need 4), skipping')
|
| 817 |
+
return scan_rgb
|
| 818 |
+
|
| 819 |
+
src = _order_corners(approx.reshape(4, 2).astype(np.float32))
|
| 820 |
+
dst = np.array([
|
| 821 |
+
[0, 0], [ref_w - 1, 0],
|
| 822 |
+
[ref_w - 1, ref_h - 1], [0, ref_h - 1],
|
| 823 |
+
], dtype=np.float32)
|
| 824 |
+
|
| 825 |
+
M = _cv2.getPerspectiveTransform(src, dst)
|
| 826 |
+
warped = _cv2.warpPerspective(
|
| 827 |
+
scan_rgb, M, (ref_w, ref_h),
|
| 828 |
+
flags=_cv2.INTER_LINEAR, borderMode=_cv2.BORDER_REPLICATE,
|
| 829 |
+
)
|
| 830 |
+
print('[align] perspective correction applied')
|
| 831 |
+
return warped
|
| 832 |
+
|
| 833 |
+
|
| 834 |
+
def _ecc_align(scan_gray: np.ndarray, ref_gray: np.ndarray, scan_rgb: np.ndarray):
|
| 835 |
+
try:
|
| 836 |
+
h, w = ref_gray.shape
|
| 837 |
+
scale = min(1.0, 500.0 / max(h, w))
|
| 838 |
+
sh, sw = max(1, int(h * scale)), max(1, int(w * scale))
|
| 839 |
+
|
| 840 |
+
ref_s = _cv2.resize(ref_gray, (sw, sh))
|
| 841 |
+
scn_s = _cv2.resize(_cv2.resize(scan_gray, (w, h)), (sw, sh))
|
| 842 |
+
|
| 843 |
+
warp = np.eye(2, 3, dtype=np.float32)
|
| 844 |
+
criteria = (_cv2.TERM_CRITERIA_EPS | _cv2.TERM_CRITERIA_COUNT, 50, 1e-3)
|
| 845 |
+
cc, warp = _cv2.findTransformECC(ref_s, scn_s, warp, _cv2.MOTION_AFFINE, criteria)
|
| 846 |
+
|
| 847 |
+
if cc < 0.3:
|
| 848 |
+
print(f'[align] ECC low confidence (cc={cc:.4f}), skipping')
|
| 849 |
+
return None
|
| 850 |
+
|
| 851 |
+
angle = np.degrees(np.arctan2(warp[1, 0], warp[0, 0]))
|
| 852 |
+
if abs(angle) > 1.0:
|
| 853 |
+
clamped = np.radians(np.clip(angle, -1.0, 1.0))
|
| 854 |
+
warp[0, 0] = np.cos(clamped)
|
| 855 |
+
warp[0, 1] = -np.sin(clamped)
|
| 856 |
+
warp[1, 0] = np.sin(clamped)
|
| 857 |
+
warp[1, 1] = np.cos(clamped)
|
| 858 |
+
|
| 859 |
+
warp[0, 2] /= scale
|
| 860 |
+
warp[1, 2] /= scale
|
| 861 |
+
|
| 862 |
+
scan_full = _cv2.resize(scan_rgb, (w, h))
|
| 863 |
+
aligned = _cv2.warpAffine(
|
| 864 |
+
scan_full, warp, (w, h),
|
| 865 |
+
flags=_cv2.INTER_LINEAR, borderMode=_cv2.BORDER_REPLICATE,
|
| 866 |
+
)
|
| 867 |
+
print(f'[align] ECC applied (cc={cc:.4f} angle={angle:.2f}°)')
|
| 868 |
+
return aligned
|
| 869 |
+
except Exception as e:
|
| 870 |
+
print(f'[align] ECC failed: {e}')
|
| 871 |
+
return None
|
| 872 |
+
|
| 873 |
+
|
| 874 |
+
def _orb_align(scan_gray: np.ndarray, ref_gray: np.ndarray, scan_rgb: np.ndarray):
|
| 875 |
+
h, w = scan_gray.shape
|
| 876 |
+
ref_resized = _cv2.resize(ref_gray, (w, h))
|
| 877 |
+
|
| 878 |
+
orb = _cv2.ORB_create(nfeatures=5000)
|
| 879 |
+
kp1, des1 = orb.detectAndCompute(scan_gray, None)
|
| 880 |
+
kp2, des2 = orb.detectAndCompute(ref_resized, None)
|
| 881 |
+
|
| 882 |
+
if des1 is None or des2 is None or len(kp1) < 10 or len(kp2) < 10:
|
| 883 |
+
return None, 0
|
| 884 |
+
|
| 885 |
+
matcher = _cv2.BFMatcher(_cv2.NORM_HAMMING, crossCheck=True)
|
| 886 |
+
matches = sorted(matcher.match(des1, des2), key=lambda m: m.distance)
|
| 887 |
+
good = matches[:max(10, len(matches) // 3)]
|
| 888 |
+
|
| 889 |
+
if len(good) < 6:
|
| 890 |
+
return None, 0
|
| 891 |
+
|
| 892 |
+
src_pts = np.float32([kp1[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
|
| 893 |
+
dst_pts = np.float32([kp2[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
|
| 894 |
+
|
| 895 |
+
M, mask = _cv2.estimateAffinePartial2D(
|
| 896 |
+
src_pts, dst_pts, method=_cv2.RANSAC, ransacReprojThreshold=5.0,
|
| 897 |
+
)
|
| 898 |
+
if M is None:
|
| 899 |
+
return None, 0
|
| 900 |
+
|
| 901 |
+
inliers = int(mask.sum()) if mask is not None else 0
|
| 902 |
+
aligned = _cv2.warpAffine(
|
| 903 |
+
scan_rgb, M, (w, h),
|
| 904 |
+
flags=_cv2.INTER_LINEAR, borderMode=_cv2.BORDER_REPLICATE,
|
| 905 |
+
)
|
| 906 |
+
print(f'[align] ORB applied ({inliers} inliers)')
|
| 907 |
+
return aligned, inliers
|
| 908 |
+
|
| 909 |
+
|
| 910 |
+
def _orb_inliers(scan_gray: np.ndarray, ref_gray: np.ndarray) -> int:
|
| 911 |
+
orb = _cv2.ORB_create(nfeatures=3000)
|
| 912 |
+
kp1, des1 = orb.detectAndCompute(scan_gray, None)
|
| 913 |
+
kp2, des2 = orb.detectAndCompute(ref_gray, None)
|
| 914 |
+
|
| 915 |
+
if des1 is None or des2 is None or len(kp1) < 10 or len(kp2) < 10:
|
| 916 |
+
return 0
|
| 917 |
+
|
| 918 |
+
matcher = _cv2.BFMatcher(_cv2.NORM_HAMMING, crossCheck=True)
|
| 919 |
+
matches = sorted(matcher.match(des1, des2), key=lambda m: m.distance)
|
| 920 |
+
good = matches[:max(10, len(matches) // 3)]
|
| 921 |
+
|
| 922 |
+
if len(good) < 6:
|
| 923 |
+
return 0
|
| 924 |
+
|
| 925 |
+
src_pts = np.float32([kp1[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
|
| 926 |
+
dst_pts = np.float32([kp2[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
|
| 927 |
+
|
| 928 |
+
_, mask = _cv2.findHomography(src_pts, dst_pts, _cv2.RANSAC, 5.0)
|
| 929 |
+
return int(mask.sum()) if mask is not None else 0
|
| 930 |
+
|
| 931 |
+
|
| 932 |
+
def check_image_quality(image_path: str, form_type: str) -> dict:
|
| 933 |
+
if not _CV2_OK:
|
| 934 |
+
return {
|
| 935 |
+
'ok': True,
|
| 936 |
+
'upside_down': False,
|
| 937 |
+
'skew_angle': 0.0,
|
| 938 |
+
'aspect_mismatch': 1.0,
|
| 939 |
+
'orb_fit': 0,
|
| 940 |
+
'orb_fit_normal': 0,
|
| 941 |
+
'orb_fit_180': 0,
|
| 942 |
+
'blur_score': 9999.0,
|
| 943 |
+
'warnings': ['OpenCV not available; skipping quality check'],
|
| 944 |
+
}
|
| 945 |
+
|
| 946 |
+
result = {}
|
| 947 |
+
warnings = []
|
| 948 |
+
|
| 949 |
+
try:
|
| 950 |
+
img = Image.open(image_path).convert('RGB')
|
| 951 |
+
except Exception as e:
|
| 952 |
+
return {
|
| 953 |
+
'ok': False,
|
| 954 |
+
'upside_down': False,
|
| 955 |
+
'skew_angle': 0.0,
|
| 956 |
+
'aspect_mismatch': 0.0,
|
| 957 |
+
'orb_fit': 0,
|
| 958 |
+
'orb_fit_normal': 0,
|
| 959 |
+
'orb_fit_180': 0,
|
| 960 |
+
'blur_score': 0.0,
|
| 961 |
+
'warnings': [f'Cannot open image: {e}'],
|
| 962 |
+
}
|
| 963 |
+
|
| 964 |
+
scan_rgb = np.array(img)
|
| 965 |
+
scan_gray = _cv2.cvtColor(scan_rgb, _cv2.COLOR_RGB2GRAY)
|
| 966 |
+
h, w = scan_gray.shape
|
| 967 |
+
|
| 968 |
+
blur_score = float(_cv2.Laplacian(scan_gray, _cv2.CV_64F).var())
|
| 969 |
+
result['blur_score'] = round(blur_score, 1)
|
| 970 |
+
if blur_score < 80:
|
| 971 |
+
warnings.append(
|
| 972 |
+
f'Image appears blurry (Laplacian variance={blur_score:.1f}; threshold 80).'
|
| 973 |
+
)
|
| 974 |
+
|
| 975 |
+
edges = _cv2.Canny(scan_gray, 50, 150, apertureSize=3)
|
| 976 |
+
lines = _cv2.HoughLinesP(
|
| 977 |
+
edges, 1, np.pi / 180, threshold=80,
|
| 978 |
+
minLineLength=60, maxLineGap=15,
|
| 979 |
+
)
|
| 980 |
+
skew_angle = 0.0
|
| 981 |
+
if lines is not None:
|
| 982 |
+
angles = [
|
| 983 |
+
np.degrees(np.arctan2(y2 - y1, x2 - x1))
|
| 984 |
+
for x1, y1, x2, y2 in lines[:, 0]
|
| 985 |
+
if abs(np.degrees(np.arctan2(y2 - y1, x2 - x1))) < 45
|
| 986 |
+
]
|
| 987 |
+
if angles:
|
| 988 |
+
skew_angle = float(np.median(angles))
|
| 989 |
+
|
| 990 |
+
result['skew_angle'] = round(skew_angle, 2)
|
| 991 |
+
if abs(skew_angle) > 3.0:
|
| 992 |
+
warnings.append(f'Page is significantly skewed ({skew_angle:.1f}°).')
|
| 993 |
+
|
| 994 |
+
upside_down = False
|
| 995 |
+
orb_fit = 0
|
| 996 |
+
inliers_normal = 0
|
| 997 |
+
inliers_180 = 0
|
| 998 |
+
|
| 999 |
+
ref_gray = _get_ref_gray(form_type)
|
| 1000 |
+
if ref_gray is not None:
|
| 1001 |
+
ref_h, ref_w = ref_gray.shape
|
| 1002 |
+
scan_rs = _cv2.resize(scan_gray, (ref_w, ref_h))
|
| 1003 |
+
scan_180 = _cv2.rotate(scan_rs, _cv2.ROTATE_180)
|
| 1004 |
+
|
| 1005 |
+
inliers_normal = _orb_inliers(scan_rs, ref_gray)
|
| 1006 |
+
inliers_180 = _orb_inliers(scan_180, ref_gray)
|
| 1007 |
+
orb_fit = inliers_normal
|
| 1008 |
+
|
| 1009 |
+
if inliers_180 > inliers_normal * 1.5 and inliers_180 > 10:
|
| 1010 |
+
upside_down = True
|
| 1011 |
+
orb_fit = inliers_180
|
| 1012 |
+
warnings.append(
|
| 1013 |
+
f'Image appears upside down (ORB normal={inliers_normal}, rotated_180={inliers_180}).'
|
| 1014 |
+
)
|
| 1015 |
+
|
| 1016 |
+
if orb_fit < 10:
|
| 1017 |
+
warnings.append(f'Poor alignment fit for form {form_type} (ORB inliers={orb_fit}).')
|
| 1018 |
+
elif orb_fit < 25:
|
| 1019 |
+
warnings.append(f'Weak alignment fit for form {form_type} (ORB inliers={orb_fit}).')
|
| 1020 |
+
|
| 1021 |
+
scan_aspect = w / max(h, 1)
|
| 1022 |
+
ref_aspect = ref_w / max(ref_h, 1)
|
| 1023 |
+
aspect_ratio = scan_aspect / max(ref_aspect, 1e-6)
|
| 1024 |
+
result['aspect_mismatch'] = round(aspect_ratio, 3)
|
| 1025 |
+
else:
|
| 1026 |
+
result['aspect_mismatch'] = 1.0
|
| 1027 |
+
|
| 1028 |
+
result['upside_down'] = upside_down
|
| 1029 |
+
result['orb_fit'] = orb_fit
|
| 1030 |
+
result['orb_fit_normal'] = inliers_normal
|
| 1031 |
+
result['orb_fit_180'] = inliers_180
|
| 1032 |
+
result['warnings'] = warnings
|
| 1033 |
+
result['ok'] = len(warnings) == 0
|
| 1034 |
+
return result
|
| 1035 |
+
|
| 1036 |
+
|
| 1037 |
+
def correct_image(img: Image.Image, quality: dict):
|
| 1038 |
+
applied = []
|
| 1039 |
+
|
| 1040 |
+
if not _CV2_OK:
|
| 1041 |
+
print('[correct_image] OpenCV not available; skipping corrections.')
|
| 1042 |
+
return img, applied
|
| 1043 |
+
|
| 1044 |
+
rgb = np.array(img.convert('RGB'))
|
| 1045 |
+
|
| 1046 |
+
if quality.get('upside_down'):
|
| 1047 |
+
rgb = _cv2.rotate(rgb, _cv2.ROTATE_180)
|
| 1048 |
+
applied.append('rotated 180° (upside-down correction)')
|
| 1049 |
+
print('[correct_image] Applied: 180° rotation')
|
| 1050 |
+
|
| 1051 |
+
skew_angle = quality.get('skew_angle', 0.0)
|
| 1052 |
+
if 1.0 < abs(skew_angle) < 15.0:
|
| 1053 |
+
correction_angle = -skew_angle
|
| 1054 |
+
h, w = rgb.shape[:2]
|
| 1055 |
+
center = (w / 2.0, h / 2.0)
|
| 1056 |
+
M = _cv2.getRotationMatrix2D(center, correction_angle, 1.0)
|
| 1057 |
+
|
| 1058 |
+
cos_a = abs(M[0, 0])
|
| 1059 |
+
sin_a = abs(M[0, 1])
|
| 1060 |
+
new_w = int(h * sin_a + w * cos_a)
|
| 1061 |
+
new_h = int(h * cos_a + w * sin_a)
|
| 1062 |
+
M[0, 2] += (new_w - w) / 2.0
|
| 1063 |
+
M[1, 2] += (new_h - h) / 2.0
|
| 1064 |
+
|
| 1065 |
+
rgb = _cv2.warpAffine(
|
| 1066 |
+
rgb, M, (new_w, new_h),
|
| 1067 |
+
flags=_cv2.INTER_CUBIC,
|
| 1068 |
+
borderMode=_cv2.BORDER_REPLICATE,
|
| 1069 |
+
)
|
| 1070 |
+
applied.append(f'de-skewed {correction_angle:+.2f}°')
|
| 1071 |
+
print(f'[correct_image] Applied: de-skew {correction_angle:+.2f}°')
|
| 1072 |
+
|
| 1073 |
+
result_img = Image.fromarray(rgb)
|
| 1074 |
+
if img.mode != 'RGB':
|
| 1075 |
+
result_img = result_img.convert(img.mode)
|
| 1076 |
+
return result_img, applied
|
| 1077 |
+
|
| 1078 |
+
|
| 1079 |
+
def align_to_reference(img: Image.Image, form_type: str):
|
| 1080 |
+
if not _CV2_OK:
|
| 1081 |
+
return img, 0
|
| 1082 |
+
|
| 1083 |
+
ref_gray = _get_ref_gray(form_type)
|
| 1084 |
+
if ref_gray is None:
|
| 1085 |
+
return img, 0
|
| 1086 |
+
|
| 1087 |
+
ref_h, ref_w = ref_gray.shape
|
| 1088 |
+
scan_rgb = np.array(img.convert('RGB'))
|
| 1089 |
+
|
| 1090 |
+
stage0 = _correct_perspective(scan_rgb, ref_w, ref_h)
|
| 1091 |
+
stage0_gray = _cv2.cvtColor(stage0, _cv2.COLOR_RGB2GRAY)
|
| 1092 |
+
|
| 1093 |
+
precheck = _orb_inliers(stage0_gray, ref_gray)
|
| 1094 |
+
print(f'[align] ORB pre-check: {precheck} inliers')
|
| 1095 |
+
|
| 1096 |
+
if precheck >= 40:
|
| 1097 |
+
orb_aligned, orb_inliers = _orb_align(stage0_gray, ref_gray, stage0)
|
| 1098 |
+
if orb_aligned is not None:
|
| 1099 |
+
return Image.fromarray(orb_aligned), orb_inliers
|
| 1100 |
+
|
| 1101 |
+
ecc_aligned = _ecc_align(stage0_gray, ref_gray, stage0)
|
| 1102 |
+
if ecc_aligned is not None:
|
| 1103 |
+
ecc_gray = _cv2.cvtColor(ecc_aligned, _cv2.COLOR_RGB2GRAY)
|
| 1104 |
+
orb_aligned, orb_inliers = _orb_align(ecc_gray, ref_gray, ecc_aligned)
|
| 1105 |
+
if orb_aligned is not None:
|
| 1106 |
+
return Image.fromarray(orb_aligned), orb_inliers
|
| 1107 |
+
return Image.fromarray(ecc_aligned), _orb_inliers(ecc_gray, ref_gray)
|
| 1108 |
+
|
| 1109 |
+
orb_aligned, orb_inliers = _orb_align(stage0_gray, ref_gray, stage0)
|
| 1110 |
+
if orb_aligned is not None:
|
| 1111 |
+
return Image.fromarray(orb_aligned), orb_inliers
|
| 1112 |
+
|
| 1113 |
+
resized = _cv2.resize(stage0, (ref_w, ref_h))
|
| 1114 |
+
return Image.fromarray(resized), precheck
|
| 1115 |
+
|
| 1116 |
+
|
| 1117 |
+
def _deskew(gray: np.ndarray) -> np.ndarray:
|
| 1118 |
+
if not _CV2_OK:
|
| 1119 |
+
return gray
|
| 1120 |
+
|
| 1121 |
+
edges = _cv2.Canny(gray, 50, 150, apertureSize=3)
|
| 1122 |
+
lines = _cv2.HoughLinesP(
|
| 1123 |
+
edges, 1, np.pi / 180, threshold=100,
|
| 1124 |
+
minLineLength=100, maxLineGap=10,
|
| 1125 |
+
)
|
| 1126 |
+
if lines is None:
|
| 1127 |
+
return gray
|
| 1128 |
+
|
| 1129 |
+
angles = [
|
| 1130 |
+
np.degrees(np.arctan2(y2 - y1, x2 - x1))
|
| 1131 |
+
for x1, y1, x2, y2 in lines[:, 0]
|
| 1132 |
+
if -3 < np.degrees(np.arctan2(y2 - y1, x2 - x1)) < 3
|
| 1133 |
+
]
|
| 1134 |
+
|
| 1135 |
+
if not angles:
|
| 1136 |
+
return gray
|
| 1137 |
+
|
| 1138 |
+
angle = float(np.median(angles))
|
| 1139 |
+
if abs(angle) < 0.5:
|
| 1140 |
+
return gray
|
| 1141 |
+
|
| 1142 |
+
h, w = gray.shape
|
| 1143 |
+
M = _cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0)
|
| 1144 |
+
return _cv2.warpAffine(
|
| 1145 |
+
gray, M, (w, h),
|
| 1146 |
+
flags=_cv2.INTER_CUBIC, borderMode=_cv2.BORDER_REPLICATE,
|
| 1147 |
+
)
|
| 1148 |
+
|
| 1149 |
+
|
| 1150 |
+
def _preprocess(img: Image.Image) -> Image.Image:
|
| 1151 |
+
if not _CV2_OK:
|
| 1152 |
+
return img.convert('L')
|
| 1153 |
+
gray = np.array(img.convert('L'))
|
| 1154 |
+
gray = _deskew(gray)
|
| 1155 |
+
return Image.fromarray(gray)
|
| 1156 |
+
|
| 1157 |
+
|
| 1158 |
+
def _crop_field(img: Image.Image, x1r, y1r, x2r, y2r) -> Image.Image:
|
| 1159 |
+
w, h = img.size
|
| 1160 |
+
pad = 4
|
| 1161 |
+
x1 = max(0, int(x1r * w) - pad)
|
| 1162 |
+
y1 = max(0, int(y1r * h) - pad)
|
| 1163 |
+
x2 = min(w, int(x2r * w) + pad)
|
| 1164 |
+
y2 = min(h, int(y2r * h) + pad)
|
| 1165 |
+
return img.crop((x1, y1, x2, y2))
|
| 1166 |
+
|
| 1167 |
+
|
| 1168 |
+
def _expand_box(box, img_w, img_h, pad_x=10, pad_y=8):
|
| 1169 |
+
x1, y1, x2, y2 = box
|
| 1170 |
+
return (
|
| 1171 |
+
max(0, x1 - pad_x),
|
| 1172 |
+
max(0, y1 - pad_y),
|
| 1173 |
+
min(img_w, x2 + pad_x),
|
| 1174 |
+
min(img_h, y2 + pad_y),
|
| 1175 |
+
)
|
| 1176 |
+
|
| 1177 |
+
|
| 1178 |
+
def _crop_from_box(img: Image.Image, box):
|
| 1179 |
+
return img.crop(box)
|
| 1180 |
+
|
| 1181 |
+
|
| 1182 |
+
def _norm_text(s: str) -> str:
|
| 1183 |
+
return _re.sub(r'[^a-z0-9]+', '', (s or '').lower())
|
| 1184 |
+
|
| 1185 |
+
|
| 1186 |
+
def _find_nearby_detection(field_rect, detections, expected_hint=None):
|
| 1187 |
+
fx1, fy1, fx2, fy2 = field_rect
|
| 1188 |
+
fcx = (fx1 + fx2) / 2
|
| 1189 |
+
fcy = (fy1 + fy2) / 2
|
| 1190 |
+
fw = max(1, fx2 - fx1)
|
| 1191 |
+
fh = max(1, fy2 - fy1)
|
| 1192 |
+
|
| 1193 |
+
best = None
|
| 1194 |
+
best_score = -1e9
|
| 1195 |
+
|
| 1196 |
+
for det in detections:
|
| 1197 |
+
x1, y1, x2, y2 = det['box']
|
| 1198 |
+
dcx = det['cx']
|
| 1199 |
+
dcy = det['cy']
|
| 1200 |
+
dw = max(1, x2 - x1)
|
| 1201 |
+
dh = max(1, y2 - y1)
|
| 1202 |
+
|
| 1203 |
+
dist = ((dcx - fcx) ** 2 + (dcy - fcy) ** 2) ** 0.5
|
| 1204 |
+
overlap_x = max(0, min(fx2, x2) - max(fx1, x1))
|
| 1205 |
+
overlap_y = max(0, min(fy2, y2) - max(fy1, y1))
|
| 1206 |
+
overlap = overlap_x * overlap_y
|
| 1207 |
+
|
| 1208 |
+
size_penalty = abs(dw - fw) * 0.2 + abs(dh - fh) * 0.2
|
| 1209 |
+
score = overlap * 0.02 - dist - size_penalty + det.get('conf', 0.0) * 40.0
|
| 1210 |
+
|
| 1211 |
+
text = (det.get('text') or '').strip()
|
| 1212 |
+
if expected_hint == _WORD and len(text.split()) <= 3:
|
| 1213 |
+
score += 10
|
| 1214 |
+
elif expected_hint == _LINE and 1 <= len(text.split()) <= 12:
|
| 1215 |
+
score += 8
|
| 1216 |
+
elif expected_hint == _BLOCK and len(text.split()) >= 2:
|
| 1217 |
+
score += 6
|
| 1218 |
+
|
| 1219 |
+
if score > best_score:
|
| 1220 |
+
best_score = score
|
| 1221 |
+
best = det
|
| 1222 |
+
|
| 1223 |
+
return best if best_score > -150 else None
|
| 1224 |
+
|
| 1225 |
+
|
| 1226 |
+
def _get_field_crop_with_paddle(processed_img: Image.Image, field_coords, detections):
|
| 1227 |
+
w, h = processed_img.size
|
| 1228 |
+
x1r, y1r, x2r, y2r, hint = field_coords
|
| 1229 |
+
|
| 1230 |
+
fx1 = int(x1r * w)
|
| 1231 |
+
fy1 = int(y1r * h)
|
| 1232 |
+
fx2 = int(x2r * w)
|
| 1233 |
+
fy2 = int(y2r * h)
|
| 1234 |
+
field_rect = (fx1, fy1, fx2, fy2)
|
| 1235 |
+
|
| 1236 |
+
det = _find_nearby_detection(field_rect, detections, expected_hint=hint)
|
| 1237 |
+
if det is not None:
|
| 1238 |
+
box = _expand_box(det['box'], w, h, pad_x=10, pad_y=8)
|
| 1239 |
+
return _crop_from_box(processed_img, box), 'paddle-detect', det
|
| 1240 |
+
|
| 1241 |
+
return _crop_field(processed_img, x1r, y1r, x2r, y2r), 'absolute', None
|
| 1242 |
+
|
| 1243 |
+
|
| 1244 |
+
def _get_field_crop_with_easyocr(processed_img: Image.Image, field_coords, detections):
|
| 1245 |
+
return _get_field_crop_with_paddle(processed_img, field_coords, detections)
|
| 1246 |
+
|
| 1247 |
+
|
| 1248 |
+
def detect_form_type(image_path: str) -> str:
|
| 1249 |
+
if _CV2_OK:
|
| 1250 |
+
try:
|
| 1251 |
+
img = Image.open(image_path).convert('RGB')
|
| 1252 |
+
scan_rgb = np.array(img)
|
| 1253 |
+
scan_gray = _cv2.cvtColor(scan_rgb, _cv2.COLOR_RGB2GRAY)
|
| 1254 |
+
|
| 1255 |
+
best_type, best_inliers = None, 0
|
| 1256 |
+
det_w = 800
|
| 1257 |
+
|
| 1258 |
+
for ft in REFERENCE_IMAGES:
|
| 1259 |
+
ref_gray = _get_ref_gray(ft)
|
| 1260 |
+
if ref_gray is None:
|
| 1261 |
+
continue
|
| 1262 |
+
|
| 1263 |
+
ref_h, ref_w = ref_gray.shape
|
| 1264 |
+
sc = min(1.0, det_w / ref_w)
|
| 1265 |
+
dw = max(1, int(ref_w * sc))
|
| 1266 |
+
dh = max(1, int(ref_h * sc))
|
| 1267 |
+
ref_ds = _cv2.resize(ref_gray, (dw, dh))
|
| 1268 |
+
scan_ds = _cv2.resize(_cv2.resize(scan_gray, (ref_w, ref_h)), (dw, dh))
|
| 1269 |
+
|
| 1270 |
+
count = _orb_inliers(scan_ds, ref_ds)
|
| 1271 |
+
print(f'[detect] Form {ft}: {count} ORB inliers')
|
| 1272 |
+
|
| 1273 |
+
if count > best_inliers:
|
| 1274 |
+
best_inliers, best_type = count, ft
|
| 1275 |
+
|
| 1276 |
+
if best_type and best_inliers >= 15:
|
| 1277 |
+
print(f'[detect] Best: Form {best_type} ({best_inliers} inliers)')
|
| 1278 |
+
return best_type
|
| 1279 |
+
|
| 1280 |
+
print(f'[detect] ORB inconclusive ({best_inliers}), trying OCR title')
|
| 1281 |
+
except Exception as e:
|
| 1282 |
+
print(f'[template_matcher] detect_form_type ORB error: {e}')
|
| 1283 |
+
|
| 1284 |
+
try:
|
| 1285 |
+
img_l = Image.open(image_path).convert('L')
|
| 1286 |
+
w, h = img_l.size
|
| 1287 |
+
title_crop = img_l.crop((0, int(h * 0.04), w, int(h * 0.15)))
|
| 1288 |
+
title = _crnn_read(title_crop).upper()
|
| 1289 |
+
|
| 1290 |
+
if title:
|
| 1291 |
+
if 'LIVE BIRTH' in title or ('BIRTH' in title and 'DEATH' not in title and 'MARRIAGE' not in title):
|
| 1292 |
+
return '102'
|
| 1293 |
+
if 'DEATH' in title:
|
| 1294 |
+
return '103'
|
| 1295 |
+
if 'MARRIAGE' in title and 'LICENSE' in title:
|
| 1296 |
+
return '90'
|
| 1297 |
+
if 'MARRIAGE' in title:
|
| 1298 |
+
return '97'
|
| 1299 |
+
except Exception as e:
|
| 1300 |
+
print(f'[template_matcher] detect_form_type OCR error: {e}')
|
| 1301 |
+
|
| 1302 |
+
print('[detect] Could not detect form type; defaulting to 102.')
|
| 1303 |
+
return '102'
|
| 1304 |
+
|
| 1305 |
+
|
| 1306 |
+
def is_blank_image(img: Image.Image, threshold: float = 0.995) -> bool:
|
| 1307 |
+
if not _CV2_OK:
|
| 1308 |
+
return False
|
| 1309 |
+
|
| 1310 |
+
gray = np.array(img.convert('L'))
|
| 1311 |
+
h, w = gray.shape
|
| 1312 |
+
|
| 1313 |
+
y1 = int(h * 0.20)
|
| 1314 |
+
y2 = int(h * 0.80)
|
| 1315 |
+
x1 = int(w * 0.20)
|
| 1316 |
+
x2 = int(w * 0.80)
|
| 1317 |
+
center = gray[y1:y2, x1:x2]
|
| 1318 |
+
|
| 1319 |
+
light_pixels = np.sum(center > 240)
|
| 1320 |
+
total_pixels = center.size
|
| 1321 |
+
ratio = light_pixels / max(total_pixels, 1)
|
| 1322 |
+
variance = float(np.var(center))
|
| 1323 |
+
|
| 1324 |
+
print(f'[template_matcher] Blank check: {ratio:.2%} light pixels, variance={variance:.1f}')
|
| 1325 |
+
return ratio >= threshold and variance < 50.0
|
| 1326 |
+
|
| 1327 |
+
|
| 1328 |
+
def extract_fields(image_path: str, form_type: str = None):
|
| 1329 |
+
try:
|
| 1330 |
+
if not form_type:
|
| 1331 |
+
form_type = detect_form_type(image_path)
|
| 1332 |
+
|
| 1333 |
+
template = TEMPLATES.get(form_type)
|
| 1334 |
+
if not template:
|
| 1335 |
+
return {'status': 'error', 'message': f'No template for form {form_type}.'}
|
| 1336 |
+
|
| 1337 |
+
quality = check_image_quality(image_path, form_type)
|
| 1338 |
+
img = Image.open(image_path).convert('RGB')
|
| 1339 |
+
|
| 1340 |
+
if is_blank_image(img):
|
| 1341 |
+
return {'status': 'error', 'message': 'Blank or near-blank image detected.'}
|
| 1342 |
+
|
| 1343 |
+
img, corrections = correct_image(img, quality)
|
| 1344 |
+
img, orb_fit = align_to_reference(img, form_type)
|
| 1345 |
+
processed = _preprocess(img)
|
| 1346 |
+
detections = _paddle_detect(processed)
|
| 1347 |
+
|
| 1348 |
+
fields = {}
|
| 1349 |
+
debug_methods = {}
|
| 1350 |
+
field_names = []
|
| 1351 |
+
crops = []
|
| 1352 |
+
assist_texts = []
|
| 1353 |
+
|
| 1354 |
+
for field_name, coords in template.items():
|
| 1355 |
+
crop, method, det = _get_field_crop_with_paddle(processed, coords, detections)
|
| 1356 |
+
field_names.append(field_name)
|
| 1357 |
+
crops.append(crop)
|
| 1358 |
+
debug_methods[field_name] = method
|
| 1359 |
+
|
| 1360 |
+
assist_text = ''
|
| 1361 |
+
if USE_SELECTIVE_PADDLE_ASSIST and field_name in PADDLE_ASSIST_FIELDS:
|
| 1362 |
+
if det is not None:
|
| 1363 |
+
assist_text = (det.get('text') or '').strip()
|
| 1364 |
+
if not assist_text:
|
| 1365 |
+
assist_text = _paddle_read(crop)
|
| 1366 |
+
assist_texts.append(assist_text)
|
| 1367 |
+
|
| 1368 |
+
crnn_texts = _crnn_read_batch(crops)
|
| 1369 |
+
|
| 1370 |
+
for field_name, crnn_text, assist_text in zip(field_names, crnn_texts, assist_texts):
|
| 1371 |
+
final_text = _smart_merge(field_name, crnn_text, assist_text)
|
| 1372 |
+
if final_text:
|
| 1373 |
+
fields[field_name] = final_text
|
| 1374 |
+
|
| 1375 |
+
print(f'[template_matcher] Extracted: {len(fields)}/{len(template)} fields')
|
| 1376 |
+
paddle_count = sum(1 for m in debug_methods.values() if m == 'paddle-detect')
|
| 1377 |
+
abs_count = sum(1 for m in debug_methods.values() if m == 'absolute')
|
| 1378 |
+
print(f'[template_matcher] Crop source: paddle={paddle_count}, absolute={abs_count}, orb_fit={orb_fit}')
|
| 1379 |
+
|
| 1380 |
+
if len(fields) == 0:
|
| 1381 |
+
return {'status': 'error', 'message': 'No readable text found.'}
|
| 1382 |
+
|
| 1383 |
+
fields['_quality'] = quality
|
| 1384 |
+
fields['_corrections'] = corrections
|
| 1385 |
+
return fields
|
| 1386 |
+
except Exception as e:
|
| 1387 |
+
print(f'[template_matcher] extract_fields error: {e}')
|
| 1388 |
+
return {'status': 'error', 'message': str(e)}
|
| 1389 |
+
|
| 1390 |
+
|
| 1391 |
+
def debug_draw_boxes(image_path: str, form_type: str, out_path: str = None) -> str:
|
| 1392 |
+
from PIL import ImageDraw, ImageFont
|
| 1393 |
+
|
| 1394 |
+
template = TEMPLATES.get(form_type)
|
| 1395 |
+
if not template:
|
| 1396 |
+
print(f'No template for {form_type}')
|
| 1397 |
+
return None
|
| 1398 |
+
|
| 1399 |
+
quality = check_image_quality(image_path, form_type)
|
| 1400 |
+
img = Image.open(image_path).convert('RGB')
|
| 1401 |
+
img, _ = correct_image(img, quality)
|
| 1402 |
+
img, _ = align_to_reference(img, form_type)
|
| 1403 |
+
|
| 1404 |
+
draw = ImageDraw.Draw(img)
|
| 1405 |
+
w, h = img.size
|
| 1406 |
+
|
| 1407 |
+
try:
|
| 1408 |
+
font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 11)
|
| 1409 |
+
except Exception:
|
| 1410 |
+
try:
|
| 1411 |
+
font = ImageFont.truetype('C:/Windows/Fonts/arial.ttf', 11)
|
| 1412 |
+
except Exception:
|
| 1413 |
+
font = ImageFont.load_default()
|
| 1414 |
+
|
| 1415 |
+
for field_name, coords in template.items():
|
| 1416 |
+
x1r, y1r, x2r, y2r, _ = coords
|
| 1417 |
+
bx1, by1 = int(x1r * w), int(y1r * h)
|
| 1418 |
+
bx2, by2 = int(x2r * w), int(y2r * h)
|
| 1419 |
+
draw.rectangle([bx1, by1, bx2, by2], outline='#1a6fd4', width=1)
|
| 1420 |
+
draw.text((bx1 + 2, by1 + 2), field_name, fill='#1a6fd4', font=font)
|
| 1421 |
+
|
| 1422 |
+
base, ext = os.path.splitext(image_path)
|
| 1423 |
+
out = out_path or f'{base}_debug_{form_type}{ext}'
|
| 1424 |
+
img.save(out)
|
| 1425 |
+
print(f'[template_matcher] Debug image saved: {out}')
|
| 1426 |
+
return out
|
| 1427 |
+
|
| 1428 |
+
|
| 1429 |
+
def debug_draw_paddle_matches(image_path: str, form_type: str, out_path: str = None) -> str:
|
| 1430 |
+
from PIL import ImageDraw, ImageFont
|
| 1431 |
+
|
| 1432 |
+
template = TEMPLATES.get(form_type)
|
| 1433 |
+
if not template:
|
| 1434 |
+
print(f'No template for {form_type}')
|
| 1435 |
+
return None
|
| 1436 |
+
|
| 1437 |
+
quality = check_image_quality(image_path, form_type)
|
| 1438 |
+
img = Image.open(image_path).convert('RGB')
|
| 1439 |
+
img, _ = correct_image(img, quality)
|
| 1440 |
+
img, _ = align_to_reference(img, form_type)
|
| 1441 |
+
processed = _preprocess(img)
|
| 1442 |
+
detections = _paddle_detect(processed)
|
| 1443 |
+
|
| 1444 |
+
canvas = img.copy()
|
| 1445 |
+
draw = ImageDraw.Draw(canvas)
|
| 1446 |
+
w, h = canvas.size
|
| 1447 |
+
|
| 1448 |
+
try:
|
| 1449 |
+
font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 11)
|
| 1450 |
+
except Exception:
|
| 1451 |
+
try:
|
| 1452 |
+
font = ImageFont.truetype('C:/Windows/Fonts/arial.ttf', 11)
|
| 1453 |
+
except Exception:
|
| 1454 |
+
font = ImageFont.load_default()
|
| 1455 |
+
|
| 1456 |
+
for det in detections:
|
| 1457 |
+
x1, y1, x2, y2 = det['box']
|
| 1458 |
+
draw.rectangle([x1, y1, x2, y2], outline='red', width=1)
|
| 1459 |
+
|
| 1460 |
+
for field_name, coords in template.items():
|
| 1461 |
+
x1r, y1r, x2r, y2r, hint = coords
|
| 1462 |
+
fx1 = int(x1r * w)
|
| 1463 |
+
fy1 = int(y1r * h)
|
| 1464 |
+
fx2 = int(x2r * w)
|
| 1465 |
+
fy2 = int(y2r * h)
|
| 1466 |
+
draw.rectangle([fx1, fy1, fx2, fy2], outline='blue', width=2)
|
| 1467 |
+
draw.text((fx1 + 2, fy1 + 2), field_name, fill='blue', font=font)
|
| 1468 |
+
|
| 1469 |
+
det = _find_nearby_detection((fx1, fy1, fx2, fy2), detections, expected_hint=hint)
|
| 1470 |
+
if det is not None:
|
| 1471 |
+
dx1, dy1, dx2, dy2 = det['box']
|
| 1472 |
+
draw.rectangle([dx1, dy1, dx2, dy2], outline='green', width=2)
|
| 1473 |
+
|
| 1474 |
+
base, ext = os.path.splitext(image_path)
|
| 1475 |
+
out = out_path or f'{base}_paddle_debug_{form_type}{ext}'
|
| 1476 |
+
canvas.save(out)
|
| 1477 |
+
print(f'[template_matcher] Paddle debug image saved: {out}')
|
| 1478 |
+
return out
|
| 1479 |
+
|
| 1480 |
+
|
| 1481 |
+
def debug_draw_easyocr_matches(image_path: str, form_type: str, out_path: str = None) -> str:
|
| 1482 |
+
# Backward-compatible function name.
|
| 1483 |
+
return debug_draw_paddle_matches(image_path, form_type, out_path)
|
| 1484 |
+
|
| 1485 |
+
|
| 1486 |
+
def pdf_to_image(pdf_path: str, page: int = 0) -> str:
|
| 1487 |
+
try:
|
| 1488 |
+
from pdf2image import convert_from_path
|
| 1489 |
+
pages = convert_from_path(pdf_path, dpi=150)
|
| 1490 |
+
out_path = pdf_path.replace('.pdf', f'_page{page}.png')
|
| 1491 |
+
pages[page].save(out_path, 'PNG')
|
| 1492 |
+
return out_path
|
| 1493 |
+
except ImportError:
|
| 1494 |
+
print('[template_matcher] pdf2image not installed.')
|
| 1495 |
+
return None
|
| 1496 |
+
except Exception as e:
|
| 1497 |
+
print(f'[template_matcher] PDF conversion failed: {e}')
|
| 1498 |
+
return None
|
| 1499 |
+
|
| 1500 |
+
|
| 1501 |
+
if __name__ == '__main__':
|
| 1502 |
+
warmup()
|
| 1503 |
+
|
| 1504 |
+
if len(sys.argv) < 2:
|
| 1505 |
+
print('Usage:')
|
| 1506 |
+
print(' python template_matcher.py <image_path> <form_type> [out_path]')
|
| 1507 |
+
print(' python template_matcher.py <image_path> check [form_type]')
|
| 1508 |
+
print(' form_type: 102 | 103 | 90 | 97')
|
| 1509 |
+
sys.exit(1)
|
| 1510 |
+
|
| 1511 |
+
img_path = sys.argv[1]
|
| 1512 |
+
|
| 1513 |
+
if len(sys.argv) >= 3 and sys.argv[2] == 'check':
|
| 1514 |
+
ft = sys.argv[3] if len(sys.argv) > 3 else detect_form_type(img_path)
|
| 1515 |
+
q = check_image_quality(img_path, ft)
|
| 1516 |
+
|
| 1517 |
+
print(f'\nQuality report for form {ft}:')
|
| 1518 |
+
for k, v in q.items():
|
| 1519 |
+
if k != 'warnings':
|
| 1520 |
+
print(f' {k:<22} = {v}')
|
| 1521 |
+
|
| 1522 |
+
if q['warnings']:
|
| 1523 |
+
print('\nWarnings:')
|
| 1524 |
+
for msg in q['warnings']:
|
| 1525 |
+
print(f' • {msg}')
|
| 1526 |
+
|
| 1527 |
+
img_pil = Image.open(img_path).convert('RGB')
|
| 1528 |
+
_, corrections = correct_image(img_pil, q)
|
| 1529 |
+
print('\nCorrections that would be applied:')
|
| 1530 |
+
if corrections:
|
| 1531 |
+
for c in corrections:
|
| 1532 |
+
print(f' ✓ {c}')
|
| 1533 |
+
else:
|
| 1534 |
+
print(' (none needed)')
|
| 1535 |
+
|
| 1536 |
+
sys.exit(0 if q['ok'] else 1)
|
| 1537 |
+
|
| 1538 |
+
form_type = sys.argv[2]
|
| 1539 |
+
out_path = sys.argv[3] if len(sys.argv) > 3 else None
|
| 1540 |
+
|
| 1541 |
+
debug_draw_boxes(img_path, form_type, out_path)
|
| 1542 |
+
debug_draw_paddle_matches(img_path, form_type)
|
| 1543 |
+
|
| 1544 |
+
result = extract_fields(img_path, form_type)
|
| 1545 |
+
meta_keys = {'_quality', '_corrections'}
|
| 1546 |
+
|
| 1547 |
+
data_fields = {k: v for k, v in result.items() if k not in meta_keys}
|
| 1548 |
+
print(f'\nExtracted fields ({len(data_fields)}):')
|
| 1549 |
+
for k, v in data_fields.items():
|
| 1550 |
+
print(f' {k:<40} = {v}')
|
| 1551 |
+
|
| 1552 |
+
template = TEMPLATES.get(form_type, {})
|
| 1553 |
+
missing = [k for k in template if k not in data_fields]
|
| 1554 |
+
if missing:
|
| 1555 |
+
print(f'\nEmpty fields ({len(missing)}):')
|
| 1556 |
+
for k in missing:
|
| 1557 |
+
print(f' {k}')
|
| 1558 |
+
|
| 1559 |
+
corrections = result.get('_corrections', [])
|
| 1560 |
+
if corrections:
|
| 1561 |
+
print('\nAuto-corrections applied:')
|
| 1562 |
+
for c in corrections:
|
| 1563 |
+
print(f' ✓ {c}')
|
| 1564 |
+
|
| 1565 |
+
quality = result.get('_quality', {})
|
| 1566 |
+
if quality.get('warnings'):
|
| 1567 |
+
print('\nQuality warnings:')
|
| 1568 |
+
for w_msg in quality['warnings']:
|
| 1569 |
+
print(f' • {w_msg}')
|
test_flask.html
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<title>Flask Pipeline Test</title>
|
| 5 |
+
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600&display=swap" rel="stylesheet">
|
| 6 |
+
<style>
|
| 7 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 8 |
+
body { font-family: 'Poppins', sans-serif; background: #f0fdf7; padding: 32px; color: #1a1a1a; }
|
| 9 |
+
h2 { color: #1a7a4a; margin-bottom: 6px; }
|
| 10 |
+
p { color: #555; font-size: 14px; margin-bottom: 20px; }
|
| 11 |
+
|
| 12 |
+
.controls { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 20px; }
|
| 13 |
+
button {
|
| 14 |
+
padding: 10px 22px; background: #1ec77c; color: white;
|
| 15 |
+
border: none; border-radius: 8px; font-size: 14px;
|
| 16 |
+
font-family: 'Poppins', sans-serif; font-weight: 600; cursor: pointer;
|
| 17 |
+
}
|
| 18 |
+
button:hover { background: #18a868; }
|
| 19 |
+
.btn-open { background: #3498db; }
|
| 20 |
+
.btn-open:hover { background: #2176ae; }
|
| 21 |
+
|
| 22 |
+
.status { font-weight: 600; font-size: 14px; }
|
| 23 |
+
.success { color: #1a7a4a; }
|
| 24 |
+
.error { color: #e74c3c; }
|
| 25 |
+
|
| 26 |
+
/* ── Pipeline diagram ── */
|
| 27 |
+
.pipeline {
|
| 28 |
+
display: flex; align-items: center; gap: 0;
|
| 29 |
+
background: white; border-radius: 12px; padding: 20px 24px;
|
| 30 |
+
box-shadow: 0 2px 12px rgba(0,0,0,0.07); margin-bottom: 24px;
|
| 31 |
+
flex-wrap: wrap; gap: 8px;
|
| 32 |
+
}
|
| 33 |
+
.pipe-step {
|
| 34 |
+
background: #f0fdf7; border: 2px solid #1ec77c; border-radius: 8px;
|
| 35 |
+
padding: 10px 16px; text-align: center; font-size: 12px; min-width: 110px;
|
| 36 |
+
}
|
| 37 |
+
.pipe-step .title { font-weight: 700; color: #1a7a4a; font-size: 13px; }
|
| 38 |
+
.pipe-step .sub { color: #888; font-size: 11px; margin-top: 2px; }
|
| 39 |
+
.pipe-step.active { background: #1ec77c; border-color: #1a7a4a; }
|
| 40 |
+
.pipe-step.active .title { color: white; }
|
| 41 |
+
.pipe-step.active .sub { color: #d0f5e8; }
|
| 42 |
+
.arrow { font-size: 20px; color: #1ec77c; font-weight: bold; padding: 0 4px; }
|
| 43 |
+
|
| 44 |
+
/* ── Results panel ── */
|
| 45 |
+
.results { display: none; }
|
| 46 |
+
.card {
|
| 47 |
+
background: white; border-radius: 12px; padding: 24px;
|
| 48 |
+
box-shadow: 0 2px 12px rgba(0,0,0,0.07); margin-bottom: 20px;
|
| 49 |
+
}
|
| 50 |
+
.card h3 { color: #1a7a4a; margin-bottom: 14px; font-size: 15px; display: flex; align-items: center; gap: 8px; }
|
| 51 |
+
|
| 52 |
+
/* ── Form 102 raw fields ── */
|
| 53 |
+
.raw-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
| 54 |
+
.raw-table th { background: #f5f5f5; padding: 7px 12px; text-align: left; color: #555; font-weight: 600; border-bottom: 2px solid #eee; }
|
| 55 |
+
.raw-table td { padding: 6px 12px; border-bottom: 1px solid #f0f0f0; }
|
| 56 |
+
.raw-table td:first-child { color: #888; font-size: 12px; }
|
| 57 |
+
.raw-table td:last-child { font-weight: 600; }
|
| 58 |
+
|
| 59 |
+
/* ── Mapping arrows ── */
|
| 60 |
+
.mapping { display: flex; flex-direction: column; gap: 6px; font-size: 13px; }
|
| 61 |
+
.map-row { display: flex; align-items: center; gap: 10px; padding: 5px 8px; border-radius: 6px; background: #f9f9f9; }
|
| 62 |
+
.map-from { color: #888; min-width: 200px; font-size: 12px; }
|
| 63 |
+
.map-arr { color: #1ec77c; font-weight: bold; font-size: 16px; }
|
| 64 |
+
.map-to { color: #1a7a4a; font-weight: 600; min-width: 200px; }
|
| 65 |
+
.map-val { color: #111; font-size: 12px; background: #fffde7; padding: 2px 8px; border-radius: 4px; border: 1px solid #f0d000; }
|
| 66 |
+
|
| 67 |
+
/* ── Confidence bars ── */
|
| 68 |
+
.conf-row { display: flex; align-items: center; gap: 10px; margin-bottom: 7px; font-size: 12px; }
|
| 69 |
+
.conf-lbl { min-width: 210px; color: #555; }
|
| 70 |
+
.conf-wrap { flex: 1; background: #eee; border-radius: 99px; height: 10px; overflow: hidden; }
|
| 71 |
+
.conf-bar { height: 100%; border-radius: 99px; transition: width 0.5s; }
|
| 72 |
+
.conf-pct { min-width: 40px; text-align: right; font-weight: 700; }
|
| 73 |
+
|
| 74 |
+
/* ── Saved file ── */
|
| 75 |
+
.file-box { display: flex; align-items: center; gap: 14px; padding: 14px 18px; background: #f0fdf7; border: 2px solid #1ec77c; border-radius: 10px; }
|
| 76 |
+
.file-box .icon { font-size: 28px; }
|
| 77 |
+
.file-box .name { font-weight: 700; color: #1a7a4a; font-size: 14px; }
|
| 78 |
+
.file-box .path { font-size: 12px; color: #888; margin-top: 2px; }
|
| 79 |
+
</style>
|
| 80 |
+
</head>
|
| 81 |
+
<body>
|
| 82 |
+
|
| 83 |
+
<h2>🧪 Flask Pipeline Test</h2>
|
| 84 |
+
<p>Simulates scanning <strong>Form 102</strong> → CRNN+CTC extracts fields → MNB classifies → NER maps to <strong>Form 1A</strong> → saved to <code>uploads/temp/</code></p>
|
| 85 |
+
|
| 86 |
+
<!-- Pipeline diagram -->
|
| 87 |
+
<div class="pipeline">
|
| 88 |
+
<div class="pipe-step" id="step1"><div class="title">📄 Form 102</div><div class="sub">Scanned input</div></div>
|
| 89 |
+
<div class="arrow">→</div>
|
| 90 |
+
<div class="pipe-step" id="step2"><div class="title">🔍 CRNN+CTC</div><div class="sub">OCR extraction</div></div>
|
| 91 |
+
<div class="arrow">→</div>
|
| 92 |
+
<div class="pipe-step" id="step3"><div class="title">📊 MNB</div><div class="sub">Classification</div></div>
|
| 93 |
+
<div class="arrow">→</div>
|
| 94 |
+
<div class="pipe-step" id="step4"><div class="title">🏷️ NER</div><div class="sub">Field mapping</div></div>
|
| 95 |
+
<div class="arrow">→</div>
|
| 96 |
+
<div class="pipe-step" id="step5"><div class="title">📋 Form 1A</div><div class="sub">Auto-filled</div></div>
|
| 97 |
+
<div class="arrow">→</div>
|
| 98 |
+
<div class="pipe-step" id="step6"><div class="title">💾 Saved</div><div class="sub">uploads/temp/</div></div>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<div class="controls">
|
| 102 |
+
<button onclick="testFlask()">▶ Run Pipeline Test</button>
|
| 103 |
+
<span id="status" class="status"></span>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<div class="results" id="results">
|
| 107 |
+
|
| 108 |
+
<!-- Step 1+2: Form 102 raw fields -->
|
| 109 |
+
<div class="card">
|
| 110 |
+
<h3>📄 Step 1+2 — Form 102 raw fields extracted by CRNN+CTC</h3>
|
| 111 |
+
<table class="raw-table">
|
| 112 |
+
<thead><tr><th>Form 102 Field Name</th><th>Extracted Value</th></tr></thead>
|
| 113 |
+
<tbody id="rawFieldsTable"></tbody>
|
| 114 |
+
</table>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
<!-- Step 3+4: NER mapping -->
|
| 118 |
+
<div class="card">
|
| 119 |
+
<h3>🏷️ Step 3+4 — MNB classified as <span id="formClassBadge" style="background:#1ec77c;color:white;padding:2px 10px;border-radius:99px;font-size:13px"></span> → NER mapped fields</h3>
|
| 120 |
+
<div class="mapping" id="mappingRows"></div>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
<!-- Confidence scores -->
|
| 124 |
+
<div class="card">
|
| 125 |
+
<h3>📊 NER Confidence Scores</h3>
|
| 126 |
+
<div id="confidenceBars"></div>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
<!-- Saved file -->
|
| 130 |
+
<div class="card">
|
| 131 |
+
<h3>💾 Saved to uploads/temp/</h3>
|
| 132 |
+
<div class="file-box">
|
| 133 |
+
<div class="icon">📄</div>
|
| 134 |
+
<div>
|
| 135 |
+
<div class="name" id="savedFileName"></div>
|
| 136 |
+
<div class="path" id="savedFilePath"></div>
|
| 137 |
+
</div>
|
| 138 |
+
<button class="btn-open" onclick="openPreview()" style="margin-left:auto">👁 Open Form 1A Preview</button>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
</div>
|
| 143 |
+
|
| 144 |
+
<script>
|
| 145 |
+
// Form 102 field → Form 1A field name mapping (for display)
|
| 146 |
+
const mappingDef = {
|
| 147 |
+
'child_first_name + child_middle_name + child_last_name': 'child_name',
|
| 148 |
+
'sex': 'sex',
|
| 149 |
+
'date_of_birth': 'date_of_birth',
|
| 150 |
+
'place_of_birth': 'place_of_birth',
|
| 151 |
+
'mother_first_name + mother_middle_name + mother_last_name': 'mother_name',
|
| 152 |
+
'mother_citizenship':'mother_nationality',
|
| 153 |
+
'father_first_name + father_middle_name + father_last_name': 'father_name',
|
| 154 |
+
'father_citizenship':'father_nationality',
|
| 155 |
+
'date_of_marriage': 'parents_marriage_date',
|
| 156 |
+
'place_of_marriage': 'parents_marriage_place',
|
| 157 |
+
'registry_number': 'registry_number',
|
| 158 |
+
'date_of_registration': 'date_of_registration',
|
| 159 |
+
'civil_registrar': 'verified_by',
|
| 160 |
+
'civil_registrar_position': 'verified_position',
|
| 161 |
+
};
|
| 162 |
+
|
| 163 |
+
const fieldLabels = {
|
| 164 |
+
child_name:'child_name → Name of Child',
|
| 165 |
+
sex:'sex → Sex',
|
| 166 |
+
date_of_birth:'date_of_birth → Date of Birth',
|
| 167 |
+
place_of_birth:'place_of_birth → Place of Birth',
|
| 168 |
+
mother_name:'mother_name → Name of Mother',
|
| 169 |
+
mother_nationality:'mother_nationality → Nationality of Mother',
|
| 170 |
+
father_name:'father_name → Name of Father',
|
| 171 |
+
father_nationality:'father_nationality → Nationality of Father',
|
| 172 |
+
parents_marriage_date:'parents_marriage_date → Date of Marriage of Parents',
|
| 173 |
+
parents_marriage_place:'parents_marriage_place → Place of Marriage of Parents',
|
| 174 |
+
registry_number:'registry_number → Registry Number',
|
| 175 |
+
date_of_registration:'date_of_registration → Date of Registration',
|
| 176 |
+
verified_by:'verified_by → Verified By',
|
| 177 |
+
verified_position:'verified_position → Position',
|
| 178 |
+
};
|
| 179 |
+
|
| 180 |
+
let _previewUrl = '';
|
| 181 |
+
|
| 182 |
+
async function testFlask() {
|
| 183 |
+
const status = document.getElementById('status');
|
| 184 |
+
const results = document.getElementById('results');
|
| 185 |
+
status.textContent = '⏳ Processing...';
|
| 186 |
+
status.className = 'status';
|
| 187 |
+
results.style.display = 'none';
|
| 188 |
+
|
| 189 |
+
// Animate pipeline steps
|
| 190 |
+
const steps = ['step1','step2','step3','step4','step5','step6'];
|
| 191 |
+
steps.forEach(s => document.getElementById(s).classList.remove('active'));
|
| 192 |
+
let i = 0;
|
| 193 |
+
const anim = setInterval(() => {
|
| 194 |
+
if (i < steps.length) document.getElementById(steps[i++]).classList.add('active');
|
| 195 |
+
else clearInterval(anim);
|
| 196 |
+
}, 300);
|
| 197 |
+
|
| 198 |
+
const fakeFile = new File(['fake'], 'form102_scan.jpg', { type: 'image/jpeg' });
|
| 199 |
+
const formData = new FormData();
|
| 200 |
+
formData.append('file', fakeFile);
|
| 201 |
+
formData.append('type', 'cert');
|
| 202 |
+
formData.append('form_hint', '1A');
|
| 203 |
+
|
| 204 |
+
try {
|
| 205 |
+
const res = await fetch('http://127.0.0.1:5000/process', { method: 'POST', body: formData });
|
| 206 |
+
const data = await res.json();
|
| 207 |
+
|
| 208 |
+
if (data.status !== 'success') {
|
| 209 |
+
status.innerHTML = '⚠️ ' + data.message;
|
| 210 |
+
status.className = 'status error';
|
| 211 |
+
return;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
// ── Populate raw fields table ─────────────────────────
|
| 215 |
+
const rawBody = document.getElementById('rawFieldsTable');
|
| 216 |
+
rawBody.innerHTML = '';
|
| 217 |
+
// Parse raw_text back into key-value (it's a stringified dict from Python)
|
| 218 |
+
// We'll show hardcoded Form 102 fields since app.py defines them
|
| 219 |
+
const form102Fields = {
|
| 220 |
+
"child_first_name": "Maria Luisa",
|
| 221 |
+
"child_middle_name": "Dela Cruz",
|
| 222 |
+
"child_last_name": "Santos",
|
| 223 |
+
"sex": "Female",
|
| 224 |
+
"date_of_birth": "January 10, 2026",
|
| 225 |
+
"place_of_birth": "Tarlac City, Tarlac",
|
| 226 |
+
"mother_first_name": "Rosa",
|
| 227 |
+
"mother_middle_name":"Reyes",
|
| 228 |
+
"mother_last_name": "Dela Cruz",
|
| 229 |
+
"mother_citizenship":"Filipino",
|
| 230 |
+
"father_first_name": "Juan",
|
| 231 |
+
"father_middle_name":"Pedro",
|
| 232 |
+
"father_last_name": "Santos",
|
| 233 |
+
"father_citizenship":"Filipino",
|
| 234 |
+
"date_of_marriage": "June 12, 2020",
|
| 235 |
+
"place_of_marriage": "Tarlac City, Tarlac",
|
| 236 |
+
"registry_number": "2026-BC-00123",
|
| 237 |
+
"date_of_registration": "January 15, 2026",
|
| 238 |
+
"civil_registrar": "John Doe",
|
| 239 |
+
"civil_registrar_position": "City Civil Registrar",
|
| 240 |
+
};
|
| 241 |
+
Object.entries(form102Fields).forEach(([k, v]) => {
|
| 242 |
+
rawBody.innerHTML += `<tr><td>${k}</td><td>${v}</td></tr>`;
|
| 243 |
+
});
|
| 244 |
+
|
| 245 |
+
// ── Populate mapping rows ─────────────────────────────
|
| 246 |
+
document.getElementById('formClassBadge').textContent = 'Form ' + data.form_class;
|
| 247 |
+
const mappingDiv = document.getElementById('mappingRows');
|
| 248 |
+
mappingDiv.innerHTML = '';
|
| 249 |
+
Object.entries(mappingDef).forEach(([from, to]) => {
|
| 250 |
+
const val = data.fields[to] || '';
|
| 251 |
+
if (!val) return;
|
| 252 |
+
mappingDiv.innerHTML += `
|
| 253 |
+
<div class="map-row">
|
| 254 |
+
<span class="map-from">📄 ${from}</span>
|
| 255 |
+
<span class="map-arr">→</span>
|
| 256 |
+
<span class="map-to">🏷️ ${to}</span>
|
| 257 |
+
<span class="map-arr">→</span>
|
| 258 |
+
<span class="map-val">${val}</span>
|
| 259 |
+
</div>`;
|
| 260 |
+
});
|
| 261 |
+
|
| 262 |
+
// ── Confidence bars ───────────────────────────────────
|
| 263 |
+
const confDiv = document.getElementById('confidenceBars');
|
| 264 |
+
confDiv.innerHTML = '';
|
| 265 |
+
Object.entries(data.confidence).forEach(([key, val]) => {
|
| 266 |
+
const pct = Math.round(val * 100);
|
| 267 |
+
const color = pct >= 90 ? '#1ec77c' : pct >= 75 ? '#f39c12' : '#e74c3c';
|
| 268 |
+
confDiv.innerHTML += `
|
| 269 |
+
<div class="conf-row">
|
| 270 |
+
<span class="conf-lbl">${fieldLabels[key] || key}</span>
|
| 271 |
+
<div class="conf-wrap">
|
| 272 |
+
<div class="conf-bar" style="width:${pct}%;background:${color}"></div>
|
| 273 |
+
</div>
|
| 274 |
+
<span class="conf-pct" style="color:${color}">${pct}%</span>
|
| 275 |
+
</div>`;
|
| 276 |
+
});
|
| 277 |
+
|
| 278 |
+
// ── Saved file ────────────────────────────────────────
|
| 279 |
+
_previewUrl = data.preview_url;
|
| 280 |
+
document.getElementById('savedFileName').textContent = data.saved_file;
|
| 281 |
+
document.getElementById('savedFilePath').textContent = `C:\\xampp\\htdocs\\uploads\\temp\\${data.saved_file}`;
|
| 282 |
+
|
| 283 |
+
results.style.display = 'block';
|
| 284 |
+
status.innerHTML = `✅ Done — Form ${data.form_class} filled and saved to uploads/temp/`;
|
| 285 |
+
status.className = 'status success';
|
| 286 |
+
|
| 287 |
+
} catch (err) {
|
| 288 |
+
status.innerHTML = '❌ Could not reach Flask. Is python app.py running?';
|
| 289 |
+
status.className = 'status error';
|
| 290 |
+
console.error(err);
|
| 291 |
+
}
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
function openPreview() {
|
| 295 |
+
if (_previewUrl) window.open('http://localhost' + _previewUrl, '_blank');
|
| 296 |
+
}
|
| 297 |
+
</script>
|
| 298 |
+
</body>
|
| 299 |
+
</html>
|
train.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Training Script for CRNN+CTC Civil Registry OCR Includes CTC loss, learning rate scheduling, and model checkpointing
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
import torch.optim as optim
|
| 6 |
+
from torch.utils.data import DataLoader
|
| 7 |
+
import os
|
| 8 |
+
from tqdm import tqdm
|
| 9 |
+
import numpy as np
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
import json
|
| 12 |
+
|
| 13 |
+
from crnn_model import get_crnn_model, initialize_weights
|
| 14 |
+
from dataset import CivilRegistryDataset, collate_fn
|
| 15 |
+
from utils import (
|
| 16 |
+
decode_ctc_predictions,
|
| 17 |
+
calculate_cer,
|
| 18 |
+
calculate_wer,
|
| 19 |
+
EarlyStopping
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class CRNNTrainer:
|
| 24 |
+
"""
|
| 25 |
+
Trainer class for CRNN+CTC model
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
def __init__(self, config):
|
| 29 |
+
self.config = config
|
| 30 |
+
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 31 |
+
|
| 32 |
+
# Create directories
|
| 33 |
+
self.checkpoint_dir = Path(config['checkpoint_dir'])
|
| 34 |
+
self.log_dir = Path(config['log_dir'])
|
| 35 |
+
self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
| 36 |
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
| 37 |
+
|
| 38 |
+
# Initialize datasets
|
| 39 |
+
print("Loading datasets...")
|
| 40 |
+
self.train_dataset = CivilRegistryDataset(
|
| 41 |
+
data_dir=config['train_data_dir'],
|
| 42 |
+
annotations_file=config['train_annotations'],
|
| 43 |
+
img_height=config['img_height'],
|
| 44 |
+
img_width=config['img_width'],
|
| 45 |
+
augment=True,
|
| 46 |
+
form_type=config.get('form_type', 'all')
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
self.val_dataset = CivilRegistryDataset(
|
| 50 |
+
data_dir=config['val_data_dir'],
|
| 51 |
+
annotations_file=config['val_annotations'],
|
| 52 |
+
img_height=config['img_height'],
|
| 53 |
+
img_width=config['img_width'],
|
| 54 |
+
augment=False,
|
| 55 |
+
form_type=config.get('form_type', 'all')
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
# Create data loaders
|
| 59 |
+
self.train_loader = DataLoader(
|
| 60 |
+
self.train_dataset,
|
| 61 |
+
batch_size=config['batch_size'],
|
| 62 |
+
shuffle=True,
|
| 63 |
+
num_workers=config['num_workers'],
|
| 64 |
+
collate_fn=collate_fn,
|
| 65 |
+
pin_memory=False
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
self.val_loader = DataLoader(
|
| 69 |
+
self.val_dataset,
|
| 70 |
+
batch_size=config['batch_size'],
|
| 71 |
+
shuffle=False,
|
| 72 |
+
num_workers=config['num_workers'],
|
| 73 |
+
collate_fn=collate_fn,
|
| 74 |
+
pin_memory=False
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# Initialize model
|
| 78 |
+
print(f"Initializing model on {self.device}...")
|
| 79 |
+
self.model = get_crnn_model(
|
| 80 |
+
model_type=config.get('model_type', 'standard'),
|
| 81 |
+
img_height=config['img_height'],
|
| 82 |
+
num_chars=self.train_dataset.num_chars,
|
| 83 |
+
hidden_size=config['hidden_size'],
|
| 84 |
+
num_lstm_layers=config['num_lstm_layers']
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
self.model = self.model.to(self.device)
|
| 88 |
+
|
| 89 |
+
# Loss function - CTC Loss
|
| 90 |
+
self.criterion = nn.CTCLoss(blank=0, zero_infinity=True)
|
| 91 |
+
|
| 92 |
+
# Optimizer — lower LR prevents CTC collapse on epoch 1
|
| 93 |
+
self.optimizer = optim.Adam(
|
| 94 |
+
self.model.parameters(),
|
| 95 |
+
lr=config['learning_rate'],
|
| 96 |
+
weight_decay=config.get('weight_decay', 1e-4) # FIXED: fallback was 1e-5
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
# Warmup scheduler: ramp LR from near-zero to target over first N epochs,
|
| 100 |
+
# then hand off to ReduceLROnPlateau.
|
| 101 |
+
# This is the single most effective fix for CTC blank collapse.
|
| 102 |
+
warmup_epochs = config.get('warmup_epochs', 5)
|
| 103 |
+
|
| 104 |
+
def warmup_lambda(epoch):
|
| 105 |
+
if epoch < warmup_epochs:
|
| 106 |
+
return (epoch + 1) / warmup_epochs # gradual: 0.2→0.4→0.6→0.8→1.0
|
| 107 |
+
return 1.0
|
| 108 |
+
|
| 109 |
+
self.warmup_scheduler = optim.lr_scheduler.LambdaLR(
|
| 110 |
+
self.optimizer, lr_lambda=warmup_lambda)
|
| 111 |
+
|
| 112 |
+
# ReduceLROnPlateau kicks in after warmup
|
| 113 |
+
self.scheduler = optim.lr_scheduler.ReduceLROnPlateau(
|
| 114 |
+
self.optimizer,
|
| 115 |
+
mode='min',
|
| 116 |
+
factor=0.5,
|
| 117 |
+
patience=config.get('lr_patience', 5),
|
| 118 |
+
min_lr=1e-6
|
| 119 |
+
)
|
| 120 |
+
self._warmup_epochs = warmup_epochs
|
| 121 |
+
|
| 122 |
+
# Early stopping
|
| 123 |
+
self.early_stopping = EarlyStopping(
|
| 124 |
+
patience=config.get('early_stopping_patience', 10),
|
| 125 |
+
min_delta=config.get('min_delta', 0.001)
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# Training history
|
| 129 |
+
self.history = {
|
| 130 |
+
'train_loss': [],
|
| 131 |
+
'val_loss': [],
|
| 132 |
+
'val_cer': [],
|
| 133 |
+
'val_wer': [],
|
| 134 |
+
'learning_rates': []
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
# ── Resume from checkpoint if available ──────────────
|
| 138 |
+
self.start_epoch = 1
|
| 139 |
+
self.best_val_loss = float('inf')
|
| 140 |
+
resume_path = self.checkpoint_dir / 'latest_checkpoint.pth'
|
| 141 |
+
|
| 142 |
+
if resume_path.exists():
|
| 143 |
+
print(f"\n Found checkpoint: {resume_path}")
|
| 144 |
+
print(f" Resuming training from last saved epoch...")
|
| 145 |
+
ckpt = torch.load(resume_path, map_location=self.device, weights_only=False)
|
| 146 |
+
self.model.load_state_dict(ckpt['model_state_dict'])
|
| 147 |
+
self.optimizer.load_state_dict(ckpt['optimizer_state_dict'])
|
| 148 |
+
self.scheduler.load_state_dict(ckpt['scheduler_state_dict'])
|
| 149 |
+
if 'warmup_scheduler_state_dict' in ckpt:
|
| 150 |
+
self.warmup_scheduler.load_state_dict(ckpt['warmup_scheduler_state_dict'])
|
| 151 |
+
self.start_epoch = ckpt['epoch'] + 1
|
| 152 |
+
self.best_val_loss = ckpt.get('val_loss', float('inf'))
|
| 153 |
+
self.history = ckpt.get('history', self.history)
|
| 154 |
+
print(f" ✓ Resumed from Epoch {ckpt['epoch']} "
|
| 155 |
+
f"(Val Loss: {ckpt['val_loss']:.4f}, CER: {ckpt['val_cer']:.2f}%)")
|
| 156 |
+
else:
|
| 157 |
+
print(f" No checkpoint found — starting fresh.")
|
| 158 |
+
initialize_weights(self.model)
|
| 159 |
+
|
| 160 |
+
print(f"✓ Model ready with {sum(p.numel() for p in self.model.parameters()):,} parameters")
|
| 161 |
+
|
| 162 |
+
def train_epoch(self, epoch):
|
| 163 |
+
"""Train for one epoch"""
|
| 164 |
+
self.model.train()
|
| 165 |
+
total_loss = 0
|
| 166 |
+
|
| 167 |
+
pbar = tqdm(self.train_loader, desc=f"Epoch {epoch}/{self.config['epochs']}")
|
| 168 |
+
|
| 169 |
+
nan_count = 0
|
| 170 |
+
for batch_idx, (images, targets, target_lengths, _) in enumerate(pbar):
|
| 171 |
+
images = images.to(self.device)
|
| 172 |
+
targets = targets.to(self.device)
|
| 173 |
+
|
| 174 |
+
# FIXED: zero_grad before forward pass (was incorrectly placed after loss)
|
| 175 |
+
self.optimizer.zero_grad()
|
| 176 |
+
|
| 177 |
+
# Forward pass
|
| 178 |
+
outputs = self.model(images) # [seq_len, batch, num_chars]
|
| 179 |
+
|
| 180 |
+
# Apply log_softmax for CTC
|
| 181 |
+
log_probs = nn.functional.log_softmax(outputs, dim=2)
|
| 182 |
+
|
| 183 |
+
# Calculate sequence lengths
|
| 184 |
+
batch_size = images.size(0)
|
| 185 |
+
input_lengths = torch.full(
|
| 186 |
+
size=(batch_size,),
|
| 187 |
+
fill_value=outputs.size(0),
|
| 188 |
+
dtype=torch.long
|
| 189 |
+
).to(self.device)
|
| 190 |
+
|
| 191 |
+
# CTC loss
|
| 192 |
+
loss = self.criterion(
|
| 193 |
+
log_probs,
|
| 194 |
+
targets,
|
| 195 |
+
input_lengths,
|
| 196 |
+
target_lengths
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
# FIXED: skip NaN/Inf batches — accumulating them corrupts gradients
|
| 200 |
+
if torch.isnan(loss) or torch.isinf(loss):
|
| 201 |
+
nan_count += 1
|
| 202 |
+
continue
|
| 203 |
+
|
| 204 |
+
# Backward pass
|
| 205 |
+
loss.backward()
|
| 206 |
+
|
| 207 |
+
# Gradient clipping to prevent exploding gradients
|
| 208 |
+
torch.nn.utils.clip_grad_norm_(self.model.parameters(), 5.0)
|
| 209 |
+
|
| 210 |
+
self.optimizer.step()
|
| 211 |
+
|
| 212 |
+
total_loss += loss.item()
|
| 213 |
+
|
| 214 |
+
# Update progress bar
|
| 215 |
+
pbar.set_postfix({
|
| 216 |
+
'loss': f'{loss.item():.4f}',
|
| 217 |
+
'avg_loss': f'{total_loss / (batch_idx + 1):.4f}'
|
| 218 |
+
})
|
| 219 |
+
if nan_count > 0:
|
| 220 |
+
print(f" [WARNING] {nan_count} NaN/Inf batches skipped this epoch.")
|
| 221 |
+
|
| 222 |
+
avg_loss = total_loss / len(self.train_loader)
|
| 223 |
+
return avg_loss
|
| 224 |
+
|
| 225 |
+
def validate(self):
|
| 226 |
+
"""Validate the model"""
|
| 227 |
+
self.model.eval()
|
| 228 |
+
total_loss = 0
|
| 229 |
+
all_predictions = []
|
| 230 |
+
all_ground_truths = []
|
| 231 |
+
|
| 232 |
+
with torch.no_grad():
|
| 233 |
+
for images, targets, target_lengths, texts in tqdm(self.val_loader, desc="Validating"):
|
| 234 |
+
images = images.to(self.device)
|
| 235 |
+
targets_gpu = targets.to(self.device)
|
| 236 |
+
|
| 237 |
+
# Forward pass
|
| 238 |
+
outputs = self.model(images)
|
| 239 |
+
log_probs = nn.functional.log_softmax(outputs, dim=2)
|
| 240 |
+
|
| 241 |
+
# CTC loss
|
| 242 |
+
batch_size = images.size(0)
|
| 243 |
+
input_lengths = torch.full(
|
| 244 |
+
size=(batch_size,),
|
| 245 |
+
fill_value=outputs.size(0),
|
| 246 |
+
dtype=torch.long
|
| 247 |
+
).to(self.device)
|
| 248 |
+
|
| 249 |
+
loss = self.criterion(log_probs, targets_gpu, input_lengths, target_lengths)
|
| 250 |
+
total_loss += loss.item()
|
| 251 |
+
|
| 252 |
+
# Decode predictions
|
| 253 |
+
predictions = decode_ctc_predictions(
|
| 254 |
+
outputs.cpu(),
|
| 255 |
+
self.train_dataset.idx_to_char
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
all_predictions.extend(predictions)
|
| 259 |
+
all_ground_truths.extend(texts)
|
| 260 |
+
|
| 261 |
+
avg_loss = total_loss / len(self.val_loader)
|
| 262 |
+
|
| 263 |
+
# Calculate metrics
|
| 264 |
+
cer = calculate_cer(all_predictions, all_ground_truths)
|
| 265 |
+
wer = calculate_wer(all_predictions, all_ground_truths)
|
| 266 |
+
|
| 267 |
+
return avg_loss, cer, wer, all_predictions, all_ground_truths
|
| 268 |
+
|
| 269 |
+
def train(self):
|
| 270 |
+
"""Main training loop"""
|
| 271 |
+
print("\n" + "=" * 70)
|
| 272 |
+
print("Starting Training")
|
| 273 |
+
print("=" * 70)
|
| 274 |
+
|
| 275 |
+
best_val_loss = self.best_val_loss
|
| 276 |
+
|
| 277 |
+
for epoch in range(self.start_epoch, self.config['epochs'] + 1):
|
| 278 |
+
print(f"\nEpoch {epoch}/{self.config['epochs']}")
|
| 279 |
+
print("-" * 70)
|
| 280 |
+
|
| 281 |
+
# Train
|
| 282 |
+
train_loss = self.train_epoch(epoch)
|
| 283 |
+
|
| 284 |
+
# Validate
|
| 285 |
+
val_loss, val_cer, val_wer, predictions, ground_truths = self.validate()
|
| 286 |
+
|
| 287 |
+
# Learning rate scheduling
|
| 288 |
+
# Use warmup for first N epochs, then ReduceLROnPlateau
|
| 289 |
+
if epoch <= self._warmup_epochs:
|
| 290 |
+
self.warmup_scheduler.step()
|
| 291 |
+
else:
|
| 292 |
+
self.scheduler.step(val_loss)
|
| 293 |
+
current_lr = self.optimizer.param_groups[0]['lr']
|
| 294 |
+
|
| 295 |
+
# Update history
|
| 296 |
+
self.history['train_loss'].append(train_loss)
|
| 297 |
+
self.history['val_loss'].append(val_loss)
|
| 298 |
+
self.history['val_cer'].append(val_cer)
|
| 299 |
+
self.history['val_wer'].append(val_wer)
|
| 300 |
+
self.history['learning_rates'].append(current_lr)
|
| 301 |
+
|
| 302 |
+
# Print metrics
|
| 303 |
+
print(f"\nMetrics:")
|
| 304 |
+
print(f" Train Loss: {train_loss:.4f}")
|
| 305 |
+
print(f" Val Loss: {val_loss:.4f}")
|
| 306 |
+
print(f" Val CER: {val_cer:.2f}%")
|
| 307 |
+
print(f" Val WER: {val_wer:.2f}%")
|
| 308 |
+
print(f" LR: {current_lr:.6f}")
|
| 309 |
+
|
| 310 |
+
# Print sample predictions
|
| 311 |
+
print(f"\nSample Predictions:")
|
| 312 |
+
for i in range(min(3, len(predictions))):
|
| 313 |
+
print(f" GT: {ground_truths[i]}")
|
| 314 |
+
print(f" Pred: {predictions[i]}")
|
| 315 |
+
print()
|
| 316 |
+
|
| 317 |
+
# show raw model output
|
| 318 |
+
with torch.no_grad():
|
| 319 |
+
sample_img = self.val_dataset[0][0].unsqueeze(0).to(self.device)
|
| 320 |
+
raw_out = self.model(sample_img)
|
| 321 |
+
probs = torch.softmax(raw_out, dim=2)
|
| 322 |
+
best_idx = probs[:, 0, :].argmax(dim=1)
|
| 323 |
+
best_prob = probs[:, 0, :].max(dim=1).values
|
| 324 |
+
blank_pct = (best_idx == 0).float().mean().item() * 100
|
| 325 |
+
avg_conf = best_prob.mean().item()
|
| 326 |
+
non_blank = [self.train_dataset.idx_to_char.get(i.item(), '?')
|
| 327 |
+
for i in best_idx if i.item() != 0]
|
| 328 |
+
print(f" blank={blank_pct:.0f}% conf={avg_conf:.3f} "
|
| 329 |
+
f"chars={''.join(non_blank[:20])!r}")
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
# Save checkpoint
|
| 333 |
+
is_best = val_loss < best_val_loss
|
| 334 |
+
if is_best:
|
| 335 |
+
best_val_loss = val_loss
|
| 336 |
+
|
| 337 |
+
self.save_checkpoint(epoch, val_loss, val_cer, is_best)
|
| 338 |
+
|
| 339 |
+
# Early stopping
|
| 340 |
+
if self.early_stopping(val_loss):
|
| 341 |
+
print(f"\nEarly stopping triggered at epoch {epoch}")
|
| 342 |
+
break
|
| 343 |
+
|
| 344 |
+
print("\n" + "=" * 70)
|
| 345 |
+
print("Training Complete!")
|
| 346 |
+
print(f"Best validation loss: {best_val_loss:.4f}")
|
| 347 |
+
print("=" * 70)
|
| 348 |
+
|
| 349 |
+
# Save final training history
|
| 350 |
+
self.save_history()
|
| 351 |
+
|
| 352 |
+
def save_checkpoint(self, epoch, val_loss, val_cer, is_best=False):
|
| 353 |
+
"""Save model checkpoint"""
|
| 354 |
+
checkpoint = {
|
| 355 |
+
'epoch': epoch,
|
| 356 |
+
'model_state_dict': self.model.state_dict(),
|
| 357 |
+
'optimizer_state_dict': self.optimizer.state_dict(),
|
| 358 |
+
'scheduler_state_dict': self.scheduler.state_dict(),
|
| 359 |
+
'warmup_scheduler_state_dict': self.warmup_scheduler.state_dict(),
|
| 360 |
+
'val_loss': val_loss,
|
| 361 |
+
'val_cer': val_cer,
|
| 362 |
+
'char_to_idx': self.train_dataset.char_to_idx,
|
| 363 |
+
'idx_to_char': self.train_dataset.idx_to_char,
|
| 364 |
+
'config': self.config,
|
| 365 |
+
'history': self.history
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
# Save latest checkpoint
|
| 369 |
+
checkpoint_path = self.checkpoint_dir / 'latest_checkpoint.pth'
|
| 370 |
+
torch.save(checkpoint, checkpoint_path)
|
| 371 |
+
|
| 372 |
+
# Save best checkpoint
|
| 373 |
+
if is_best:
|
| 374 |
+
best_path = self.checkpoint_dir / 'best_model.pth'
|
| 375 |
+
torch.save(checkpoint, best_path)
|
| 376 |
+
print(f" ✓ Best model saved (Val Loss: {val_loss:.4f}, CER: {val_cer:.2f}%)")
|
| 377 |
+
|
| 378 |
+
# Save epoch checkpoint (history omitted to save disk space — it's in latest_checkpoint.pth)
|
| 379 |
+
if epoch % self.config.get('save_freq', 10) == 0:
|
| 380 |
+
epoch_path = self.checkpoint_dir / f'checkpoint_epoch_{epoch}.pth'
|
| 381 |
+
epoch_ckpt = {k: v for k, v in checkpoint.items() if k != 'history'}
|
| 382 |
+
torch.save(epoch_ckpt, epoch_path)
|
| 383 |
+
|
| 384 |
+
def save_history(self):
|
| 385 |
+
"""Save training history"""
|
| 386 |
+
history_path = self.log_dir / 'training_history.json'
|
| 387 |
+
with open(history_path, 'w') as f:
|
| 388 |
+
json.dump(self.history, f, indent=2)
|
| 389 |
+
print(f"\n✓ Training history saved to {history_path}")
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
def main():
|
| 393 |
+
"""Main training function"""
|
| 394 |
+
|
| 395 |
+
# Configuration
|
| 396 |
+
config = {
|
| 397 |
+
# Data
|
| 398 |
+
'train_data_dir': 'data/train',
|
| 399 |
+
'train_annotations': 'data/train_annotations.json',
|
| 400 |
+
'val_data_dir': 'data/val',
|
| 401 |
+
'val_annotations': 'data/val_annotations.json',
|
| 402 |
+
'form_type': 'all', # 'all', 'form1a', 'form2a', 'form3a', 'form90'
|
| 403 |
+
|
| 404 |
+
# Model
|
| 405 |
+
'model_type': 'standard', # 'standard', 'ensemble', 'lightweight'
|
| 406 |
+
'img_height': 64,
|
| 407 |
+
'img_width': 512,
|
| 408 |
+
'hidden_size': 128,
|
| 409 |
+
'num_lstm_layers': 1,
|
| 410 |
+
|
| 411 |
+
# Training
|
| 412 |
+
'batch_size': 32,
|
| 413 |
+
'epochs': 100,
|
| 414 |
+
'learning_rate': 0.0001,
|
| 415 |
+
'weight_decay': 1e-4, # FIXED: was 1e-5 — stronger L2 regularisation to reduce overfitting
|
| 416 |
+
'num_workers': 0,
|
| 417 |
+
'warmup_epochs': 5, # Ramp LR gradually for first 5 epochs
|
| 418 |
+
|
| 419 |
+
# Scheduling & Early Stopping
|
| 420 |
+
'lr_patience': 5, # FIXED: was 3 — give model more time before halving LR
|
| 421 |
+
'early_stopping_patience': 20, # FIXED: was 10 — more patience during zoom training
|
| 422 |
+
'min_delta': 0.001,
|
| 423 |
+
|
| 424 |
+
# Saving
|
| 425 |
+
'checkpoint_dir': 'checkpoints',
|
| 426 |
+
'log_dir': 'logs',
|
| 427 |
+
'save_freq': 10,
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
# Initialize trainer
|
| 431 |
+
trainer = CRNNTrainer(config)
|
| 432 |
+
|
| 433 |
+
# Start training
|
| 434 |
+
trainer.train()
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
if __name__ == "__main__":
|
| 438 |
+
main()
|
train_emnist.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torchvision
|
| 2 |
+
import torchvision.transforms as transforms
|
| 3 |
+
|
| 4 |
+
print("Loading EMNIST dataset...")
|
| 5 |
+
|
| 6 |
+
train_data = torchvision.datasets.EMNIST(
|
| 7 |
+
root='datasets/emnist',
|
| 8 |
+
split='byclass',
|
| 9 |
+
train=True,
|
| 10 |
+
download=False,
|
| 11 |
+
transform=transforms.ToTensor()
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
print(f"Training samples: {len(train_data)}")
|
| 15 |
+
print("EMNIST loaded successfully!")
|
train_mnist.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import tensorflow as tf
|
| 2 |
+
import numpy as np
|
| 3 |
+
from tensorflow.keras import layers, models
|
| 4 |
+
|
| 5 |
+
# Load MNIST dataset
|
| 6 |
+
(x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data()
|
| 7 |
+
|
| 8 |
+
# Normalize pixel values to 0-1
|
| 9 |
+
x_train = x_train / 255.0
|
| 10 |
+
x_test = x_test / 255.0
|
| 11 |
+
|
| 12 |
+
# Add channel dimension (28, 28) -> (28, 28, 1)
|
| 13 |
+
x_train = x_train[..., tf.newaxis]
|
| 14 |
+
x_test = x_test[..., tf.newaxis]
|
| 15 |
+
|
| 16 |
+
# Build simple CNN model
|
| 17 |
+
model = models.Sequential([
|
| 18 |
+
layers.Conv2D(32, (3,3), activation='relu', input_shape=(28,28,1)),
|
| 19 |
+
layers.MaxPooling2D(2,2),
|
| 20 |
+
layers.Conv2D(64, (3,3), activation='relu'),
|
| 21 |
+
layers.MaxPooling2D(2,2),
|
| 22 |
+
layers.Flatten(),
|
| 23 |
+
layers.Dense(128, activation='relu'),
|
| 24 |
+
layers.Dense(10, activation='softmax') # 10 digits (0-9)
|
| 25 |
+
])
|
| 26 |
+
|
| 27 |
+
model.compile(optimizer='adam',
|
| 28 |
+
loss='sparse_categorical_crossentropy',
|
| 29 |
+
metrics=['accuracy'])
|
| 30 |
+
|
| 31 |
+
model.summary()
|
| 32 |
+
|
| 33 |
+
# Train
|
| 34 |
+
model.fit(x_train, y_train, epochs=5, validation_split=0.1)
|
| 35 |
+
|
| 36 |
+
# Evaluate
|
| 37 |
+
test_loss, test_acc = model.evaluate(x_test, y_test)
|
| 38 |
+
print(f"\nTest accuracy: {test_acc:.4f}")
|
| 39 |
+
|
| 40 |
+
# Save model
|
| 41 |
+
model.save("mnist_model.h5")
|
| 42 |
+
print("Model saved as mnist_model.h5")
|
train_with_emnist.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
train_with_emnist.py
|
| 3 |
+
====================
|
| 4 |
+
Fine-tune the CRNN model with EMNIST character data.
|
| 5 |
+
|
| 6 |
+
FIXES vs old version:
|
| 7 |
+
- Phase 1: CNN FROZEN — only RNN+FC trained (prevents catastrophic forgetting)
|
| 8 |
+
- Phase 2: Full model at 10x lower LR for final polish
|
| 9 |
+
- log_softmax applied before CTCLoss (was missing — caused garbage loss)
|
| 10 |
+
- Loads from best_model.pth (synthetic, 0.12% CER baseline)
|
| 11 |
+
- Saves best_model_emnist.pth only when val improves
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
import torch
|
| 17 |
+
import torch.nn.functional as F
|
| 18 |
+
import torch.optim as optim
|
| 19 |
+
from torch.utils.data import DataLoader, ConcatDataset
|
| 20 |
+
|
| 21 |
+
sys.path.append('.')
|
| 22 |
+
from crnn_model import get_crnn_model
|
| 23 |
+
from dataset import CivilRegistryDataset, collate_fn
|
| 24 |
+
|
| 25 |
+
print("=" * 55)
|
| 26 |
+
print("Fine-tuning CRNN with EMNIST dataset")
|
| 27 |
+
print("=" * 55)
|
| 28 |
+
|
| 29 |
+
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 30 |
+
print(f"Device: {DEVICE}")
|
| 31 |
+
|
| 32 |
+
emnist_dataset = CivilRegistryDataset(
|
| 33 |
+
data_dir='data/train',
|
| 34 |
+
annotations_file='data/emnist_train_annotations.json',
|
| 35 |
+
img_height=64, img_width=512, augment=True
|
| 36 |
+
)
|
| 37 |
+
# FIXED: mix synthetic data in so the model never forgets multi-word sequences
|
| 38 |
+
synth_dataset = CivilRegistryDataset(
|
| 39 |
+
data_dir='data/train',
|
| 40 |
+
annotations_file='data/train_annotations.json',
|
| 41 |
+
img_height=64, img_width=512, augment=True
|
| 42 |
+
)
|
| 43 |
+
train_dataset = emnist_dataset # keep reference for char_to_idx / num_chars
|
| 44 |
+
mixed_train = ConcatDataset([emnist_dataset, synth_dataset])
|
| 45 |
+
val_dataset = CivilRegistryDataset(
|
| 46 |
+
data_dir='data/val',
|
| 47 |
+
annotations_file='data/val_annotations.json', # FIXED: was emnist_val — must match real task
|
| 48 |
+
img_height=64, img_width=512, augment=False
|
| 49 |
+
)
|
| 50 |
+
print(f"EMNIST train : {len(emnist_dataset)}")
|
| 51 |
+
print(f"Synthetic train: {len(synth_dataset)}")
|
| 52 |
+
print(f"Mixed train : {len(mixed_train)}")
|
| 53 |
+
print(f"Val : {len(val_dataset)}")
|
| 54 |
+
|
| 55 |
+
train_loader = DataLoader(mixed_train, batch_size=32, shuffle=True,
|
| 56 |
+
num_workers=0, collate_fn=collate_fn)
|
| 57 |
+
val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False,
|
| 58 |
+
num_workers=0, collate_fn=collate_fn)
|
| 59 |
+
|
| 60 |
+
# ── Load best synthetic checkpoint ───────────────────────────
|
| 61 |
+
BASE = 'checkpoints/best_model.pth'
|
| 62 |
+
if not os.path.exists(BASE):
|
| 63 |
+
print(f"ERROR: {BASE} not found. Run: python train.py")
|
| 64 |
+
sys.exit(1)
|
| 65 |
+
|
| 66 |
+
ckpt = torch.load(BASE, map_location=DEVICE, weights_only=False)
|
| 67 |
+
config = ckpt.get('config', {})
|
| 68 |
+
|
| 69 |
+
model = get_crnn_model(
|
| 70 |
+
model_type = config.get('model_type', 'standard'),
|
| 71 |
+
img_height = config.get('img_height', 64),
|
| 72 |
+
num_chars = train_dataset.num_chars,
|
| 73 |
+
hidden_size = config.get('hidden_size', 128),
|
| 74 |
+
num_lstm_layers = config.get('num_lstm_layers', 1),
|
| 75 |
+
).to(DEVICE)
|
| 76 |
+
|
| 77 |
+
missing, _ = model.load_state_dict(ckpt['model_state_dict'], strict=False)
|
| 78 |
+
if missing:
|
| 79 |
+
print(f" Note: {len(missing)} layers re-initialized (expected for fc layer)")
|
| 80 |
+
print(f" Loaded epoch {ckpt.get('epoch')} "
|
| 81 |
+
f"(val_loss={ckpt.get('val_loss', ckpt.get('val_cer', 0)):.4f})")
|
| 82 |
+
|
| 83 |
+
criterion = torch.nn.CTCLoss(blank=0, reduction='mean', zero_infinity=True)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def run_epoch(loader, training, optimizer=None):
|
| 87 |
+
model.train() if training else model.eval()
|
| 88 |
+
total, n = 0, 0
|
| 89 |
+
ctx = torch.enable_grad() if training else torch.no_grad()
|
| 90 |
+
with ctx:
|
| 91 |
+
for images, targets, target_lengths, _ in loader:
|
| 92 |
+
images = images.to(DEVICE)
|
| 93 |
+
batch_size = images.size(0)
|
| 94 |
+
if training:
|
| 95 |
+
optimizer.zero_grad()
|
| 96 |
+
# CRITICAL: log_softmax before CTCLoss
|
| 97 |
+
outputs = F.log_softmax(model(images), dim=2)
|
| 98 |
+
seq_len = outputs.size(0)
|
| 99 |
+
input_lengths = torch.full((batch_size,), seq_len, dtype=torch.long)
|
| 100 |
+
loss = criterion(outputs, targets, input_lengths, target_lengths)
|
| 101 |
+
if not torch.isnan(loss) and not torch.isinf(loss):
|
| 102 |
+
if training:
|
| 103 |
+
loss.backward()
|
| 104 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 5)
|
| 105 |
+
optimizer.step()
|
| 106 |
+
total += loss.item()
|
| 107 |
+
n += 1
|
| 108 |
+
return total / max(n, 1)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def run_phase(num, epochs, lr, freeze_cnn, patience):
|
| 112 |
+
print(f"\n{'='*55}")
|
| 113 |
+
print(f" PHASE {num} — "
|
| 114 |
+
f"{'CNN FROZEN (RNN+FC only)' if freeze_cnn else 'FULL MODEL (all layers)'}"
|
| 115 |
+
f" LR={lr}")
|
| 116 |
+
print(f"{'='*55}")
|
| 117 |
+
|
| 118 |
+
# Freeze or unfreeze CNN
|
| 119 |
+
for name, param in model.named_parameters():
|
| 120 |
+
param.requires_grad = not (freeze_cnn and 'cnn' in name)
|
| 121 |
+
|
| 122 |
+
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 123 |
+
print(f" Trainable params : {trainable:,}")
|
| 124 |
+
|
| 125 |
+
opt = optim.Adam(
|
| 126 |
+
filter(lambda p: p.requires_grad, model.parameters()), lr=lr)
|
| 127 |
+
sched = optim.lr_scheduler.ReduceLROnPlateau(opt, patience=3, factor=0.5)
|
| 128 |
+
best = float('inf')
|
| 129 |
+
counter = 0
|
| 130 |
+
|
| 131 |
+
for epoch in range(1, epochs + 1):
|
| 132 |
+
tr = run_epoch(train_loader, True, opt)
|
| 133 |
+
vl = run_epoch(val_loader, False, None)
|
| 134 |
+
sched.step(vl)
|
| 135 |
+
|
| 136 |
+
if vl < best:
|
| 137 |
+
best = vl
|
| 138 |
+
counter = 0
|
| 139 |
+
torch.save({
|
| 140 |
+
'model_state_dict': model.state_dict(),
|
| 141 |
+
'config': config,
|
| 142 |
+
'char_to_idx': train_dataset.char_to_idx,
|
| 143 |
+
'idx_to_char': train_dataset.idx_to_char,
|
| 144 |
+
'epoch': epoch,
|
| 145 |
+
'val_loss': vl, # FIXED: renamed from val_cer — this is val loss, not CER%
|
| 146 |
+
}, 'checkpoints/best_model_emnist.pth')
|
| 147 |
+
print(f" Epoch {epoch:02d}/{epochs} Train={tr:.4f} Val={vl:.4f} <- saved")
|
| 148 |
+
else:
|
| 149 |
+
counter += 1
|
| 150 |
+
print(f" Epoch {epoch:02d}/{epochs} Train={tr:.4f} Val={vl:.4f}"
|
| 151 |
+
f" (patience {counter}/{patience})")
|
| 152 |
+
if counter >= patience:
|
| 153 |
+
print(f" Early stopping at epoch {epoch}.")
|
| 154 |
+
break
|
| 155 |
+
return best
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# ── Phase 1: Freeze CNN — teach RNN+FC to handle EMNIST chars ─
|
| 159 |
+
p1_best = run_phase(1, epochs=30, lr=1e-4, freeze_cnn=True, patience=7)
|
| 160 |
+
|
| 161 |
+
# ── Phase 2: Unfreeze all — gentle full-model polish ──────────
|
| 162 |
+
p2_best = run_phase(2, epochs=20, lr=1e-6, freeze_cnn=False, patience=5)
|
| 163 |
+
|
| 164 |
+
print(f"\n{'='*55}")
|
| 165 |
+
print(f"EMNIST fine-tuning complete!")
|
| 166 |
+
print(f" Phase 1 best val loss : {p1_best:.4f}")
|
| 167 |
+
print(f" Phase 2 best val loss : {p2_best:.4f}")
|
| 168 |
+
print(f" Saved : checkpoints/best_model_emnist.pth")
|
| 169 |
+
print(f"\nNext step: python IAM_train.py --prepare --train")
|
utils.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utility Functions for CRNN+CTC Civil Registry OCR
|
| 3 |
+
Includes CTC decoding, metrics calculation, and helper functions
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
import numpy as np
|
| 8 |
+
def _editdistance(a, b):
|
| 9 |
+
"""Pure-Python Levenshtein distance — replaces the editdistance C extension."""
|
| 10 |
+
m, n = len(a), len(b)
|
| 11 |
+
dp = list(range(n + 1))
|
| 12 |
+
for i in range(1, m + 1):
|
| 13 |
+
prev, dp[0] = dp[0], i
|
| 14 |
+
for j in range(1, n + 1):
|
| 15 |
+
prev, dp[j] = dp[j], prev if a[i-1] == b[j-1] else 1 + min(prev, dp[j], dp[j-1])
|
| 16 |
+
return dp[n]
|
| 17 |
+
from typing import List, Dict, Tuple
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def decode_ctc_predictions(outputs, idx_to_char, method='greedy'):
|
| 21 |
+
"""
|
| 22 |
+
Decode CTC predictions to text
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
outputs: Model outputs [seq_len, batch, num_chars]
|
| 26 |
+
idx_to_char: Dictionary mapping indices to characters
|
| 27 |
+
method: 'greedy' or 'beam_search'
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
List of decoded strings
|
| 31 |
+
"""
|
| 32 |
+
if method == 'greedy':
|
| 33 |
+
return greedy_decode(outputs, idx_to_char)
|
| 34 |
+
elif method == 'beam_search':
|
| 35 |
+
return beam_search_decode(outputs, idx_to_char)
|
| 36 |
+
else:
|
| 37 |
+
raise ValueError(f"Unknown decoding method: {method}")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def greedy_decode(outputs, idx_to_char):
|
| 41 |
+
"""
|
| 42 |
+
Greedy CTC decoding - fast but less accurate
|
| 43 |
+
"""
|
| 44 |
+
# Get most probable characters
|
| 45 |
+
pred_indices = torch.argmax(outputs, dim=2) # [seq_len, batch]
|
| 46 |
+
pred_indices = pred_indices.permute(1, 0) # [batch, seq_len]
|
| 47 |
+
|
| 48 |
+
decoded_texts = []
|
| 49 |
+
|
| 50 |
+
for sequence in pred_indices:
|
| 51 |
+
chars = []
|
| 52 |
+
prev_idx = -1
|
| 53 |
+
|
| 54 |
+
for idx in sequence:
|
| 55 |
+
idx = idx.item()
|
| 56 |
+
# Skip blank (0) and consecutive duplicates
|
| 57 |
+
if idx != 0 and idx != prev_idx:
|
| 58 |
+
if idx in idx_to_char:
|
| 59 |
+
chars.append(idx_to_char[idx])
|
| 60 |
+
prev_idx = idx
|
| 61 |
+
|
| 62 |
+
decoded_texts.append(''.join(chars))
|
| 63 |
+
|
| 64 |
+
return decoded_texts
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def beam_search_decode(outputs, idx_to_char, beam_width=10):
|
| 68 |
+
"""
|
| 69 |
+
Beam search CTC decoding - slower but more accurate.
|
| 70 |
+
|
| 71 |
+
FIXED Bug 6: previous code mixed list-of-chars and string representations.
|
| 72 |
+
After sorting new_beams (a dict keyed by strings), it did `list(seq)` on the
|
| 73 |
+
string key — which splits a string like "AB" into ['A','B'] accidentally works
|
| 74 |
+
for ASCII but is fragile and confusing. Rewritten to use strings throughout:
|
| 75 |
+
beams are now List[Tuple[str, float]] with the sequence always kept as a plain
|
| 76 |
+
string, eliminating the list/string ambiguity entirely.
|
| 77 |
+
"""
|
| 78 |
+
outputs = torch.nn.functional.softmax(outputs, dim=2)
|
| 79 |
+
outputs = outputs.permute(1, 0, 2).cpu().numpy() # [batch, seq_len, num_chars]
|
| 80 |
+
|
| 81 |
+
decoded_texts = []
|
| 82 |
+
|
| 83 |
+
for output in outputs:
|
| 84 |
+
# Each beam is (sequence_string, cumulative_probability)
|
| 85 |
+
beams: list = [('', 1.0)]
|
| 86 |
+
|
| 87 |
+
for timestep in output:
|
| 88 |
+
new_beams: dict = {}
|
| 89 |
+
|
| 90 |
+
for sequence, prob in beams:
|
| 91 |
+
for idx, char_prob in enumerate(timestep):
|
| 92 |
+
if idx == 0: # blank token — sequence unchanged
|
| 93 |
+
new_seq = sequence
|
| 94 |
+
elif idx in idx_to_char:
|
| 95 |
+
char = idx_to_char[idx]
|
| 96 |
+
# CTC rule: merge consecutive duplicate characters
|
| 97 |
+
if sequence and sequence[-1] == char:
|
| 98 |
+
new_seq = sequence # duplicate — stay the same
|
| 99 |
+
else:
|
| 100 |
+
new_seq = sequence + char # append directly to string
|
| 101 |
+
else:
|
| 102 |
+
continue
|
| 103 |
+
|
| 104 |
+
new_prob = prob * char_prob
|
| 105 |
+
# Merge beams that produce the same string
|
| 106 |
+
if new_seq in new_beams:
|
| 107 |
+
new_beams[new_seq] = max(new_beams[new_seq], new_prob)
|
| 108 |
+
else:
|
| 109 |
+
new_beams[new_seq] = new_prob
|
| 110 |
+
|
| 111 |
+
# Keep top-k beams; keys are already strings — no list() conversion needed
|
| 112 |
+
beams = sorted(new_beams.items(), key=lambda x: x[1], reverse=True)[:beam_width]
|
| 113 |
+
|
| 114 |
+
# Best sequence is the string with highest probability
|
| 115 |
+
best_sequence = max(beams, key=lambda x: x[1])[0]
|
| 116 |
+
decoded_texts.append(best_sequence)
|
| 117 |
+
|
| 118 |
+
return decoded_texts
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def calculate_cer(predictions: List[str], ground_truths: List[str]) -> float:
|
| 122 |
+
"""
|
| 123 |
+
Calculate Character Error Rate (CER)
|
| 124 |
+
|
| 125 |
+
CER = (Substitutions + Deletions + Insertions) / Total Characters
|
| 126 |
+
"""
|
| 127 |
+
if len(predictions) != len(ground_truths):
|
| 128 |
+
raise ValueError("Predictions and ground truths must have same length")
|
| 129 |
+
|
| 130 |
+
total_distance = 0
|
| 131 |
+
total_length = 0
|
| 132 |
+
|
| 133 |
+
for pred, gt in zip(predictions, ground_truths):
|
| 134 |
+
distance = _editdistance(pred, gt)
|
| 135 |
+
total_distance += distance
|
| 136 |
+
total_length += len(gt)
|
| 137 |
+
|
| 138 |
+
cer = (total_distance / total_length * 100) if total_length > 0 else 0
|
| 139 |
+
return cer
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def calculate_wer(predictions: List[str], ground_truths: List[str]) -> float:
|
| 143 |
+
"""
|
| 144 |
+
Calculate Word Error Rate (WER)
|
| 145 |
+
|
| 146 |
+
WER = (Substitutions + Deletions + Insertions) / Total Words
|
| 147 |
+
"""
|
| 148 |
+
if len(predictions) != len(ground_truths):
|
| 149 |
+
raise ValueError("Predictions and ground truths must have same length")
|
| 150 |
+
|
| 151 |
+
total_distance = 0
|
| 152 |
+
total_length = 0
|
| 153 |
+
|
| 154 |
+
for pred, gt in zip(predictions, ground_truths):
|
| 155 |
+
pred_words = pred.split()
|
| 156 |
+
gt_words = gt.split()
|
| 157 |
+
|
| 158 |
+
distance = _editdistance(pred_words, gt_words)
|
| 159 |
+
total_distance += distance
|
| 160 |
+
total_length += len(gt_words)
|
| 161 |
+
|
| 162 |
+
wer = (total_distance / total_length * 100) if total_length > 0 else 0
|
| 163 |
+
return wer
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def calculate_accuracy(predictions: List[str], ground_truths: List[str]) -> float:
|
| 167 |
+
"""
|
| 168 |
+
Calculate exact match accuracy
|
| 169 |
+
"""
|
| 170 |
+
if len(predictions) != len(ground_truths):
|
| 171 |
+
raise ValueError("Predictions and ground truths must have same length")
|
| 172 |
+
|
| 173 |
+
correct = sum(1 for pred, gt in zip(predictions, ground_truths) if pred == gt)
|
| 174 |
+
accuracy = (correct / len(predictions) * 100) if len(predictions) > 0 else 0
|
| 175 |
+
|
| 176 |
+
return accuracy
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
class EarlyStopping:
|
| 180 |
+
"""
|
| 181 |
+
Early stopping to stop training when validation loss stops improving
|
| 182 |
+
"""
|
| 183 |
+
|
| 184 |
+
def __init__(self, patience=10, min_delta=0.001):
|
| 185 |
+
self.patience = patience
|
| 186 |
+
self.min_delta = min_delta
|
| 187 |
+
self.counter = 0
|
| 188 |
+
self.best_loss = None
|
| 189 |
+
self.early_stop = False
|
| 190 |
+
|
| 191 |
+
def __call__(self, val_loss):
|
| 192 |
+
if self.best_loss is None:
|
| 193 |
+
self.best_loss = val_loss
|
| 194 |
+
elif val_loss > self.best_loss - self.min_delta:
|
| 195 |
+
self.counter += 1
|
| 196 |
+
if self.counter >= self.patience:
|
| 197 |
+
self.early_stop = True
|
| 198 |
+
else:
|
| 199 |
+
self.best_loss = val_loss
|
| 200 |
+
self.counter = 0
|
| 201 |
+
|
| 202 |
+
return self.early_stop
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
class AverageMeter:
|
| 206 |
+
"""
|
| 207 |
+
Computes and stores the average and current value
|
| 208 |
+
"""
|
| 209 |
+
|
| 210 |
+
def __init__(self):
|
| 211 |
+
self.reset()
|
| 212 |
+
|
| 213 |
+
def reset(self):
|
| 214 |
+
self.val = 0
|
| 215 |
+
self.avg = 0
|
| 216 |
+
self.sum = 0
|
| 217 |
+
self.count = 0
|
| 218 |
+
|
| 219 |
+
def update(self, val, n=1):
|
| 220 |
+
self.val = val
|
| 221 |
+
self.sum += val * n
|
| 222 |
+
self.count += n
|
| 223 |
+
self.avg = self.sum / self.count
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def calculate_confusion_matrix(predictions: List[str], ground_truths: List[str], char_set: List[str]) -> np.ndarray:
|
| 227 |
+
"""
|
| 228 |
+
Calculate character-level confusion matrix
|
| 229 |
+
|
| 230 |
+
Args:
|
| 231 |
+
predictions: List of predicted strings
|
| 232 |
+
ground_truths: List of ground truth strings
|
| 233 |
+
char_set: List of all possible characters
|
| 234 |
+
|
| 235 |
+
Returns:
|
| 236 |
+
Confusion matrix [num_chars, num_chars]
|
| 237 |
+
"""
|
| 238 |
+
char_to_idx = {char: idx for idx, char in enumerate(char_set)}
|
| 239 |
+
n_chars = len(char_set)
|
| 240 |
+
|
| 241 |
+
confusion = np.zeros((n_chars, n_chars), dtype=np.int64)
|
| 242 |
+
|
| 243 |
+
for pred, gt in zip(predictions, ground_truths):
|
| 244 |
+
# Align sequences (simple alignment)
|
| 245 |
+
max_len = max(len(pred), len(gt))
|
| 246 |
+
pred_padded = pred + ' ' * (max_len - len(pred))
|
| 247 |
+
gt_padded = gt + ' ' * (max_len - len(gt))
|
| 248 |
+
|
| 249 |
+
for p_char, g_char in zip(pred_padded, gt_padded):
|
| 250 |
+
if p_char in char_to_idx and g_char in char_to_idx:
|
| 251 |
+
confusion[char_to_idx[g_char], char_to_idx[p_char]] += 1
|
| 252 |
+
|
| 253 |
+
return confusion
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def extract_form_fields(text: str, form_type: str) -> Dict[str, str]:
|
| 257 |
+
"""
|
| 258 |
+
Extract specific fields from recognized text based on form type
|
| 259 |
+
|
| 260 |
+
Args:
|
| 261 |
+
text: Recognized text
|
| 262 |
+
form_type: 'form1a', 'form2a', 'form3a', 'form90'
|
| 263 |
+
|
| 264 |
+
Returns:
|
| 265 |
+
Dictionary of extracted fields
|
| 266 |
+
"""
|
| 267 |
+
fields = {}
|
| 268 |
+
|
| 269 |
+
if form_type == 'form1a': # Birth Certificate
|
| 270 |
+
# Extract common fields (simplified)
|
| 271 |
+
# In practice, use NER or regex patterns
|
| 272 |
+
fields['type'] = 'Birth Certificate'
|
| 273 |
+
# Add more field extraction logic
|
| 274 |
+
|
| 275 |
+
elif form_type == 'form2a': # Death Certificate
|
| 276 |
+
fields['type'] = 'Death Certificate'
|
| 277 |
+
|
| 278 |
+
elif form_type == 'form3a': # Marriage Certificate
|
| 279 |
+
fields['type'] = 'Marriage Certificate'
|
| 280 |
+
|
| 281 |
+
elif form_type == 'form90': # Marriage License Application
|
| 282 |
+
fields['type'] = 'Marriage License Application'
|
| 283 |
+
|
| 284 |
+
return fields
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def validate_extracted_data(data: Dict[str, str], form_type: str) -> Tuple[bool, List[str]]:
|
| 288 |
+
"""
|
| 289 |
+
Validate extracted data for completeness and format
|
| 290 |
+
|
| 291 |
+
Args:
|
| 292 |
+
data: Extracted data dictionary
|
| 293 |
+
form_type: Form type
|
| 294 |
+
|
| 295 |
+
Returns:
|
| 296 |
+
(is_valid, list_of_errors)
|
| 297 |
+
"""
|
| 298 |
+
errors = []
|
| 299 |
+
|
| 300 |
+
# Define required fields per form type
|
| 301 |
+
required_fields = {
|
| 302 |
+
'form1a': ['name', 'date_of_birth', 'place_of_birth'],
|
| 303 |
+
'form2a': ['name', 'date_of_death', 'place_of_death'],
|
| 304 |
+
'form3a': ['husband_name', 'wife_name', 'date_of_marriage'],
|
| 305 |
+
'form90': ['husband_name', 'wife_name', 'date_of_application']
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
# Check required fields
|
| 309 |
+
for field in required_fields.get(form_type, []):
|
| 310 |
+
if field not in data or not data[field]:
|
| 311 |
+
errors.append(f"Missing required field: {field}")
|
| 312 |
+
|
| 313 |
+
# Additional validation can be added here
|
| 314 |
+
# - Date format validation
|
| 315 |
+
# - Name format validation
|
| 316 |
+
# - etc.
|
| 317 |
+
|
| 318 |
+
is_valid = len(errors) == 0
|
| 319 |
+
return is_valid, errors
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def load_checkpoint(checkpoint_path, model, optimizer=None, device='cpu'):
|
| 323 |
+
"""
|
| 324 |
+
Load model checkpoint
|
| 325 |
+
|
| 326 |
+
Args:
|
| 327 |
+
checkpoint_path: Path to checkpoint file
|
| 328 |
+
model: Model instance
|
| 329 |
+
optimizer: Optimizer instance (optional)
|
| 330 |
+
device: Device to load to
|
| 331 |
+
|
| 332 |
+
Returns:
|
| 333 |
+
(model, optimizer, checkpoint_dict)
|
| 334 |
+
"""
|
| 335 |
+
checkpoint = torch.load(checkpoint_path, map_location=device)
|
| 336 |
+
|
| 337 |
+
model.load_state_dict(checkpoint['model_state_dict'])
|
| 338 |
+
|
| 339 |
+
if optimizer is not None and 'optimizer_state_dict' in checkpoint:
|
| 340 |
+
optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
|
| 341 |
+
|
| 342 |
+
print(f"✓ Loaded checkpoint from {checkpoint_path}")
|
| 343 |
+
print(f" Epoch: {checkpoint.get('epoch', 'N/A')}")
|
| 344 |
+
if 'val_cer' in checkpoint:
|
| 345 |
+
print(f" Val CER : {checkpoint['val_cer']:.4f}%")
|
| 346 |
+
elif 'val_loss' in checkpoint:
|
| 347 |
+
print(f" Val Loss : {checkpoint['val_loss']:.4f} (run compare_live_cer.py for true CER)")
|
| 348 |
+
else:
|
| 349 |
+
print(f" Val CER : N/A (run compare_live_cer.py for true CER)")
|
| 350 |
+
|
| 351 |
+
return model, optimizer, checkpoint
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def save_predictions_to_file(predictions: List[str], ground_truths: List[str], output_file: str):
|
| 355 |
+
"""
|
| 356 |
+
Save predictions and ground truths to file for analysis
|
| 357 |
+
"""
|
| 358 |
+
with open(output_file, 'w', encoding='utf-8') as f:
|
| 359 |
+
f.write("Ground Truth\tPrediction\tMatch\n")
|
| 360 |
+
f.write("=" * 80 + "\n")
|
| 361 |
+
|
| 362 |
+
for gt, pred in zip(ground_truths, predictions):
|
| 363 |
+
match = "✓" if gt == pred else "✗"
|
| 364 |
+
f.write(f"{gt}\t{pred}\t{match}\n")
|
| 365 |
+
|
| 366 |
+
print(f"✓ Predictions saved to {output_file}")
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
if __name__ == "__main__":
|
| 370 |
+
# Test utility functions
|
| 371 |
+
print("=" * 60)
|
| 372 |
+
print("Testing Utility Functions")
|
| 373 |
+
print("=" * 60)
|
| 374 |
+
|
| 375 |
+
# Test CER calculation
|
| 376 |
+
predictions = ["Hello World", "Test", "Sample Text"]
|
| 377 |
+
ground_truths = ["Hello World", "Tset", "Sample Txt"]
|
| 378 |
+
|
| 379 |
+
cer = calculate_cer(predictions, ground_truths)
|
| 380 |
+
wer = calculate_wer(predictions, ground_truths)
|
| 381 |
+
accuracy = calculate_accuracy(predictions, ground_truths)
|
| 382 |
+
|
| 383 |
+
print(f"\nMetrics:")
|
| 384 |
+
print(f" CER: {cer:.2f}%")
|
| 385 |
+
print(f" WER: {wer:.2f}%")
|
| 386 |
+
print(f" Accuracy: {accuracy:.2f}%")
|
| 387 |
+
|
| 388 |
+
# Test early stopping
|
| 389 |
+
print("\nTesting Early Stopping:")
|
| 390 |
+
early_stopping = EarlyStopping(patience=3, min_delta=0.001)
|
| 391 |
+
|
| 392 |
+
val_losses = [1.0, 0.9, 0.85, 0.84, 0.84, 0.84, 0.84]
|
| 393 |
+
for epoch, loss in enumerate(val_losses, 1):
|
| 394 |
+
should_stop = early_stopping(loss)
|
| 395 |
+
print(f" Epoch {epoch}: Loss = {loss:.2f}, Stop = {should_stop}")
|
| 396 |
+
if should_stop:
|
| 397 |
+
break
|