Spaces:
Sleeping
Sleeping
| 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=["*"], | |
| ) | |
| 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 | |
| # ============================================================ | |
| 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) | |