ktp-ocr-engine / main.py
muhammadhabibna's picture
feat: OCR scoring, adaptive fallback, NIK region fallback, relaxed rejection
3f0bee0
Raw
History Blame Contribute Delete
28 kB
import cv2
import numpy as np
import re
from difflib import get_close_matches
from fastapi import FastAPI, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware
from paddleocr import PaddleOCR
app = FastAPI(title="OCR KTP API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
async def root():
return {"status": "online", "message": "KTP OCR Engine is running"}
print("Memuat model PaddleOCR...")
ocr = PaddleOCR(
use_angle_cls=True,
lang="en",
det_db_thresh=0.3,
det_db_unclip_ratio=1.8,
rec_batch_num=6,
use_space_char=True,
show_log=False,
)
print("Model berhasil dimuat!")
# ============================================================
# DATABASE KOTA INDONESIA (untuk fuzzy matching nama kota di TTL)
# ============================================================
KOTA_DB = [
"SURABAYA","JAKARTA","BANDUNG","SEMARANG","MALANG","MEDAN","MAKASSAR",
"PALEMBANG","TANGERANG","DEPOK","BEKASI","BOGOR","YOGYAKARTA","SOLO",
"DENPASAR","MANADO","PONTIANAK","BANJARMASIN","SAMARINDA","BALIKPAPAN",
"PADANG","PEKANBARU","JAMBI","LAMPUNG","MATARAM","KUPANG","AMBON",
"JAYAPURA","SORONG","TERNATE","KENDARI","PALU","GORONTALO","MAMUJU",
"BENGKULU","BANGKA","PANGKAL PINANG","SERANG","CILEGON","TASIKMALAYA",
"CIREBON","SUKABUMI","PURWOKERTO","TEGAL","PEKALONGAN","MAGELANG",
"KLATEN","SURAKARTA","KEDIRI","BLITAR","PROBOLINGGO","PASURUAN",
"MOJOKERTO","MADIUN","JEMBER","BANYUWANGI","SIDOARJO","GRESIK",
"LAMONGAN","TUBAN","LUMAJANG","BONDOWOSO","SITUBONDO","NGAWI",
"MAGETAN","PONOROGO","TRENGGALEK","TULUNGAGUNG","PACITAN",
"BANGKALAN","SAMPANG","PAMEKASAN","SUMENEP","NGANJUK","JOMBANG",
"BOJONEGORO","GARUT","SUBANG","KARAWANG","PURWAKARTA","CIANJUR",
"BREBES","DEMAK","KUDUS","JEPARA","REMBANG","BLORA","WONOGIRI",
]
PROVINSI_CODES = {
"11","12","13","14","15","16","17","18","19","21",
"31","32","33","34","35","36","51","52","53","61",
"62","63","64","65","71","72","73","74","75","76","81","82","91","94",
}
# ============================================================
# PREPROCESSING
# ============================================================
def deskew_image(img):
"""Koreksi kemiringan teks otomatis."""
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]
coords = np.column_stack(np.where(thresh > 0))
if len(coords) < 50:
return img
angle = cv2.minAreaRect(coords)[-1]
if angle < -45:
angle = -(90 + angle)
else:
angle = -angle
if abs(angle) < 0.5 or abs(angle) > 15:
return img # Skip jika sudut terlalu kecil atau terlalu besar (bukan skew)
h, w = img.shape[:2]
center = (w // 2, h // 2)
M = cv2.getRotationMatrix2D(center, angle, 1.0)
return cv2.warpAffine(img, M, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE)
def smart_resize(img, target_width=1280):
"""Resize ke lebar optimal untuk OCR."""
h, w = img.shape[:2]
if w < 800 or w > 2500:
scale = target_width / w
img = cv2.resize(img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_CUBIC)
return img
def preprocess_path_a(img):
"""Path A: CLAHE + Sharpening β€” untuk foto dengan pencahayaan normal."""
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
enhanced = clahe.apply(gray)
# Unsharp mask
blur = cv2.GaussianBlur(enhanced, (0, 0), 3.0)
sharpened = cv2.addWeighted(enhanced, 1.5, blur, -0.5, 0)
return cv2.cvtColor(sharpened, cv2.COLOR_GRAY2BGR)
def preprocess_path_b(img):
"""Path B: Adaptive Threshold β€” untuk foto gelap/kontras rendah."""
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
denoised = cv2.fastNlMeansDenoising(gray, h=10)
thresh = cv2.adaptiveThreshold(denoised, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 21, 10)
kernel = np.ones((1, 1), np.uint8)
cleaned = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel)
return cv2.cvtColor(cleaned, cv2.COLOR_GRAY2BGR)
def check_image_quality(img):
"""Cek kualitas gambar β€” return score 0-100."""
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# Laplacian variance = ukuran ketajaman
sharpness = cv2.Laplacian(gray, cv2.CV_64F).var()
# Kontras
contrast = gray.std()
score = min(100, (sharpness / 100) * 50 + (contrast / 60) * 50)
return score
def preprocess_image(image_bytes):
"""Pipeline preprocessing lengkap."""
nparr = np.frombuffer(image_bytes, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if img is None:
raise ValueError("Gambar tidak valid")
img = smart_resize(img)
img = deskew_image(img)
return img
# ============================================================
# OCR TEXT FIXES
# ============================================================
def fix_ocr_digits(text):
"""Fix karakter yang salah di konteks ANGKA: O→0, I→1, S→5.
Khusus NIK: tidak fix 4β†’9 atau 9β†’4 karena ambigu,
tapi fix karakter huruf yang mirip angka."""
return text.replace("O", "0").replace("o", "0").replace("I", "1").replace("l", "1").replace("S", "5").replace("s", "5")
def fix_nik_string(text):
"""Fix string khusus untuk NIK: O→0, I→1, S→5, B→8.
TIDAK mengubah angka ke angka lain (4/9, 1/7) karena bisa merusak NIK asli."""
result = []
for c in text:
if c in 'Oo': result.append('0')
elif c in 'IilL': result.append('1')
elif c in 'Ss': result.append('5')
elif c == 'B': result.append('8')
else: result.append(c)
return ''.join(result)
def fix_ocr_text(text):
"""Fix karakter yang salah di konteks TEKS: 0β†’O, 1β†’I, 5β†’S."""
return text.replace("0", "O").replace("1", "I").replace("5", "S")
def fix_abbreviated_address(text):
text = re.sub(r"\bJL([A-Z])", r"JL. \1", text)
text = re.sub(r"\bJEND\.?\s*([A-Z])", r"JEND. \1", text)
text = re.sub(r"\bJL\s*([A-Z]{2,})", r"JL. \1", text)
text = re.sub(r"\bJln\.?\s*", "JL. ", text)
text = re.sub(r"\bjl\.?\s*", "JL. ", text)
text = re.sub(r"([A-Z]{2,})NO\.?\s*(\d)", r"\1 NO. \2", text)
text = re.sub(r"([A-Z]{2,})\.(\d)", r"\1 NO. \2", text)
text = re.sub(r"\bNO\.?\s*(\d)", r"NO. \1", text)
return text
def fuzzy_city(ocr_text):
"""Cocokkan nama kota OCR dengan database kota Indonesia."""
if not ocr_text or len(ocr_text) < 3:
return ocr_text
matches = get_close_matches(ocr_text.upper(), KOTA_DB, n=1, cutoff=0.6)
return matches[0] if matches else ocr_text
def validate_nik(nik):
"""Validasi NIK Indonesia: 16 digit, kode provinsi valid, tanggal valid."""
if len(nik) != 16 or not nik.isdigit():
return False
if nik[:2] not in PROVINSI_CODES:
return False
day = int(nik[6:8])
if not (1 <= day <= 71):
return False
month = int(nik[8:10])
if not (1 <= month <= 12):
return False
return True
# ============================================================
# DUAL-PIPELINE OCR
# ============================================================
def run_ocr_with_confidence(img):
"""Jalankan OCR dan return lines dengan confidence score."""
results = ocr.ocr(img, cls=True)
if not results or not results[0]:
return [], 0.0
lines = []
total_conf = 0
count = 0
for line in results[0]:
text = line[1][0].strip().upper()
conf = line[1][1]
if conf < 0.4: # Filter noise
continue
text = text.replace("?", "7").replace("!", "1")
lines.append({"text": text, "conf": conf, "box": line[0]})
total_conf += conf
count += 1
avg_conf = total_conf / count if count > 0 else 0
return lines, avg_conf
def score_extracted_data(data, lines, avg_conf):
score = 0
nik = data.get("nik", "-")
if validate_nik(nik):
score += 45
elif nik != "-" and len(re.sub(r"\D", "", nik)) == 16:
score += 35
field_weights = {
"nama": 16,
"ttl": 8,
"jk": 5,
"alamat": 10,
"rtrw": 5,
"keldesa": 4,
"kecamatan": 4,
"agama": 3,
"pekerjaan": 3,
"berlakuHingga": 2,
}
for field, weight in field_weights.items():
value = data.get(field, "-")
if value and value != "-":
score += weight
score += min(8, len(lines) * 0.6)
score += min(8, avg_conf * 8)
return min(100, round(score, 2))
def evaluate_ocr_candidate(lines, avg_conf, label):
data = extract_ktp_data(lines)
score = score_extracted_data(data, lines, avg_conf)
return {
"label": label,
"lines": lines,
"conf": avg_conf,
"data": data,
"score": score,
}
def is_good_enough(candidate):
data = candidate["data"]
nik = data.get("nik", "-")
if validate_nik(nik):
return True
if nik != "-" and len(re.sub(r"\D", "", nik)) == 16 and candidate["conf"] >= 0.35:
return True
return candidate["score"] >= 68
def best_candidate(candidates):
return max(candidates, key=lambda c: (c["score"], c["conf"], len(c["lines"])))
def dual_pipeline_ocr(image_bytes):
"""Dual-pipeline: jalankan 2 preprocessing, ambil hasil terbaik."""
nparr = np.frombuffer(image_bytes, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if img is None:
raise ValueError("Gambar tidak valid")
img = smart_resize(img)
img = deskew_image(img)
# Quality check
quality = check_image_quality(img)
candidates = []
# Path A: CLAHE + Sharpening
img_a = preprocess_path_a(img)
lines_a, conf_a = run_ocr_with_confidence(img_a)
candidate_a = evaluate_ocr_candidate(lines_a, conf_a, "clahe")
candidates.append(candidate_a)
if is_good_enough(candidate_a):
metadata = {
"ocr_passes": 1,
"best_pipeline": candidate_a["label"],
"best_score": candidate_a["score"],
"best_confidence": round(candidate_a["conf"], 4),
}
return candidate_a["lines"], quality, metadata, candidate_a["data"]
# Path B: Adaptive Threshold (fallback untuk foto jelek)
img_b = preprocess_path_b(img)
lines_b, conf_b = run_ocr_with_confidence(img_b)
candidate_b = evaluate_ocr_candidate(lines_b, conf_b, "threshold")
candidates.append(candidate_b)
# Pilih yang terbaik, atau merge
if conf_a >= conf_b:
primary, secondary = lines_a, lines_b
else:
primary, secondary = lines_b, lines_a
# Merge: tambahkan baris dari secondary yang tidak ada di primary
primary_texts = {l["text"] for l in primary}
for line in secondary:
if line["text"] not in primary_texts and line["conf"] > 0.6:
primary.append(line)
merged_conf = max(conf_a, conf_b)
candidates.append(evaluate_ocr_candidate(primary, merged_conf, "merged"))
current_best = best_candidate(candidates)
if current_best["data"].get("nik", "-") == "-":
h, w = img.shape[:2]
top_region = img[:max(1, int(h * 0.45)), 0:w]
top_a = preprocess_path_a(top_region)
lines_top, conf_top = run_ocr_with_confidence(top_a)
top_candidate = evaluate_ocr_candidate(lines_top, conf_top, "nik_region")
candidates.append(top_candidate)
if top_candidate["data"].get("nik", "-") != "-":
combined_lines = list(current_best["lines"])
combined_texts = {l["text"] for l in combined_lines}
for line in lines_top:
if line["text"] not in combined_texts:
combined_lines.append(line)
candidates.append(evaluate_ocr_candidate(combined_lines, max(current_best["conf"], conf_top), "merged_nik_region"))
current_best = best_candidate(candidates)
if not is_good_enough(current_best) and current_best["score"] < 45:
for label, rotate_code in [
("rotate_90_clockwise", cv2.ROTATE_90_CLOCKWISE),
("rotate_90_counterclockwise", cv2.ROTATE_90_COUNTERCLOCKWISE),
]:
rotated = cv2.rotate(img, rotate_code)
rotated_a = preprocess_path_a(rotated)
lines_r, conf_r = run_ocr_with_confidence(rotated_a)
candidates.append(evaluate_ocr_candidate(lines_r, conf_r, label))
current_best = best_candidate(candidates)
metadata = {
"ocr_passes": len(candidates),
"best_pipeline": current_best["label"],
"best_score": current_best["score"],
"best_confidence": round(current_best["conf"], 4),
}
return current_best["lines"], quality, metadata, current_best["data"]
# ============================================================
# SPATIAL HELPERS
# ============================================================
def extract_value_after_colon(text):
"""Ambil teks setelah ':' atau ';'. Jika tidak ada, kembalikan asli."""
match = re.search(r'[:;]\s*(.+)', text)
return match.group(1).strip() if match else text.strip()
def sort_lines_by_y(ocr_lines):
"""Urutkan baris OCR berdasarkan posisi Y (atas ke bawah)."""
def get_y(line):
box = line["box"]
return (box[0][1] + box[2][1]) / 2
return sorted(ocr_lines, key=get_y)
def merge_same_y_lines(sorted_lines, y_threshold=15):
"""Gabungkan baris yang posisi Y-nya berdekatan (satu baris visual)."""
if not sorted_lines:
return []
merged = []
group = [sorted_lines[0]]
for i in range(1, len(sorted_lines)):
prev_y = (group[-1]["box"][0][1] + group[-1]["box"][2][1]) / 2
curr_y = (sorted_lines[i]["box"][0][1] + sorted_lines[i]["box"][2][1]) / 2
if abs(curr_y - prev_y) < y_threshold:
group.append(sorted_lines[i])
else:
group.sort(key=lambda l: (l["box"][0][0] + l["box"][1][0]) / 2)
text = " ".join([l["text"] for l in group])
conf = sum(l["conf"] for l in group) / len(group)
merged.append({"text": text, "conf": conf, "box": group[0]["box"]})
group = [sorted_lines[i]]
if group:
group.sort(key=lambda l: (l["box"][0][0] + l["box"][1][0]) / 2)
text = " ".join([l["text"] for l in group])
conf = sum(l["conf"] for l in group) / len(group)
merged.append({"text": text, "conf": conf, "box": group[0]["box"]})
return merged
# ============================================================
# DATA EXTRACTION (LABEL-PATTERN REGEX β€” no spatial ordering)
# ============================================================
def fix_date_string(raw):
"""Perbaiki karakter non-angka dalam string tanggal: O→0, I→1, S→5, l→1."""
return re.sub(r"[OoIlSs]", lambda m: {"O":"0","o":"0","I":"1","l":"1","S":"5","s":"5"}[m.group()], raw)
def extract_ktp_data(ocr_lines):
data = {
"nik": "-", "nama": "-", "ttl": "-", "jk": "-", "goldarah": "-",
"alamat": "-", "rtrw": "-", "keldesa": "-", "kecamatan": "-",
"agama": "-", "status": "-", "pekerjaan": "-",
"kewarganegaraan": "-", "berlakuHingga": "-",
}
if not ocr_lines:
return data
sorted_lines = sort_lines_by_y(ocr_lines)
merged = merge_same_y_lines(sorted_lines)
raw_lines = [l["text"] for l in merged]
# Gabungkan semua teks menjadi satu string bersih untuk regex global
full_text = " ".join(raw_lines)
# Versi full_text dengan fix teks (0β†’O, 1β†’I) untuk field berbasis teks
full_text_fixed = fix_ocr_text(full_text)
# ── Helper ──────────────────────────────────────────────────────────────
def search(patterns, text=full_text, flags=re.IGNORECASE):
"""Coba daftar regex berurutan, return group(1) pertama yang match."""
for p in patterns:
m = re.search(p, text, flags)
if m:
return m.group(1).strip()
return None
# ── NIK ─────────────────────────────────────────────────────────────────
# Gunakan fix_nik_string: hanya fix huruf→angka, tidak ubah angka→angka
nik_raw = re.sub(r"\s", "", full_text)
nik_fixed = fix_nik_string(nik_raw)
m = re.search(r"(\d{16})", nik_fixed)
if m:
data["nik"] = m.group(1)
# ── NAMA ─────────────────────────────────────────────────────────────────
# Cari dengan label dulu (lebih akurat), lalu fallback spatial (baris setelah NIK)
nama_val = search([
r"(?:NAMA|NARNA|N[A4]M[A4]|LAMA|L[A4]M[A4])\s*[:.]*\s*([A-Z][A-Z\s.,'-]{2,49}?)\s*(?=TEMPAT|TGL|LAHIR|JENIS|KELAMIN|LAKI|PEREMPUAN|GOL|DARAH|ALAMA?T|BERLAKU|$)",
# Fallback: cari pola 'Nama : XXX' secara lebih longgar
r"N[A4A]M[A4A]\s*[:.]+\s*([A-Z][A-Z\s.'-]{2,49})",
], full_text_fixed)
if nama_val:
nama_val = re.sub(r"[^A-Z\s.'-]", "", nama_val).strip()
if 2 < len(nama_val) <= 50:
data["nama"] = nama_val
# Fallback spasial: baris pertama setelah NIK yang hanya berisi huruf
if data["nama"] == "-":
nik_idx = next((i for i, l in enumerate(raw_lines) if re.search(r"\d{14,16}", re.sub(r"\s","",l))), -1)
if nik_idx != -1:
for candidate in raw_lines[nik_idx+1:nik_idx+3]:
c = re.sub(r"(?i)(nama|lama|n[a4]m[a4])[\s:.]?", "", candidate).strip()
c = re.sub(r"[^A-Z\s.'-]", "", c.upper()).strip()
if 3 < len(c) <= 50 and not any(kw in c for kw in ["LAHIR","JENIS","LAKI","ALAMA","RT","RW","JL","AGAMA","KAWIN"]):
data["nama"] = c
break
# ── TTL ──────────────────────────────────────────────────────────────────
# Tangkap tanggal yang toleran terhadap O/I/S, lalu fix
ttl_pat = r"(?:TEMPAT[/\s]*TGL[/\s]*LAHIR|TGL\s*LAHIR|LAHIR|TMPTTL|T\.?T\.?L\.?)\s*[:.]*\s*(.*?)(?=JENIS|KELAMIN|LAKI|PEREMPUAN|GOL|DARAH|ALAMA?T|$)"
m = re.search(ttl_pat, full_text, re.IGNORECASE)
if m:
raw_ttl = m.group(1).strip()
# Cari tanggal toleran: digit atau O,I,S,l
date_m = re.search(r"([0-9OIlS]{2}[-./]?[0-9OIlS]{2}[-./]?[0-9OIlS]{4})", raw_ttl, re.IGNORECASE)
if date_m:
clean_date = fix_date_string(date_m.group(1))
clean_date = re.sub(r"[./]", "-", clean_date)
# Kota: teks alfabet sebelum tanggal
city_part = raw_ttl[:date_m.start()].strip()
city_part = re.sub(r"[^A-Z\s]", "", fix_ocr_text(city_part)).strip()
city = fuzzy_city(city_part) if city_part else ""
data["ttl"] = f"{city}, {clean_date}" if city else clean_date
# Fallback: cari tanggal di mana saja
if data["ttl"] == "-":
date_m = re.search(r"([0-9OIlS]{2}[-][0-9OIlS]{2}[-][0-9OIlS]{4})", full_text, re.IGNORECASE)
if date_m and "BERLAKU" not in full_text[max(0, date_m.start()-10):date_m.start()]:
data["ttl"] = fix_date_string(date_m.group(1))
# ── JENIS KELAMIN & GOL DARAH ────────────────────────────────────────────
for line in raw_lines:
uline = line.upper()
if ("PEREMPUAN" in uline or "LAKI" in uline) and data["jk"] == "-":
data["jk"] = "PEREMPUAN" if "PEREMPUAN" in uline else "LAKI-LAKI"
# Gol darah: cari di dekat konteks GOL/DARAH, "0" β†’ "O"
if data["goldarah"] == "-":
gd = re.search(r"(?:GOL|DARAH)[^A-Z0]*([ABO0]{1,2})\b", uline)
if gd:
val = gd.group(1).replace("0", "O")
if val in ["A", "B", "AB", "O"]:
data["goldarah"] = val
# Fallback Gol Darah: dari akhir baris JK
if data["goldarah"] == "-":
for line in raw_lines:
parts = line.upper().split()
if parts and parts[-1].replace("0","O") in ["A","B","AB","O"]:
if "LAKI" in line or "PEREMPUAN" in line:
data["goldarah"] = parts[-1].replace("0","O")
break
# ── ALAMAT ───────────────────────────────────────────────────────────────
# Multi-barrier lookahead + max 100 karakter
alamat_val = search([
r"(?:ALAMA?T|ALAM[A4]T)\s*[:.]*\s*([A-Z0-9\s.,'/\-]{5,100}?)\s*(?=R[TW7][\s/]|RT[\s/]|RW[\s/]|KEL|DESA|KEC|AGAMA|STATUS|PEKERJAAN|$)",
])
if alamat_val:
data["alamat"] = fix_abbreviated_address(alamat_val.strip())
# Fallback: cari baris yang punya keyword jalan
if data["alamat"] == "-":
for line in raw_lines:
if re.search(r"\b(JL\.?|JLN\.?|GANG|GG\.?|JEND)\b", line, re.IGNORECASE):
data["alamat"] = fix_abbreviated_address(re.sub(r"(?i)^alama?t\s*[:.]*\s*", "", line).strip())
break
# ── RT/RW ────────────────────────────────────────────────────────────────
m = re.search(r"(\d{1,3})\s*[/|\\]\s*(\d{1,3})", full_text)
if m:
data["rtrw"] = f"{m.group(1).zfill(3)}/{m.group(2).zfill(3)}"
# ── KELURAHAN/DESA ────────────────────────────────────────────────────────
keldesa_val = search([
r"(?:KEL\.?/?DESA|KELURAHAN|DESA)\s*[:.]*\s*([A-Z][A-Z\s.-]{2,49}?)\s*(?=KEC|KABUPATEN|KOTA|PROVINSI|AGAMA|$)",
], full_text_fixed)
if keldesa_val:
keldesa_val = re.sub(r"[^A-Z\s.-]", "", keldesa_val).strip()
if len(keldesa_val) > 2:
data["keldesa"] = keldesa_val
# ── KECAMATAN ─────────────────────────────────────────────────────────────
kec_val = search([
r"(?:KECAMATAN|KEC\.?)\s*[:.]*\s*([A-Z][A-Z\s.-]{2,49}?)\s*(?=KABUPATEN|KOTA|PROVINSI|AGAMA|STATUS|$)",
], full_text_fixed)
if kec_val:
kec_val = re.sub(r"[^A-Z\s.-]", "", kec_val).strip()
if len(kec_val) > 2:
data["kecamatan"] = kec_val
# ── AGAMA ─────────────────────────────────────────────────────────────────
# Fuzzy: toleran terhadap J→I, 4→A, dll.
AGAMA_LIST = ["ISLAM","KRISTEN","PROTESTAN","KATHOLIK","KATOLIK","HINDU","BUDHA","BUDDHA","KONGHUCU"]
for ag in AGAMA_LIST:
# Izinkan 1 karakter berbeda (misalnya J vs I di JSLAM)
if ag in full_text_fixed:
data["agama"] = ag
break
if data["agama"] == "-":
# Fuzzy: cari sub-string yang mirip dengan toleransi 1 karakter
for ag in AGAMA_LIST:
pattern = "".join(f"[{c}{c.lower()}{'J' if c=='I' else ''}{'4' if c=='A' else ''}{'1' if c=='I' else ''}]" for c in ag)
if re.search(pattern, full_text_fixed, re.IGNORECASE):
data["agama"] = ag
break
# ── STATUS ────────────────────────────────────────────────────────────────
sm = re.search(r"\b(BELUM\s*KAWIN|KAWIN|CERAI\s*HIDUP|CERAI\s*MATI)\b", full_text)
if sm:
v = sm.group(0)
data["status"] = "BELUM KAWIN" if "BELUM" in v else re.sub(r"\s+", " ", v)
# ── PEKERJAAN ─────────────────────────────────────────────────────────────
JOBS = [
"PELAJAR/MAHASISWA","PELAJAR","MAHASISWA","WIRASWASTA",
"KARYAWAN SWASTA","KARYAWAN","PEGAWAI NEGERI SIPIL","PEGAWAI NEGERI",
"BURUH","MENGURUS RUMAH TANGGA","BELUM/TIDAK BEKERJA","GURU","DOSEN",
"PNS","TNI","POLRI","PEDAGANG","PETANI","NELAYAN","DOKTER","PERAWAT",
]
for line in raw_lines:
for job in JOBS:
if job in line.upper():
data["pekerjaan"] = job
break
if data["pekerjaan"] != "-":
break
# ── KEWARGANEGARAAN ───────────────────────────────────────────────────────
km = re.search(r"\b(WNI|WN\s*I|W\s*N\s*I|WN1|WNA)\b", full_text)
if km:
data["kewarganegaraan"] = "WNA" if "A" in km.group(0) else "WNI"
# ── BERLAKU HINGGA ────────────────────────────────────────────────────────
if re.search(r"\bSEUMUR\s*HIDUP\b", full_text):
data["berlakuHingga"] = "SEUMUR HIDUP"
else:
bh = re.search(r"BERLAKU\s*HINGGA\s*[:.]*\s*([0-9OIlS]{2}[-][0-9OIlS]{2}[-][0-9OIlS]{4})", full_text, re.IGNORECASE)
if bh:
data["berlakuHingga"] = fix_date_string(bh.group(1))
# ── CLEANUP ───────────────────────────────────────────────────────────────
for k in data:
if data[k] and data[k] != "-":
data[k] = re.sub(r"^[:\-\s.,]+|[:\-\s.,]+$", "", data[k]).strip()
if not data[k]:
data[k] = "-"
return data
# ============================================================
# API ENDPOINT
# ============================================================
@app.post("/scan-ktp")
async def scan_ktp(file: UploadFile = File(...)):
try:
image_bytes = await file.read()
ocr_lines, quality_score, metadata, extracted_data = dual_pipeline_ocr(image_bytes)
if extracted_data["nik"] == "-" and extracted_data["nama"] == "-":
return {
"status": "error",
"message": "NIK belum terbaca. Coba foto ulang dengan area NIK lebih dekat, terang, dan tidak terpotong.",
"quality_score": round(quality_score, 2),
"ocr_metadata": metadata,
}
warning = None
if extracted_data["nik"] == "-":
warning = "NIK belum terbaca, data perlu dicek manual sebelum disimpan."
elif quality_score < 30 or metadata.get("best_score", 0) < 55:
warning = "Kualitas foto kurang baik, beberapa data mungkin tidak akurat."
result = {
"status": "success",
"data": extracted_data,
"quality_score": round(quality_score, 2),
"ocr_metadata": metadata,
}
if warning:
result["warning"] = warning
return result
except ValueError as e:
return {"status": "error", "message": str(e)}
except Exception as e:
import traceback
return {
"status": "error",
"message": f"Terjadi kesalahan internal. Detail: {str(e)}",
"traceback": traceback.format_exc(),
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)