OCR-KTP / app.py
rieffs's picture
Update app.py
b77a642 verified
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()