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)