import gradio as gr import cv2 import easyocr import numpy as np import json # OCR Process - Inisialisasi Reader EasyOCR reader = easyocr.Reader(['id', 'en']) def detect_and_warp_ktp(input_img): """ Fungsi untuk mendeteksi tepi KTP, melakukan cropping, dan meluruskan perspektif gambar secara otomatis. """ clone = input_img.copy() gray = cv2.cvtColor(input_img, cv2.COLOR_RGB2GRAY) # Efek blur dan deteksi tepi untuk mengekstrak kontur kartu blurred = cv2.GaussianBlur(gray, (5, 5), 0) edged = cv2.Canny(blurred, 50, 200) # Contour Detection - Mencari 5 kontur terbesar (KTP biasanya berbentuk persegi panjang dominan) contours, _ = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours = sorted(contours, key=cv2.contourArea, reverse=True)[:5] ktp_contour = None for c in contours: peri = cv2.arcLength(c, True) approx = cv2.approxPolyDP(c, 0.02 * peri, True) # Jika kontur memiliki tepat 4 titik sudut, asumsikan sebagai KTP if len(approx) == 4: ktp_contour = approx break # Perspective Warp - Jika kartu terdeteksi, lakukan transformasi perspektif (Pelurusan) if ktp_contour is not None: pts = ktp_contour.reshape(4, 2) rect = np.zeros((4, 2), dtype="float32") # Mengurutkan koordinat titik sudut s = pts.sum(axis=1) rect[0] = pts[np.argmin(s)] # Top-left rect[2] = pts[np.argmax(s)] # Bottom-right diff = np.diff(pts, axis=1) rect[1] = pts[np.argmin(diff)] # Top-right rect[3] = pts[np.argmax(diff)] # Bottom-left # Standarisasi ukuran dimensi hasil crop (Rasio KTP standard) width = 600 height = 380 dst = np.array([ [0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]], dtype="float32") M = cv2.getPerspectiveTransform(rect, dst) warped = cv2.warpPerspective(clone, M, (width, height)) return warped # Jika gagal mendeteksi kartu, kembalikan gambar asli return input_img def process_ktp(input_img): if input_img is None: return None, "Mohon unggah gambar KTP." # TAHAP 1: Deteksi area kartu dan perbaikan perspektif secara dinamis img_cropped = detect_and_warp_ktp(input_img) # Standarisasi ukuran prapemrosesan akhir scale_percent = 150 width = int(img_cropped.shape[1] * scale_percent / 100) height = int(img_cropped.shape[0] * scale_percent / 100) img = cv2.resize(img_cropped, (width, height), interpolation=cv2.INTER_CUBIC) display_img = img.copy() gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) # TAHAP 2: Jalankan OCR pada gambar yang sudah bersih dan lurus ocr_results = reader.readtext(gray, detail=1, paragraph=False) extracted_data = { "NIK": "-", "Nama": "-", "Tempat/Tgl Lahir": "-" } # Toleransi piksel vertikal (Y) menjadi sangat konsisten pasca pelurusan y_tolerance = 25 # TAHAP 3: Proses Ekstraksi Dinamis Berdasarkan Kedekatan Koordinat for i, (bbox, text, prob) in enumerate(ocr_results): text_clean = text.upper().strip() (tl, tr, br, bl) = bbox key_y_center = (tl[1] + bl[1]) / 2 key_x_max = tr[0] # --- DETEKSI NIK --- if "NIK" in text_clean: digits = ''.join(filter(str.isdigit, text_clean)) # Filtrasi Angka NIK if len(digits) >= 12: extracted_data["NIK"] = digits else: for sub_bbox, sub_text, _ in ocr_results: sub_y_center = (sub_bbox[0][1] + sub_bbox[3][1]) / 2 if abs(sub_y_center - key_y_center) < y_tolerance and sub_bbox[0][0] > key_x_max: sub_digits = ''.join(filter(str.isdigit, sub_text)) if len(sub_digits) >= 12: extracted_data["NIK"] = sub_digits cv2.rectangle(display_img, (int(sub_bbox[0][0]), int(sub_bbox[0][1])), (int(sub_bbox[2][0]), int(sub_bbox[2][1])), (0, 255, 0), 2) # --- DETEKSI NAMA --- elif "NAMA" in text_clean and not "IBU" in text_clean: for sub_bbox, sub_text, _ in ocr_results: sub_y_center = (sub_bbox[0][1] + sub_bbox[3][1]) / 2 if abs(sub_y_center - key_y_center) < y_tolerance and sub_bbox[0][0] > key_x_max: extracted_data["Nama"] = sub_text.replace(":", "").strip() cv2.rectangle(display_img, (int(sub_bbox[0][0]), int(sub_bbox[0][1])), (int(sub_bbox[2][0]), int(sub_bbox[2][1])), (0, 255, 0), 2) # --- DETEKSI TEMPAT/TGL LAHIR --- elif "TEMPAT" in text_clean or "LAHIR" in text_clean or "TGL" in text_clean: ttl_parts = [] for sub_bbox, sub_text, _ in ocr_results: sub_y_center = (sub_bbox[0][1] + sub_bbox[3][1]) / 2 if abs(sub_y_center - key_y_center) < y_tolerance and sub_bbox[0][0] > key_x_max: clean_part = sub_text.replace(":", "").strip() # Cleaning Text if clean_part and clean_part not in ttl_parts: ttl_parts.append(clean_part) cv2.rectangle(display_img, (int(sub_bbox[0][0]), int(sub_bbox[0][1])), (int(sub_bbox[2][0]), int(sub_bbox[2][1])), (0, 255, 0), 2) if ttl_parts: extracted_data["Tempat/Tgl Lahir"] = " ".join(ttl_parts) # TAHAP 4: Post-Processing & Pembersihan Simbol Sisa final_json_dict = {} for key, val in extracted_data.items(): if val.startswith(":") or val.startswith("."): val = val[1:].strip() # Mapping nama key menjadi lowercase untuk standarisasi JSON API if key == "NIK": final_json_dict["nik"] = val elif key == "Nama": final_json_dict["nama"] = val elif key == "Tempat/Tgl Lahir": final_json_dict["tempat_tgl_lahir"] = val # TAHAP 5: Konversi Dictionary ke Format Valid String JSON dengan Indentasi 4 Spasi json_output_string = json.dumps(final_json_dict, indent=4, ensure_ascii=False) return display_img, json_output_string # --- ANTARMUKA GRADIO --- with gr.Blocks(title="KTP Indonesia OCR Scanner") as demo: gr.Markdown("# 🪪 Indonesia ID Card (KTP) OCR Scanner") gr.Markdown("Aplikasi menggunakan EasyOCR dengan Deteksi Area Otomatis & Luaran Berformat JSON Standardized.") with gr.Row(): with gr.Column(): # MENAMBAHKAN parameter height agar ukuran kotak unggah terkunci (tidak melar) input_image = gr.Image(label="Unggah Foto KTP", height=350) btn = gr.Button("Ekstrak Data", variant="primary") with gr.Column(): # MENAMBAHKAN parameter height yang sama agar visualisasi hasil simetris output_image = gr.Image(label="Visualisasi Hasil Scan", height=350) # Mengunci jumlah baris tampilan agar layout tidak melompat saat teks JSON masuk output_results = gr.Textbox(label="Data Terdeteksi (JSON format)", lines=10, max_lines=10) btn.click(fn=process_ktp, inputs=input_image, outputs=[output_image, output_results]) if __name__ == "__main__": demo.launch()