hanz245 commited on
Commit
091afb2
·
0 Parent(s):

clean version without LFS

Browse files
.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> &nbsp;→ 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